เปลี่ยน 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>
340 lines
16 KiB
TypeScript
340 lines
16 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/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 };
|
||
|
||
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 {
|
||
intent: "warning",
|
||
text: "กรุณาเข้าสู่ระบบผ่านลิงก์เปิดเอกสารของ e-Court แล้วจึงใช้งานส่วนนี้",
|
||
};
|
||
default:
|
||
return null;
|
||
}
|
||
}
|