eCourt-note/lib/note-auth.ts

345 lines
16 KiB
TypeScript
Raw Normal View History

feat(sso): switch note session from cookies to token เปลี่ยน session ของแอป note จาก cookie คู่ oacCase/oacCaseSig มาเป็น access_token แบบ add-in ตามคำขอผู้ใช้ (2026-10-30): - POST /api/note/auth/session แลก credential ของลิงก์ e-Court (JWT ทาง A / ticket ทาง B) เป็น access_token purpose=login อายุ 8 ชม. พร้อมลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server (fileId จาก fid) - token เก็บ localStorage "note-auth" (lib/note-session.ts) + Bearer ผ่าน noteAuthFetch · Server Actions ทุกตัวรับ token เป็น param แรก และแกะ identity เอง (lib/note-token.ts) — ไม่เชื่อ userId จาก client - หน้า protected เป็น shell + client gate (NoteHome bootstrap ที่ /note, AnnotateView + GET /api/note/files/[id]/bootstrap) - ถอด: middleware.ts, lib/session.ts, utils/user.ts, action logout, GET /api/note/auth/e-court, entry /note/annotate?fileUrl= - ทาง ticket ปฏิเสธ secret default "super_secret" เฉพาะ production - GET /api/note/files/open: ลิงก์เปิดไฟล์ตัวอย่างแบบ /tmpl (path ใน เครื่อง /samples เท่านั้น) - หน้า /note/dev: ลิงก์ A Link Get(new) ที่ mint credential แล้วยิง เข้า flow SSO จริง (JWT/ticket สลับได้) + section Flow + เรียบเรียง ภาษาให้อ่านง่าย · หน้า /note เป็นหน้าข้อมูลระบบ - แก้ตาม code review: redo closure เรียก action ด้วย signature เก่า, bootstrap timeout + strip credential ที่ผิดรูป, แยก no-session ออก จาก network error, ห้ามอักขระ & = + % ใน identity, ตรวจ purpose ก่อน exp ของ login-token, audit log strip อักขระควบคุม - เอกสาร: docs/PLAN-SSO.md §0.5 (ฉบับ token), CLAUDE.md, .env.example ทดสอบ: tsc ผ่าน · vitest ผ่านทั้งชุด · ทดสอบจริงผ่าน Playwright (SSO → viewer → เปลี่ยนสี annotation → undo → redo ครบวงจร) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 18:05:36 +07:00
// 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) เปิดด้วย env NOTE_SSO_DEV_MINT=1 เท่านั้น และตายเสมอ
// ใน production build — ไม่ใช่ทางเข้าจริง (แผน D7) แค่ทางทดสอบของ dev server
// ---------------------------------------------------------------------------
/** เปิดตัวช่วย mint ลิงก์ทดสอบหรือไม่ — env NOTE_SSO_DEV_MINT === "1" และไม่ใช่
* production (dev server บน LAN จึงคุมด้วยการตั้ง/ถอด env เอง) */
export function devMintEnabled(): boolean {
return (
process.env.NOTE_SSO_DEV_MINT?.trim() === "1" &&
process.env.NODE_ENV !== "production"
);
}
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 แล้วจึงใช้งานส่วนนี้",
};
feat(sso): switch note session from cookies to token เปลี่ยน session ของแอป note จาก cookie คู่ oacCase/oacCaseSig มาเป็น access_token แบบ add-in ตามคำขอผู้ใช้ (2026-10-30): - POST /api/note/auth/session แลก credential ของลิงก์ e-Court (JWT ทาง A / ticket ทาง B) เป็น access_token purpose=login อายุ 8 ชม. พร้อมลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server (fileId จาก fid) - token เก็บ localStorage "note-auth" (lib/note-session.ts) + Bearer ผ่าน noteAuthFetch · Server Actions ทุกตัวรับ token เป็น param แรก และแกะ identity เอง (lib/note-token.ts) — ไม่เชื่อ userId จาก client - หน้า protected เป็น shell + client gate (NoteHome bootstrap ที่ /note, AnnotateView + GET /api/note/files/[id]/bootstrap) - ถอด: middleware.ts, lib/session.ts, utils/user.ts, action logout, GET /api/note/auth/e-court, entry /note/annotate?fileUrl= - ทาง ticket ปฏิเสธ secret default "super_secret" เฉพาะ production - GET /api/note/files/open: ลิงก์เปิดไฟล์ตัวอย่างแบบ /tmpl (path ใน เครื่อง /samples เท่านั้น) - หน้า /note/dev: ลิงก์ A Link Get(new) ที่ mint credential แล้วยิง เข้า flow SSO จริง (JWT/ticket สลับได้) + section Flow + เรียบเรียง ภาษาให้อ่านง่าย · หน้า /note เป็นหน้าข้อมูลระบบ - แก้ตาม code review: redo closure เรียก action ด้วย signature เก่า, bootstrap timeout + strip credential ที่ผิดรูป, แยก no-session ออก จาก network error, ห้ามอักขระ & = + % ใน identity, ตรวจ purpose ก่อน exp ของ login-token, audit log strip อักขระควบคุม - เอกสาร: docs/PLAN-SSO.md §0.5 (ฉบับ token), CLAUDE.md, .env.example ทดสอบ: tsc ผ่าน · vitest ผ่านทั้งชุด · ทดสอบจริงผ่าน Playwright (SSO → viewer → เปลี่ยนสี annotation → undo → redo ครบวงจร) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 18:05:36 +07:00
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;
feat(sso): switch note session from cookies to token เปลี่ยน session ของแอป note จาก cookie คู่ oacCase/oacCaseSig มาเป็น access_token แบบ add-in ตามคำขอผู้ใช้ (2026-10-30): - POST /api/note/auth/session แลก credential ของลิงก์ e-Court (JWT ทาง A / ticket ทาง B) เป็น access_token purpose=login อายุ 8 ชม. พร้อมลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server (fileId จาก fid) - token เก็บ localStorage "note-auth" (lib/note-session.ts) + Bearer ผ่าน noteAuthFetch · Server Actions ทุกตัวรับ token เป็น param แรก และแกะ identity เอง (lib/note-token.ts) — ไม่เชื่อ userId จาก client - หน้า protected เป็น shell + client gate (NoteHome bootstrap ที่ /note, AnnotateView + GET /api/note/files/[id]/bootstrap) - ถอด: middleware.ts, lib/session.ts, utils/user.ts, action logout, GET /api/note/auth/e-court, entry /note/annotate?fileUrl= - ทาง ticket ปฏิเสธ secret default "super_secret" เฉพาะ production - GET /api/note/files/open: ลิงก์เปิดไฟล์ตัวอย่างแบบ /tmpl (path ใน เครื่อง /samples เท่านั้น) - หน้า /note/dev: ลิงก์ A Link Get(new) ที่ mint credential แล้วยิง เข้า flow SSO จริง (JWT/ticket สลับได้) + section Flow + เรียบเรียง ภาษาให้อ่านง่าย · หน้า /note เป็นหน้าข้อมูลระบบ - แก้ตาม code review: redo closure เรียก action ด้วย signature เก่า, bootstrap timeout + strip credential ที่ผิดรูป, แยก no-session ออก จาก network error, ห้ามอักขระ & = + % ใน identity, ตรวจ purpose ก่อน exp ของ login-token, audit log strip อักขระควบคุม - เอกสาร: docs/PLAN-SSO.md §0.5 (ฉบับ token), CLAUDE.md, .env.example ทดสอบ: tsc ผ่าน · vitest ผ่านทั้งชุด · ทดสอบจริงผ่าน Playwright (SSO → viewer → เปลี่ยนสี annotation → undo → redo ครบวงจร) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 18:05:36 +07:00
default:
return null;
}
}