// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning) // - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key" // กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ // lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual, // ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ) // - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน — // ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer — // identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload // - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ // — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid // // ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้ // โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts) import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { docverError, type DocverError } from "@/lib/document-versioning"; /** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */ export type DocverTokenClaims = { userid: string; username: string; fid: string; template: string | null; }; function base64url(input: string): string { return Buffer.from(input, "utf8").toString("base64url"); } function decodeJsonSegment(segment: string): Record | null { let text: string; try { text = Buffer.from(segment, "base64url").toString("utf8"); } catch { return null; } try { const parsed = JSON.parse(text) as unknown; if ( parsed === null || typeof parsed !== "object" || Array.isArray(parsed) ) { return null; } return parsed as Record; } catch { return null; } } /** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */ export function issueDocverToken( claims: DocverTokenClaims, secret: string, ttlSeconds: number, ): string { const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); const iat = Math.floor(Date.now() / 1000); const payload = base64url( JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }), ); const signature = createHmac("sha256", secret) .update(`${header}.${payload}`) .digest("base64url"); return `${header}.${payload}.${signature}`; } /** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid * (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */ function claimsFromPayload( payload: Record, ): DocverTokenClaims | null { const { userid, username, fid, template } = payload; if (typeof userid !== "string" || userid === "") return null; if (typeof username !== "string" || username === "") return null; if (typeof fid !== "string" || fid === "") return null; if (template !== null && typeof template !== "string") return null; return { userid, username, fid, template }; } /** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") → * เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) → * exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */ export function verifyDocverToken( token: string, secret: string, ): { ok: true; claims: DocverTokenClaims } | { ok: false } { const segments = token.split("."); if (segments.length !== 3) return { ok: false }; const [headerSegment, payloadSegment, signatureSegment] = segments; const header = decodeJsonSegment(headerSegment); if (header === null || header.alg !== "HS256") return { ok: false }; const expected = createHmac("sha256", secret) .update(`${headerSegment}.${payloadSegment}`) .digest(); // ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง // (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง — // timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน) const provided = createHash("sha256") .update(Buffer.from(signatureSegment, "base64url")) .digest(); const expectedHash = createHash("sha256").update(expected).digest(); if (!timingSafeEqual(provided, expectedHash)) return { ok: false }; const payload = decodeJsonSegment(payloadSegment); if (payload === null) return { ok: false }; const exp = payload.exp; if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false }; if (exp <= Math.floor(Date.now() / 1000)) return { ok: false }; const claims = claimsFromPayload(payload); if (claims === null) return { ok: false }; return { ok: true, claims }; } /** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่ * env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้ * คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */ export function requireDocverApiKey(request: Request): Response | null { // เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file const envKey = process.env.DOCVER_API_KEY?.trim(); // const envKey = // "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54"; if (envKey === undefined || envKey === "") { return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401); } const provided = request.headers.get("x-api-key"); if (provided === null || provided === "") { return docverError("API key ไม่ถูกต้อง", 401); } const providedHash = createHash("sha256").update(provided).digest(); const envHash = createHash("sha256").update(envKey).digest(); return timingSafeEqual(providedHash, envHash) ? null : docverError("API key ไม่ถูกต้อง", 401); } /** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด / * หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */ const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ"; /** บังคับ Authorization: Bearer ของกลุ่ม docver — เรียกเป็นอย่างแรกของ * ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี * token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */ export function requireDocverBearer( request: Request, ): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } { const denied = () => ({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const; const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); // const secret = // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; if (secret === undefined || secret === "") return denied(); const header = request.headers.get("authorization"); if (header === null) return denied(); const match = /^Bearer\s+(\S+)$/i.exec(header); if (match === null) return denied(); const verified = verifyDocverToken(match[1], secret); if (!verified.ok) return denied(); return { ok: true, claims: verified.claims }; } /** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม * convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */ const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const; export function parseTokenRequestBody( raw: unknown, ): { ok: true; claims: DocverTokenClaims } | DocverError { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { ok: false, error: "body ต้องเป็น JSON object" }; } const record = raw as Record; for (const key of Object.keys(record)) { if (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) { return { ok: false, error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TOKEN_BODY_FIELDS.join(", ")})`, }; } } const rawUserid = record.userid; if (typeof rawUserid !== "string" || rawUserid.trim() === "") { return { ok: false, error: "ต้องระบุ userid (string)" }; } const userid = rawUserid.trim(); if (userid.length > 100) { return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" }; } const rawUsername = record.username; if (typeof rawUsername !== "string" || rawUsername.trim() === "") { return { ok: false, error: "ต้องระบุ username (string)" }; } const username = rawUsername.trim(); if (username.length > 200) { return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" }; } const rawFid = record.fid; if (typeof rawFid !== "string" || rawFid.trim() === "") { return { ok: false, error: "ต้องระบุ fid (string)" }; } const fid = rawFid.trim(); if (fid.length > 200) { return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" }; } const rawTemplate = record.template ?? null; if (rawTemplate !== null && typeof rawTemplate !== "string") { return { ok: false, error: "template ต้องเป็น string" }; } const template = rawTemplate?.trim() || null; if (template !== null && template.length > 100) { return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" }; } return { ok: true, claims: { userid, username, fid, template } }; }