eCourt-note/lib/note-auth.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

345 lines
17 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.

// PLAN-SSO.md §4 — ส่วนกลางของ SSO ของแอป note (ฉบับ token 2026-10-30 ตามคำขอ
// ผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ของรอบ D15/D16): ลิงก์ e-Court พา
// browser มา `/note` พร้อม credential (`t` หรือ `ticket_data`+`HmacSha256`) →
// client แลกเป็น access_token ที่ POST /api/auth/session (ทางเข้ารวม 2026-11-09
// ตาม PLAN-SSO.md §0.6 — เส้นเดิม /api/note/auth/session ถอดแล้ว) → เก็บใน
// localStorage ("note-auth") แล้วแนบ Authorization: Bearer กับทุก API และส่ง
// token เข้าทุก Server Action (identity แกะฝั่ง server — ห้ามเชื่อ userId จาก
// client ตรง ๆ)
//
// pure module — ห้าม import อะไรที่ลาก @/db หรือ node:crypto มาด้วย (client
// component ใช้ร่วมได้ — unit test tests/lib/note-auth.test.ts รันได้ปล่าว ๆ)
/** สาเหตุที่เข้าทางลิงก์ไม่สำเร็จ — "required" ใช้กับหน้า gate ฝั่ง client ตอน
* ไม่มี token ใน localStorage (คู่ขนาน sso=required ของรอบ middleware) */
export type SsoFailReason = "expired" | "invalid" | "error" | "required";
/** ตัวพา identity ในลิงก์ SSO — สองทางพร้อมกัน (มีทั้ง `t` และ ticket) = fail
* closed ตอน parse แล้ว (D12) */
export type SsoCredential =
| { kind: "jwt"; token: string }
| { kind: "ticket"; ticketData: string; hmacHex: string };
export type SsoLinkParams =
| { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string }
| { ok: false };
/** allowlist param ของ landing + cap ความยาว (t ตาม JWT ที่มี claims ไทย
* ยาวได้หลักพัน · ticket_data/hmac ตาม cap ของ parseTicketTokenBody) */
const SSO_PARAM_LIMITS = {
t: 8192,
ticket_data: 2048,
HmacSha256: 128,
fileUrl: 2048,
typeId: 200,
} as const;
/**
* แกะ query ของ URL ที่ลิงก์ e-Court ชี้มา (ใช้ทั้งฝั่ง client ของหน้า /note
* ตอน bootstrap และใน test) — allowlist 5 param เป๊ะ (D12): มี `t` = ทาง JWT ·
* ไม่มี `t` แต่มี `ticket_data`+`HmacSha256` ครบ = ทาง ticket · มีทั้งสองชุด /
* มีครึ่งเดียว / ไม่มีเลย / ยาวเกิน cap = ok:false
*/
export function parseSsoLinkParams(url: URL): SsoLinkParams {
const read = (name: keyof typeof SSO_PARAM_LIMITS): string =>
url.searchParams.get(name) ?? "";
const t = read("t");
const ticketData = read("ticket_data");
const hmacHex = read("HmacSha256");
let credential: SsoCredential;
if (t !== "") {
// มี ticket ปนมาพร้อม t แม้แต่ตัวเดียว = กำกวม → fail closed (D12)
if (ticketData !== "" || hmacHex !== "") return { ok: false };
if (t.length > SSO_PARAM_LIMITS.t) return { ok: false };
credential = { kind: "jwt", token: t };
} else if (ticketData !== "" && hmacHex !== "") {
if (ticketData.length > SSO_PARAM_LIMITS.ticket_data) return { ok: false };
if (hmacHex.length > SSO_PARAM_LIMITS.HmacSha256) return { ok: false };
credential = { kind: "ticket", ticketData, hmacHex };
} else {
// ไม่มีชุดไหนเลย หรือมี ticket ครึ่งเดียว
return { ok: false };
}
const params: { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string } = {
ok: true,
credential,
};
const fileUrl = read("fileUrl");
if (fileUrl !== "") {
if (fileUrl.length > SSO_PARAM_LIMITS.fileUrl) return { ok: false };
params.fileUrl = fileUrl;
}
const typeId = read("typeId");
if (typeId !== "") {
if (typeId.length > SSO_PARAM_LIMITS.typeId) return { ok: false };
params.typeId = typeId;
}
return params;
}
// ---------------------------------------------------------------------------
// body ของ POST /api/auth/session (แลก credential → access_token — เดิม /api/note/auth/session)
// ---------------------------------------------------------------------------
const SESSION_EXCHANGE_LIMITS = SSO_PARAM_LIMITS;
const SESSION_EXCHANGE_FIELDS = ["t", "ticket_data", "HmacSha256", "fileUrl", "typeId"] as const;
export type SessionExchangeBody =
| { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string }
| { ok: false; error: string };
/** body ของเส้นแลก token — เกณฑ์เดียวกับ parseSsoLinkParams (allowlist 5 field
* + cap ยาว + สอง credential พร้อมกัน = invalid ตาม D12) แค่รับทาง JSON body */
export function parseSessionExchangeBody(raw: unknown): SessionExchangeBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(SESSION_EXCHANGE_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${SESSION_EXCHANGE_FIELDS.join(", ")})`,
};
}
}
const str = (name: keyof typeof SESSION_EXCHANGE_LIMITS): string => {
const value = record[name];
return typeof value === "string" ? value : "";
};
const t = str("t");
const ticketData = str("ticket_data");
const hmacHex = str("HmacSha256");
let credential: SsoCredential;
if (t !== "") {
if (ticketData !== "" || hmacHex !== "") {
return { ok: false, error: "ส่ง credential ได้ทางเดียว (t หรือ ticket_data+HmacSha256)" };
}
if (t.length > SESSION_EXCHANGE_LIMITS.t) return { ok: false, error: "t ยาวเกินกำหนด" };
credential = { kind: "jwt", token: t };
} else if (ticketData !== "" && hmacHex !== "") {
if (ticketData.length > SESSION_EXCHANGE_LIMITS.ticket_data) {
return { ok: false, error: "ticket_data ยาวเกินกำหนด" };
}
if (hmacHex.length > SESSION_EXCHANGE_LIMITS.HmacSha256) {
return { ok: false, error: "HmacSha256 ยาวเกินกำหนด" };
}
credential = { kind: "ticket", ticketData, hmacHex };
} else {
return { ok: false, error: "ต้องระบุ t หรือ ticket_data+HmacSha256" };
}
const parsed: { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string } = {
ok: true,
credential,
};
const fileUrl = str("fileUrl");
if (fileUrl !== "") {
if (fileUrl.length > SESSION_EXCHANGE_LIMITS.fileUrl) {
return { ok: false, error: "fileUrl ยาวเกินกำหนด" };
}
parsed.fileUrl = fileUrl;
}
const typeId = str("typeId");
if (typeId !== "") {
if (typeId.length > SESSION_EXCHANGE_LIMITS.typeId) {
return { ok: false, error: "typeId ยาวเกินกำหนด" };
}
parsed.typeId = typeId;
}
return parsed;
}
// ---------------------------------------------------------------------------
// body ของทางเข้า mint login-token (ทาง purpose:"login" ของ POST /api/auth/token —
// เส้นเดิม POST /api/note/auth/token ถอดแล้ว · D1/D5, PLAN-SSO.md §0.6)
// ---------------------------------------------------------------------------
/** ttl_seconds ทางเลือก — ช่วง 60–600 วินาที default 300 (credential ใน URL
* ต้องอายุสั้น) */
export const LOGIN_TOKEN_TTL = { min: 60, max: 600, default: 300 } as const;
const LOGIN_TOKEN_BODY_FIELDS = ["userid", "username", "fid", "ttl_seconds"] as const;
/** อักขระที่ identity (userid/username) ใช้ไม่ได้ — ค่าไหลลง DB เป็น user_id/
* user_name ของ annotation/bookmark (แสดงผล UI + เงื่อนไขค้นหา): & = + % เป็น
* ตัวที่เคยทำ identity เพี้ยนตอนแพ็กใน payload แบบ query string ของ cookie
* (รอบก่อนเลิก cookie 2026-10-30) — กันไว้เพราะเป็นชุดอักขระที่ตีความต่างกัน
* ตาม context ง่าย (ทาง ticket ใช้เกณฑ์เดียวกันที่ parseDocverTicket) */
const UNSAFE_IDENTITY_RE = /[&=+%]/;
export type LoginTokenBody =
| { ok: true; claims: { userid: string; username: string; fid: string }; ttlSeconds: number }
| { ok: false; error: string };
/** strict allowlist 4 field (ส่ง `purpose` มา = 400 — เส้นนี้เป็น login-token
* เอกสิทธิ์) · cap ความยาว identity ตาม parseTokenRequestBody ของกลุ่ม docver */
export function parseLoginTokenBody(raw: unknown): LoginTokenBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(LOGIN_TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${LOGIN_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 ตัวอักษร" };
if (UNSAFE_IDENTITY_RE.test(userid)) {
return { ok: false, error: "userid มีอักขระ & = + % ที่รองรับไม่ได้" };
}
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 ตัวอักษร" };
if (UNSAFE_IDENTITY_RE.test(username)) {
return { ok: false, error: "username มีอักขระ & = + % ที่รองรับไม่ได้" };
}
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 ตัวอักษร" };
let ttlSeconds: number = LOGIN_TOKEN_TTL.default;
const rawTtl = record.ttl_seconds;
if (rawTtl !== undefined) {
if (
typeof rawTtl !== "number" ||
!Number.isInteger(rawTtl) ||
rawTtl < LOGIN_TOKEN_TTL.min ||
rawTtl > LOGIN_TOKEN_TTL.max
) {
return {
ok: false,
error: `ttl_seconds ต้องเป็นจำนวนเต็ม ${LOGIN_TOKEN_TTL.min}–${LOGIN_TOKEN_TTL.max}`,
};
}
ttlSeconds = rawTtl;
}
return { ok: true, claims: { userid, username, fid }, ttlSeconds };
}
// ---------------------------------------------------------------------------
// dev-link — mint ลิงก์ SSO จากหน้า /note/dev (คำขอผู้ใช้ 2026-10-29 "ใช้ได้ใน note"
// — ย้ายตามหน้า 2026-10-30) · 2026-11-02 คำขอผู้ใช้: เปิดด้วย env
// NOTE_SSO_DEV_MINT=1 อย่างเดียว — ใช้ได้ทุก environment (รวม production ที่ตั้ง
// env ไว้ เช่น เครื่องทดสอบ) 0/ไม่ตั้ง = หน้า /note/dev และ route dev-link เป็น 404
// — ความเสี่ยง mint จากหน้า public ตกอยู่ที่ผู้ตั้ง env บนเครื่องนั้น ไม่ใช่ทางเข้าจริง (D7)
// ---------------------------------------------------------------------------
/** เปิดหน้า /note/dev + ตัวช่วย mint ลิงก์ทดสอบหรือไม่ — env NOTE_SSO_DEV_MINT === "1"
* ตัวเดียว (ไม่ดู NODE_ENV — 2026-11-02) แต่ละเครื่องคุมด้วยการตั้ง/ถอด env เอง */
export function devMintEnabled(): boolean {
return process.env.NOTE_SSO_DEV_MINT?.trim() === "1";
}
const DEV_LINK_BODY_FIELDS = ["userid", "username", "fid", "ttl_seconds", "fileUrl", "typeId", "mode"] as const;
export type DevLinkBody =
| {
ok: true;
claims: { userid: string; username: string; fid: string };
ttlSeconds: number;
fileUrl?: string;
typeId?: string;
mode: "jwt" | "ticket";
}
| { ok: false; error: string };
/** body ของ POST /api/note/auth/dev-link — identity/ttl ใช้เกณฑ์เดียวกับ
* parseLoginTokenBody (ตัด field ส่วนเสริมออกก่อนส่งต่อ ให้เกณฑ์/ข้อความเดียวกัน) */
export function parseDevLinkBody(raw: unknown): DevLinkBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(DEV_LINK_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${DEV_LINK_BODY_FIELDS.join(", ")})`,
};
}
}
const { fileUrl, typeId, mode, ...identity } = record;
const base = parseLoginTokenBody(identity);
if (!base.ok) return base;
const result: DevLinkBody = { ...base, mode: "jwt" };
if (fileUrl !== undefined) {
if (typeof fileUrl !== "string" || fileUrl.trim() === "" || fileUrl.length > 2048) {
return { ok: false, error: "fileUrl ต้องเป็น string (1–2048)" };
}
result.fileUrl = fileUrl.trim();
}
if (typeId !== undefined) {
if (typeof typeId !== "string" || typeId.trim() === "" || typeId.length > 200) {
return { ok: false, error: "typeId ต้องเป็น string (1–200)" };
}
result.typeId = typeId.trim();
}
if (mode !== undefined) {
if (mode !== "jwt" && mode !== "ticket") {
return { ok: false, error: "mode ต้องเป็น jwt หรือ ticket" };
}
result.mode = mode;
}
return result;
}
// ---------------------------------------------------------------------------
// แบนเนอร์สาเหตุ ?sso= / สถานะ gate ฝั่ง client (D8/D15) — ค่าอื่น = null (ไม่แสดง)
// ---------------------------------------------------------------------------
export type SsoBanner = { intent: "warning" | "error"; text: string };
/** ข้อความ gate คงที่ของหน้า annotate — คู่ขนาน `ssoBanner("required")` ให้หน้า
* gate ฝั่ง client ใช้ตรง ๆ โดยไม่ต้อง non-null assertion */
export const SSO_REQUIRED_BANNER: SsoBanner = {
intent: "warning",
text: "กรุณาเข้าสู่ระบบผ่านลิงก์เปิดเอกสารของ e-Court แล้วจึงใช้งานส่วนนี้",
};
export function ssoBanner(reason: string | undefined): SsoBanner | null {
switch (reason) {
case "expired":
return {
intent: "warning",
text: "ลิงก์เข้าสู่ระบบหมดอายุ — กลับไปที่ e-Court แล้วกดเปิดเอกสารใหม่อีกครั้ง",
};
case "invalid":
return {
intent: "warning",
text: "ลิงก์เข้าสู่ระบบไม่ถูกต้อง — กลับไปที่ e-Court แล้วกดเปิดเอกสารใหม่อีกครั้ง",
};
case "error":
return {
intent: "error",
text: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ (ข้อผิดพลาดฝั่งเซิร์ฟเวอร์) — ลองอีกครั้งภายหลัง หากยังไม่สำเร็จโปรดติดต่อผู้ดูแลระบบ",
};
case "required":
return SSO_REQUIRED_BANNER;
default:
return null;
}
}