// PLAN-ADDIN-AUTH.md §4 — ticket ของ add-in (custom properties `ticket_data` + // `HmacSha256` ที่ e-Court ผ่านกลุ่ม template stamp ลงไฟล์) — ส่วน pure ที่ // ใช้ร่วมกันทั้งฝั่ง server (lib/addin-ticket.ts — route /api/documents/token) // และฝั่ง client ของ add-in (wordaddin/services/ticketService.ts) // // กติกา (docs/requirement/addin-workflow.md + lib/lib-utils.ts generateHmacSha256 // ของตัว stamp ฝั่ง template): // - ticket_data เป็น JSON string ตามที่ stamp ไว้เป๊ะ (key เรียงตามตัวอักษร // หลังเติม exp) — **HMAC ต้องคำนวณบน string ดิบนี้ ห้าม trim/normalize** // - HmacSha256 เป็น hex lowercase 64 ตัว (การเทียบ normalize เป็น lowercase // ให้ก่อน — ฝั่ง stamp ตัวพิมพ์ใหญ่ก็ผ่านได้) // - ห้าม import อะไรที่เป็น platform-specific (node:crypto/crypto.subtle) — // module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word โดยส่ง // ตัวคำนวณ HMAC เข้ามาทาง parameter แทน /** ticket ที่แกะจาก ticket_data — ชื่อ field ตามที่ e-Court กำหนด (requirement) */ export interface DocverTicket { /** หมดอายุเมื่อไร — unix epoch วินาที */ exp: number; /** fileId ของ e-Court — เป็น docId ของทุก operation ของกลุ่ม docver ตรง ๆ */ fid: string; /** template ต้นทาง — optional (ไม่มี = null) */ tid: string | null; /** รหัสผู้ใช้ → claim `userid` */ uid: string; /** ชื่อผู้ใช้ → claim `username` */ uname: string; } /** ความยาว cap ต่อ field — ตาม parseTokenRequestBody ของ /api/documents/token */ const TICKET_LIMITS = { uid: 100, uname: 200, fid: 200, tid: 100, } as const; /** allowlist ของ ticket_data — field อื่นปนมา = reject (กัน typo เงียบ ๆ ตาม convention ของ repo) */ const TICKET_FIELDS = ["exp", "fid", "tid", "uid", "uname"] as const; export type DocverTicketParseResult = | { ok: true; ticket: DocverTicket } | { ok: false; error: string }; /** * แกะ + ตรวจ ticket จาก string ดิบของ property `ticket_data` * คืนค่าที่ trim แล้ว (uid/uname/fid/tid) — exp เป็น number ตรง ๆ */ export function parseDocverTicket(raw: string): DocverTicketParseResult { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { ok: false, error: "ticket_data ไม่ใช่ JSON ที่ถูกต้อง" }; } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return { ok: false, error: "ticket_data ต้องเป็น JSON object" }; } const record = parsed as Record; for (const key of Object.keys(record)) { if (!(TICKET_FIELDS as readonly string[]).includes(key)) { return { ok: false, error: `ticket_data มี field ที่ไม่รู้จัก '${key}' (รับเฉพาะ ${TICKET_FIELDS.join(", ")})`, }; } } const exp = record.exp; if (typeof exp !== "number" || !Number.isSafeInteger(exp)) { return { ok: false, error: "ticket_data ต้องมี exp เป็นจำนวนเต็ม (unix วินาที)" }; } const field = (name: "uid" | "uname" | "fid"): string | null => { const value = record[name]; if (typeof value !== "string" || value.trim() === "") { return null; } const trimmed = value.trim(); return trimmed.length <= TICKET_LIMITS[name] ? trimmed : null; }; const uid = field("uid"); if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" }; const uname = field("uname"); if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" }; const fid = field("fid"); if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" }; let tid: string | null = null; if (record.tid !== undefined) { if (typeof record.tid !== "string") { return { ok: false, error: "ticket_data ของ tid ต้องเป็น string" }; } const trimmed = record.tid.trim(); if (trimmed !== "") { if (trimmed.length > TICKET_LIMITS.tid) { return { ok: false, error: "ticket_data ของ tid ยาวเกิน 100 ตัวอักษร" }; } tid = trimmed; } } return { ok: true, ticket: { exp, fid, tid, uid, uname } }; } /** exp หมดอายุไหม — exp เท่ากับ now พอดีก็ถือว่าหมดแล้ว */ export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean { return exp <= nowSec; } /** * normalize ค่า digest ที่ได้รับ (property `HmacSha256` / body field) — * trim + lowercase แล้วตรวจว่าเป็น hex ครบ 64 ตัว คืน null เมื่อรูปไม่ใช่ */ export function normalizeHexDigest(value: string): string | null { const normalized = value.trim().toLowerCase(); return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null; } /** * เทียบ hex digest สองค่าแบบ constant-time (best-effort ของ pure JS — * ฝั่ง server จริงใช้ node:crypto timingSafeEqual ผ่าน lib/addin-ticket.ts * ตัวนี้ใช้กับ UX gate ฝั่ง client เป็นหลัก) — ค่าที่ส่งเข้าควร normalize แล้ว */ export function timingSafeEqualHex(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; } /** ตัวคำนวณ HMAC-SHA256 คืนค่าเป็น hex lowercase — สองฝั่งฉีดของตัวเอง (Web Crypto / node:crypto) */ export type HmacSha256Hex = (message: string, secret: string) => Promise;