341 lines
16 KiB
TypeScript
341 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;
|
|||
|
|
}
|
|||
|
|
}
|