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:
parent
b1449ab668
commit
d37bc2a3f0
49 changed files with 2921 additions and 1307 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue