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>
This commit is contained in:
Your Name 2026-10-30 18:05:36 +07:00
parent b1449ab668
commit d37bc2a3f0
49 changed files with 2921 additions and 1307 deletions

View file

@ -21,6 +21,10 @@ export type DocverTokenClaims = {
username: string;
fid: string;
template: string | null;
/** แยกสิทธิ์ของ token (PLAN-SSO.md D4): "docver" = ใช้กับ /api/documents/* ·
* "login" = ใช้กับ SSO route ของแอป note เท่านั้น · undefined = docver
* (token เก่าก่อนมี claim นี้ — verify ยอมรับเพื่อ token ในมือ user ตอน roll ไม่ตาย) */
purpose?: "docver" | "login";
};
function base64url(input: string): string {
@ -49,16 +53,19 @@ function decodeJsonSegment(segment: string): Record<string, unknown> | null {
}
}
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + purpose + iat/exp (วินาที)
* purpose default "docver" — route ของกลุ่ม docver ไม่ต้องส่ง (PLAN-SSO.md D4),
* เส้น mint login-token ของแอป note ส่ง "login" */
export function issueDocverToken(
claims: DocverTokenClaims,
secret: string,
ttlSeconds: number,
purpose: "docver" | "login" = "docver",
): string {
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const iat = Math.floor(Date.now() / 1000);
const payload = base64url(
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }),
JSON.stringify({ ...claims, purpose, iat, exp: iat + ttlSeconds }),
);
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
@ -76,22 +83,30 @@ function claimsFromPayload(
if (typeof username !== "string" || username === "") return null;
if (typeof fid !== "string" || fid === "") return null;
if (template !== null && typeof template !== "string") return null;
return { userid, username, fid, template };
// purpose ไม่บังคับ (token เก่าไม่มี = docver) แต่ค่าอื่นนอกสองค่านี้ = invalid;
// คืนเฉพาะเมื่อเป็น "login" เพื่อไม่ให้ claims ของ token docver เปลี่ยนรูปจากเดิม
const purpose = payload.purpose;
if (purpose !== undefined && purpose !== "docver" && purpose !== "login") {
return null;
}
const claims: DocverTokenClaims = { userid, username, fid, template };
if (purpose === "login") claims.purpose = purpose;
return claims;
}
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
export function verifyDocverToken(
/** ตรวจรูป + alg บังคับ HS256 (กัน alg-confusion เช่น "none") + เทียบ signature
* แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) — ใช้ร่วมโดย
* verifyDocverToken และ verifyDocverLoginToken · คืน payload ที่ decode แล้ว */
function verifyTokenSignature(
token: string,
secret: string,
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
): Record<string, unknown> | null {
const segments = token.split(".");
if (segments.length !== 3) return { ok: false };
if (segments.length !== 3) return null;
const [headerSegment, payloadSegment, signatureSegment] = segments;
const header = decodeJsonSegment(headerSegment);
if (header === null || header.alg !== "HS256") return { ok: false };
if (header === null || header.alg !== "HS256") return null;
const expected = createHmac("sha256", secret)
.update(`${headerSegment}.${payloadSegment}`)
@ -103,9 +118,19 @@ export function verifyDocverToken(
.update(Buffer.from(signatureSegment, "base64url"))
.digest();
const expectedHash = createHash("sha256").update(expected).digest();
if (!timingSafeEqual(provided, expectedHash)) return { ok: false };
if (!timingSafeEqual(provided, expectedHash)) return null;
const payload = decodeJsonSegment(payloadSegment);
return decodeJsonSegment(payloadSegment);
}
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
export function verifyDocverToken(
token: string,
secret: string,
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
const payload = verifyTokenSignature(token, secret);
if (payload === null) return { ok: false };
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
@ -116,14 +141,37 @@ export function verifyDocverToken(
return { ok: true, claims };
}
/** ตรวจ login-token ของ SSO (PLAN-SSO.md §3.2) — ต่างจาก verifyDocverToken:
* แยก "หมดอายุ" (signature ถูกแต่ exp เลยมาแล้ว → route ตอบ sso=expired) ออกจาก
* "ผิดทุกกรณีอื่น" (→ sso=invalid) และรับเฉพาะ purpose === "login" (D4 — token
* ของกลุ่ม docver เอามาเข้า SSO ไม่ได้) · ตัดสินชนิด token (purpose) ก่อนอายุ —
* token docver หมดอายุก็เป็น invalid ไม่ใช่ expired (มิฉะนั้นแบนเนอร์แนะนำ
* "กดลิงก์ใหม่" ทั้งที่ mint ใหม่ก็ได้ token ผิดชนิดเหมือนเดิม) */
export function verifyDocverLoginToken(
token: string,
secret: string,
nowSec: number = Math.floor(Date.now() / 1000),
): { ok: true; claims: DocverTokenClaims } | { ok: false; reason: "invalid" | "expired" } {
const payload = verifyTokenSignature(token, secret);
if (payload === null) return { ok: false, reason: "invalid" };
const claims = claimsFromPayload(payload);
if (claims === null) return { ok: false, reason: "invalid" };
if (claims.purpose !== "login") return { ok: false, reason: "invalid" };
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) {
return { ok: false, reason: "invalid" };
}
if (exp <= nowSec) return { ok: false, reason: "expired" };
return { ok: true, claims };
}
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
export function requireDocverApiKey(request: Request): Response | null {
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
const envKey = process.env.DOCVER_API_KEY?.trim();
// const envKey =
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
if (envKey === undefined || envKey === "") {
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
}
@ -151,8 +199,6 @@ export function requireDocverBearer(
const denied = () =>
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
// const secret =
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
if (secret === undefined || secret === "") return denied();
const header = request.headers.get("authorization");
if (header === null) return denied();
@ -160,6 +206,9 @@ export function requireDocverBearer(
if (match === null) return denied();
const verified = verifyDocverToken(match[1], secret);
if (!verified.ok) return denied();
// D4 (PLAN-SSO.md): login-token เป็นของ SSO ของแอป note เท่านั้น — เอามายิง
// API กลุ่ม docver ไม่ได้ (scope escalation) ตอบ 401 เดียวกับ token เน่า
if (verified.claims.purpose === "login") return denied();
return { ok: true, claims: verified.claims };
}