eCourt-note/app/api/auth/token/route.ts
Your Name 5121970f31
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Failing after 30s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Failing after 1s
refactor(auth): consolidate auth under /api/auth/* endpoints
- ยุบ route auth ที่เคย copy กันจน drift (flow N11 หายจาก /api/auth/session)
  เหลือโค้ด inline สองเส้นเดียว: POST /api/auth/token (ทางเข้า 3: x-api-key
  docver / x-api-key purpose:"login" mint ลิงก์ note / ticket ของ add-in)
  + POST /api/auth/session (แลก credential + register N8 + N11 fid-only)
- POST /api/documents/token คงไว้เป็น alias re-export (กลุ่ม docver/e-Court
  เดิม — contract เดิมเป๊ะ, มี test toBe กัน copy แยกชุดกลับมา)
- ถอดเส้นฝั่ง note ทั้งสอบ: /api/note/auth/token + /api/note/auth/session
  (e-Court ต้องย้ายมาทางเข้ารวม — แจ้งทีม e-Court ก่อน deploy)
- utils/auth-store.ts: primitives กลางฝั่ง client (jwtExp/isExpired/
  localStorage store + memory fallback) — lib/note-session ใช้ร่วม
  (expMs decode จาก JWT payload = นาฬิกา server) และวางไว้ให้ wordaddin
  ย้ายมาใช้รอบหน้าได้ (รอบนี้ไม่แต้ wordaddin ตามข้อตกลง)
- นโยบาย token หมดอายุกลาง session: PDFViewer reportActionError จับ
  auth-error ของ Server Action ทุกจุด → ล้าง session แล้วพาไป
  /note?sso=expired (จบที่ gate เดียวกับ bootstrap 401)
- openapi: ทางเข้า purpose:"login" + schema LoginTokenRequest + ลบ path
  เส้นที่ถอด + test กันเส้นตายหลุดค้างใน spec
- เอกสาร sync: PLAN-SSO §0.6, CLAUDE.md, PLAN-STAGED, .env.example, /note/dev
- tests: ย้าย/เพิ่มชุด auth (50 เคสกลุ่ม auth ผ่าน, ทั้งชุด 444 ผ่าน) +
  e2e ครบทั้ง API (mint/alias/ticket/404 เส้นเก่า/N11/D4) และ browser

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-09 14:30:29 +07:00

152 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<Response> {
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<string, unknown>;
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<Response> {
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<Response> {
// ความผิด 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" } },
);
}