// PLAN-ADDIN-AUTH.md §3 — ตรวจ ticket ของ add-in ฝั่ง server (ทางเข้าที่สองของ // POST /api/documents/token) — secret อยู่ฝั่ง server อย่างเดียว เพราะทางเข้านี้ // ผู้เรียกคือ add-in ใน webview ของ Word ซึ่งแนบ x-api-key (server-to-server // ของ e-Court) มาไม่ได้ — ตัวพิสูจน์ตัวตนคือ ticket_data + HmacSha256 ที่ e-Court // stamp มากับไฟล์ (คำนวณ HMAC ตรงกับ lib/lib-utils.ts generateHmacSha256 // ของกลุ่ม template: hex lowercase บน string ดิบ) // // ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรัน // ได้โดยไม่ต้องตั้ง env อะไรเลย (pattern เดียวกับ lib/docver-token.ts) import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { isTicketExpired, normalizeHexDigest, parseDocverTicket, type DocverTicket, } from "@/utils/docver-ticket"; import type { DocverTokenClaims } from "@/lib/docver-token"; /** * secret ของ ticket — อ่านตอนเรียก ไม่ใช่ตอน module load * ลำดับ: ADDIN_TICKET_SECRET (ของกลุ่มนี้) → SECRET (env เดียวกับตัว stamp * ของกลุ่ม template — deployment เดียวกันมักตั้งค่านี้อยู่แล้ว) → "super_secret" * (ค่าตาม requirement — dev ใช้ได้ทันที แต่ production ต้องตั้งค่าจริงเพราะ * ค่าเดิมรู้กันหมด = ใครก็ปลอม ticket ได้) · ห้าม log ค่าดิบ */ export function addinTicketSecret(): string { return ( process.env.ADDIN_TICKET_SECRET?.trim() || process.env.SECRET?.trim() || "super_secret" ); } /** * ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา * (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle — * add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) */ export function verifyAddinTicket( ticketData: string, hmacHex: string, secret: string, nowSec: number = Math.floor(Date.now() / 1000), ): { ok: true; ticket: DocverTicket } | { ok: false } { const digest = normalizeHexDigest(hmacHex); if (digest === null) return { ok: false }; // เทียบแบบ timing-safe ตาม pattern verifyDocverToken — hash ทั้งสองฝั่ง // ด้วย SHA-256 ก่อนเพื่อให้ digest ยาวเท่ากันเสมอ (timingSafeEqual ไม่ throw) const expected = createHmac("sha256", secret).update(ticketData, "utf8").digest(); const expectedHash = createHash("sha256").update(expected).digest(); const providedHash = createHash("sha256").update(Buffer.from(digest, "hex")).digest(); if (!timingSafeEqual(providedHash, expectedHash)) return { ok: false }; const parsed = parseDocverTicket(ticketData); if (!parsed.ok) return { ok: false }; if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false }; return { ok: true, ticket: parsed.ticket }; } /** แปลง ticket → claims ของ access_token (uid→userid, uname→username, tid→template) */ export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims { return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid }; } /** body ของทางเข้า ticket — allowlist 2 field เป๊ะ (ชื่อเดียวกับ custom properties) */ const TICKET_BODY_FIELDS = ["ticket_data", "HmacSha256"] as const; export type TicketTokenBodyResult = | { ok: true; ticketData: string; hmacHex: string } | { ok: false; kind: "missing" | "invalid"; error: string }; /** * parse body ของ POST /api/documents/token ทางเข้า ticket * - `missing` = ไม่มี ticket มาเลย (route ตอบ 401 — คำเดียวกับตอน verify ไม่ผ่าน) * - `invalid` = รูปผิด (route ตอบ 400 ตาม convention กัน typo เงียบ ๆ) */ export function parseTicketTokenBody(raw: unknown): TicketTokenBodyResult { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { ok: false, kind: "invalid", error: "body ต้องเป็น JSON object" }; } const record = raw as Record; for (const key of Object.keys(record)) { if (!(TICKET_BODY_FIELDS as readonly string[]).includes(key)) { return { ok: false, kind: "invalid", error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TICKET_BODY_FIELDS.join(", ")})`, }; } } const read = (name: (typeof TICKET_BODY_FIELDS)[number]): string | null => { const value = record[name]; if (value === undefined || value === null || value === "") return null; // ไม่ส่ง = missing if (typeof value !== "string") return ""; // ส่งผิดชนิด = invalid return value; }; const ticketData = read("ticket_data"); if (ticketData === null) { return { ok: false, kind: "missing", error: "ต้องระบุ ticket_data (string)" }; } if (ticketData === "") { return { ok: false, kind: "invalid", error: "ticket_data ต้องเป็น string" }; } if (ticketData.length > 2048) { return { ok: false, kind: "invalid", error: "ticket_data ยาวเกินไป" }; } const hmacHex = read("HmacSha256"); if (hmacHex === null) { return { ok: false, kind: "missing", error: "ต้องระบุ HmacSha256 (string)" }; } if (hmacHex === "") { return { ok: false, kind: "invalid", error: "HmacSha256 ต้องเป็น string" }; } if (hmacHex.length > 128) { return { ok: false, kind: "invalid", error: "HmacSha256 ยาวเกินไป" }; } return { ok: true, ticketData, hmacHex }; }