eCourt-note/lib/note-auth.ts
Your Name f7e84e3ee7
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m16s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
feat(note): mock e-Court login form on dev page
เพิ่มฟอร์ม "จำลอง e-Court — ขอ token ด้วย API key" บนหน้า
/note/dev (components/MockEcourtForm): ใส่ DOCVER_API_KEY +
userid/username + เลือกไฟล์ตัวอย่าง → ยิง POST /api/note/auth/token
(ทางเข้าเดียวกับที่ e-Court ใช้จริง) แล้ว redirect เข้า
/note?fileUrl=…&t=… ให้ NoteHome แลกเป็น session 8 ชม. ต่อ —
ใช้ได้ทั้ง dev และ production และไม่เป็นประตูหลัง (ต้องถือ
API key ที่ถูกต้อง)

พร้อมกันนี้เปลี่ยนเกณฑ์เปิดหน้า dev เป็น env ตัวเดียว
(คำขอผู้ใช้ 2026-11-02):

- devMintEnabled: ตัดเงื่อนไข NODE_ENV≠production ออก เหลือ
  NOTE_SSO_DEV_MINT === "1" อย่างเดียว — ตั้งไว้บน production
  (เครื่องทดสอบ) ก็ใช้ได้
- หน้า /note/dev: 0/ไม่ตั้ง = 404 ทั้งหน้า (notFound) — เดิมเป็น
  public ตลอดเหลือแต่ข้อความแนะนำ
- dev-link (GET/POST) เปิด/ปิดตามเกณฑ์เดียวกันโดยอัตโนมัติ
- sync CLAUDE.md + ปรับ unit test ให้ตรงเกณฑ์ใหม่

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-02 12:19:38 +07:00

343 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/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<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 ของ 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<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;
}
}