// 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/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/note/auth/session (แลก credential → access_token) // --------------------------------------------------------------------------- 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; 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 ของ POST /api/note/auth/token (เส้น mint login-token — D1/D5) // --------------------------------------------------------------------------- /** 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; 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; 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; } }