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
- ยุบ 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>
345 lines
17 KiB
TypeScript
345 lines
17 KiB
TypeScript
// 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;
|
||
}
|
||
}
|