// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver // ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง): // 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY // (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง) // 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server // ตรวจ HMAC+exp ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → // "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket // (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) // ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env // DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ // endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] // (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว import { docverError } from "@/lib/document-versioning"; import { addinTicketSecret, parseTicketTokenBody, ticketToDocverClaims, verifyAddinTicket, } from "@/lib/addin-ticket"; import { issueDocverToken, parseTokenRequestBody, requireDocverApiKey, type DocverTokenClaims, } from "@/lib/docver-token"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */ const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ"; export async function POST(request: Request) { return request.headers.get("x-api-key") !== null ? postWithApiKey(request) : postWithTicket(request); } /** ทางเข้า 1 (เดิม) — e-Court server-to-server */ 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); } const parsed = parseTokenRequestBody(raw); if (!parsed.ok) return docverError(parsed.error, 400); return issueToken(parsed.claims); } /** ทางเข้า 2 — 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) { // missing = ไม่มี ticket มาเล่น ๆ (401 เดียวกับตอน verify ไม่ผ่าน) // invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401); } const verified = verifyAddinTicket(parsed.ticketData, parsed.hmacHex, addinTicketSecret()); if (!verified.ok) return docverError(TICKET_DENIED, 401); return issueToken(ticketToDocverClaims(verified.ticket)); } /** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */ async function issueToken(claims: DocverTokenClaims): Promise { // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); // const secret = // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; if (secret === undefined || secret === "") { return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); } const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS); const ttlSeconds = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600; const accessToken = issueDocverToken(claims, secret, ttlSeconds); return new Response( JSON.stringify({ access_token: accessToken, token_type: "Bearer", expires_in: ttlSeconds, }), { headers: { "content-type": "application/json; charset=utf-8" } }, ); }