// POST /api/auth/token — **ทางเข้า token เดียวของทั้งระบบ** (2026-11-09 รอบสอง: // ถอด route คู่ทั้งหมด — /api/documents/token และ /api/note/auth/token ไม่มีแล้ว // ผู้เรียกเก่าต้องย้ายมาเส้นนี้ · PLAN-DOCVER.md §auth + PLAN-SSO.md §0.6) // ทางเข้า 3 แบบ (header x-api-key เป็นตัวตั้ง): // 1) e-Court server-to-server (docver): header "x-api-key" เทียบ env DOCVER_API_KEY // (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims เดิม {userid, username, fid, // template?} → docver-token อายุตาม env DOCVER_TOKEN_TTL_SECONDS (default // 604800 = 7 วัน — token อายุยาวแทนระบบ refresh/renew ของ add-in) // 2) e-Court mint ลิงก์ SSO ของแอป note: x-api-key เช่นเดียวกัน + body // purpose:"login" (+ttl_seconds 60–600 default 300) → login-token อายุสั้น // ไว้ฝังเป็น param t= ของลิงก์ /note?t=… (credential ใน URL ต้องอายุสั้น — D5) // · purpose ค่าอื่น = 400 // 3) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server // ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → // "super_secret", lib/addin-ticket.ts) แล้วออก docver-token จาก claims ของ // ticket เอง (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in — client // ไม่ตรวจ HMAC เองแล้วตาม PLAN-SSO.md D13) · contract 2026-11-04: 400 = // ฟอร์แม็ตพัง (รวม missing) / 401 = ลายเซ็นไม่ถูก / 403 = ลายเซ็นถูกแต่ ticket // หมดอายุ (client ที่มี JWT เดิมของ fid นี้ใน session ใช้ต่อเอง) // ทุกทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET · response shape // {access_token, token_type, expires_in} ตรง OAuth2 — วันหนึ่งเพิ่ม grant // refresh_token ได้โดยไม่ break · ไม่ import @/db · ห้าม log ค่า key/body ดิบ import { docverError } from "@/lib/document-versioning"; import { addinTicketSecret, parseTicketTokenBody, ticketToDocverClaims, verifyAddinTicketHmac, } from "@/lib/addin-ticket"; import { isTicketExpired } from "@/utils/docver-ticket"; import { issueDocverToken, parseTokenRequestBody, requireDocverApiKey, type DocverTokenClaims, } from "@/lib/docver-token"; import { parseLoginTokenBody } from "@/lib/note-auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด) กัน oracle · หมดอายุแยกเป็น 403 */ const TICKET_DENIED = "ticket ไม่ถูกต้อง"; /** 403 ของทางเข้า ticket — ลายเซ็นถูกแต่หมดอายุ (client มี JWT เดิมใน session ก็ใช้ต่อได้) */ const TICKET_EXPIRED = "ticket ของ e-Court หมดอายุ"; export async function POST(request: Request) { return request.headers.get("x-api-key") !== null ? postWithApiKey(request) : postWithTicket(request); } /** ทางเข้า 1+2 — e-Court server-to-server (docver / mint login-token ลิงก์ note) */ async function postWithApiKey(request: Request): Promise { const apiKeyFail = requireDocverApiKey(request); if (apiKeyFail !== null) return apiKeyFail; let raw: unknown; try { raw = await request.json(); } catch { return docverError("body ต้องเป็น JSON", 400); } // แยกทางตาม purpose: "login" = mint login-token ลิงก์ note · ไม่ส่ง = docver // ตาม contract เดิมเป๊ะ (allowlist userid/username/fid/template) · ค่าอื่น = 400 if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) { const record = raw as Record; if (record.purpose !== undefined) { if (record.purpose !== "login") { return docverError("purpose รับเฉพาะ 'login'", 400); } // strip ออกก่อน parse — parseLoginTokenBody เป็น allowlist 4 field // (purpose อยู่ใน body = ไม่รู้จัก field) const { purpose: _purpose, ...body } = record; const parsed = parseLoginTokenBody(body); if (!parsed.ok) return docverError(parsed.error, 400); return issueToken( { userid: parsed.claims.userid, username: parsed.claims.username, fid: parsed.claims.fid, template: null, }, "login", parsed.ttlSeconds, ); } } const parsed = parseTokenRequestBody(raw); if (!parsed.ok) return docverError(parsed.error, 400); return issueToken(parsed.claims); } /** ทางเข้า 3 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */ async function postWithTicket(request: Request): Promise { let raw: unknown; try { raw = await request.json(); } catch { return docverError("body ต้องเป็น JSON", 400); } const parsed = parseTicketTokenBody(raw); if (!parsed.ok) { // contract 2026-11-04: ฟอร์แม็ตพัง = 400 ทั้ง missing/invalid (เดิม missing เป็น 401) return docverError(parsed.error, 400); } // ตรวจ HMAC ก่อนเสมอ (ตัวตนของผู้ถือไฟล์) — exp ไม่ใช่เกณฑ์ตัดสิทธิ์ ณ จุดนี้ const verified = verifyAddinTicketHmac(parsed.ticketData, parsed.hmacHex, addinTicketSecret()); if (!verified.ok) return docverError(TICKET_DENIED, 401); // ลายเซ็นถูกแต่ ticket หมดอายุ → 403 เสมอ (contract 2026-11-04 — เลิก requirement // ขั้น 7 ฉบับ 2026-10-28: ไม่ตรวจ hasVersionByUser อีกต่อไป เพราะ ticket ฝังอยู่ใน // ไฟล์ ใครได้ไฟล์ก็ได้ ticket การปล่อยให้ ticket เก่าแลก token ใหม่ได้ตลอดไป // เท่ากันคือปล่อยสิทธิ์ตามการครอบครองไฟล์ — ฝั่ง client ที่มี JWT ของ fid นี้ // ใน session ที่ยังไม่หมด (7 วัน) ใช้ต่อเอง ไม่มี = ขอไฟล์ใหม่จาก e-Court) if (isTicketExpired(verified.ticket.exp)) { return docverError(TICKET_EXPIRED, 403); } return issueToken(ticketToDocverClaims(verified.ticket)); } /** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทุกทาง — ส่วนกลาง */ async function issueToken( claims: DocverTokenClaims, purpose: "docver" | "login" = "docver", ttlSeconds?: number, ): Promise { // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); if (secret === undefined || secret === "") { return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); } // docver: TTL จาก env (default 7 วัน) · login: TTL จาก ttl_seconds ของ body // (60–600 สั้นตาม D5 — credential ใน URL ต้องอายุสั้น) let resolvedTtl: number; if (purpose === "login" && ttlSeconds !== undefined) { resolvedTtl = ttlSeconds; } else { const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS); resolvedTtl = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 604_800; } const accessToken = issueDocverToken(claims, secret, resolvedTtl, purpose); return new Response( JSON.stringify({ access_token: accessToken, token_type: "Bearer", expires_in: resolvedTtl, }), { headers: { "content-type": "application/json; charset=utf-8" } }, ); }