// POST /api/auth/session — **ทางเข้าหลัก (และเดียว) ของแอป note** (2026-11-09 // รอบสอง: ถอด /api/note/auth/session เส้นเก่าออก — ผู้เรียกต้องย้ายมาเส้นนี้) — // แลก credential ของลิงก์ e-Court เป็น access_token (ฉบับ token 2026-10-30 ตามคำขอ // ผู้ใช้ "note ใช้แบบ add-in" — แทน GET e-court ที่ set cookie ซึ่งถอดแล้ว): // body {t | ticket_data+HmacSha256, fileUrl?, typeId?} → verify → ลงทะเบียนเอกสาร // N8 แบบ trusted ฝั่ง server เลย (fileId จาก fid ที่เซ็นมา) → ออก login-token // อายุ NOTE_SESSION_TTL (8 ชม.) ให้ client เก็บ localStorage "note-auth" // · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง // · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ import { NextResponse } from "next/server"; import { eq } from "drizzle-orm"; import { db } from "@/db"; import { files } from "@/db/schema"; import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket"; import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token"; import { ecourtExternalId } from "@/lib/ecourt-fid"; import { registerExternalFile } from "@/lib/external-import"; import { parseSessionExchangeBody } from "@/lib/note-auth"; import { NOTE_SESSION_TTL } from "@/lib/note-token"; import { hasStagedDoc, isStagedSource } from "@/lib/staged-docs"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; function json(body: unknown, status = 200): NextResponse { return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } }); } export async function POST(request: Request): Promise { let raw: unknown; try { raw = await request.json(); } catch { raw = null; } const parsed = parseSessionExchangeBody(raw); if (!parsed.ok) return json({ error: parsed.error }, 400); const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); if (secret === undefined || secret === "") { console.error("[sso] DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า"); return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500); } let identity: { userid: string; username: string; fid: string }; if (parsed.credential.kind === "jwt") { const verified = verifyDocverLoginToken(parsed.credential.token, secret); if (!verified.ok) { return json( { error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: verified.reason }, 401, ); } identity = verified.claims; } else { // ค่า default "super_secret" ของ addinTicketSecret() เป็นที่รู้กันทั้ง repo/ // .env.example — ใครก็ปลอม ticket ของ uid ใครก็ได้ จึงปฏิเสธใน production // (fail-closed — กลุ่ม docver/add-in อนุญาต default ตาม requirement อยู่แล้ว // คนละประตู) · **dev ยอมรับค่า default** เพื่อให้ทดสอบทาง ticket รอบเครื่องได้ // โดยไม่ต้องตั้ง env ใหม่ — เครื่องมือ dev (make-login-link/make-ticket-docx/ // dev-link) ก็ default ค่าเดียวกัน (review 2026-10-30 รอบสอง) if (process.env.NODE_ENV === "production" && addinTicketSecret() === "super_secret") { console.error("[sso] ticket secret ยังเป็นค่า default (ADDIN_TICKET_SECRET/SECRET) — ปฏิเสธทาง ticket"); return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500); } const verified = verifyAddinTicket( parsed.credential.ticketData, parsed.credential.hmacHex, addinTicketSecret(), ); if (!verified.ok) { return json({ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: "invalid" }, 401); } identity = { userid: verified.ticket.uid, username: verified.ticket.uname, fid: verified.ticket.fid }; } // N8: ลงทะเบียนเอกสารตอนแลก token เลย — ฝั่ง server เชื่อ fid จาก credential // ที่ verify แล้วเท่านั้น (trusted) ส่วน ?fileUrl พิมพ์เองบนหน้าเว็บไม่มีทาง // เข้ามาถึงจุดนี้ · ล้ม = ยังออก token ให้ + fileError ให้หน้าแรกแสดง (คู่ขนาน // พฤติกรรมสมัย cookie: session ตั้งก่อน แล้วค่อยแจ้ง error ของไฟล์) let fileId: string | undefined; let fileError: string | undefined; if (parsed.fileUrl !== undefined) { const result = await registerExternalFile(parsed.fileUrl, identity.fid, parsed.typeId, { trusted: true, }); if (result.ok) fileId = result.fileId; else fileError = result.error; } else { // N11 (PLAN-STAGED.md D5): ลิงก์ fid-only (เอกสาร staging — เนื้อส่งมาทาง POST // /api/note/files/stage แล้ว ลิงก์มีแค่ t=) — เช็คตั้งแต่ตอนแลก token เลย ไม่ปล่อย // เข้า viewer ไปเจอ 410 เอง · ใช้ hasStagedDoc (ไม่ยืด TTL ไม่ถือ Buffer — การอ่าน // จริงเกิดที่ viewer) · เอกสาร N8 ที่รู้จักแล้ว (pdf_url เป็น URL/path ปกติ) เข้าได้ // ทางเดียวกัน · ผู้ใช้ได้ token ตามปกติทุกกรณี try { const [row] = await db .select() .from(files) .where(eq(files.externalId, ecourtExternalId(identity.fid))) .limit(1); if (!row) { fileError = "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่"; } else if (isStagedSource(row.pdfUrl)) { if (hasStagedDoc(row.pdfUrl)) fileId = row.id; else fileError = "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"; } else { fileId = row.id; } } catch (err) { console.error("[sso] ค้นหาเอกสารของ fid ล้ม", err); fileError = "ค้นเอกสารไม่สำเร็จ — ลองอีกครั้ง"; } } const access_token = issueDocverToken( { userid: identity.userid, username: identity.username, fid: identity.fid, template: null }, secret, NOTE_SESSION_TTL, "login", ); // audit: เฉพาะ userid + เวลา — ห้าม log token/query ดิบ · strip อักขระควบคุม // ก่อนพิมพ์ (claims ไม่จำกัด charset — กันฝัง \n ปลอมบรรทัด log) const auditId = identity.userid.replace(/[\u0000-\u001f\u007f-\u009f]/g, ""); console.log(`[sso] e-Court login ok: userid=${auditId}`); return json({ access_token, token_type: "Bearer", expires_in: NOTE_SESSION_TTL, userid: identity.userid, username: identity.username, ...(fileId !== undefined ? { fileId } : {}), ...(fileError !== undefined ? { fileError } : {}), }); }