2026-10-26 06:29:19 +07:00
|
|
|
// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning)
|
|
|
|
|
// - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key"
|
|
|
|
|
// กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ
|
|
|
|
|
// lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual,
|
|
|
|
|
// ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ)
|
|
|
|
|
// - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน —
|
|
|
|
|
// ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer —
|
|
|
|
|
// identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload
|
2026-10-26 10:05:01 +07:00
|
|
|
// - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ
|
|
|
|
|
// — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid
|
2026-10-26 06:29:19 +07:00
|
|
|
//
|
|
|
|
|
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
|
|
|
|
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts)
|
|
|
|
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
|
|
|
|
|
|
|
|
import { docverError, type DocverError } from "@/lib/document-versioning";
|
|
|
|
|
|
|
|
|
|
/** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */
|
|
|
|
|
export type DocverTokenClaims = {
|
|
|
|
|
userid: string;
|
|
|
|
|
username: string;
|
2026-10-26 10:05:01 +07:00
|
|
|
fid: string;
|
2026-10-26 06:29:19 +07:00
|
|
|
template: string | null;
|
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
|
|
|
/** แยกสิทธิ์ของ token (PLAN-SSO.md D4): "docver" = ใช้กับ /api/documents/* ·
|
|
|
|
|
* "login" = ใช้กับ SSO route ของแอป note เท่านั้น · undefined = docver
|
|
|
|
|
* (token เก่าก่อนมี claim นี้ — verify ยอมรับเพื่อ token ในมือ user ตอน roll ไม่ตาย) */
|
|
|
|
|
purpose?: "docver" | "login";
|
2026-10-26 06:29:19 +07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function base64url(input: string): string {
|
|
|
|
|
return Buffer.from(input, "utf8").toString("base64url");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function decodeJsonSegment(segment: string): Record<string, unknown> | null {
|
|
|
|
|
let text: string;
|
|
|
|
|
try {
|
|
|
|
|
text = Buffer.from(segment, "base64url").toString("utf8");
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(text) as unknown;
|
2026-10-26 12:29:08 +07:00
|
|
|
if (
|
|
|
|
|
parsed === null ||
|
|
|
|
|
typeof parsed !== "object" ||
|
|
|
|
|
Array.isArray(parsed)
|
|
|
|
|
) {
|
2026-10-26 06:29:19 +07:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return parsed as Record<string, unknown>;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + purpose + iat/exp (วินาที)
|
|
|
|
|
* purpose default "docver" — route ของกลุ่ม docver ไม่ต้องส่ง (PLAN-SSO.md D4),
|
|
|
|
|
* เส้น mint login-token ของแอป note ส่ง "login" */
|
2026-10-26 06:29:19 +07:00
|
|
|
export function issueDocverToken(
|
|
|
|
|
claims: DocverTokenClaims,
|
|
|
|
|
secret: string,
|
|
|
|
|
ttlSeconds: number,
|
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
|
|
|
purpose: "docver" | "login" = "docver",
|
2026-10-26 06:29:19 +07:00
|
|
|
): string {
|
|
|
|
|
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
|
|
|
const iat = Math.floor(Date.now() / 1000);
|
|
|
|
|
const payload = base64url(
|
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
|
|
|
JSON.stringify({ ...claims, purpose, iat, exp: iat + ttlSeconds }),
|
2026-10-26 06:29:19 +07:00
|
|
|
);
|
|
|
|
|
const signature = createHmac("sha256", secret)
|
|
|
|
|
.update(`${header}.${payload}`)
|
|
|
|
|
.digest("base64url");
|
|
|
|
|
return `${header}.${payload}.${signature}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid
|
|
|
|
|
* (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */
|
2026-10-26 12:29:08 +07:00
|
|
|
function claimsFromPayload(
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
): DocverTokenClaims | null {
|
2026-10-26 06:29:19 +07:00
|
|
|
const { userid, username, fid, template } = payload;
|
|
|
|
|
if (typeof userid !== "string" || userid === "") return null;
|
|
|
|
|
if (typeof username !== "string" || username === "") return null;
|
2026-10-26 10:05:01 +07:00
|
|
|
if (typeof fid !== "string" || fid === "") return null;
|
2026-10-26 06:29:19 +07:00
|
|
|
if (template !== null && typeof template !== "string") return null;
|
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
|
|
|
// 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;
|
2026-10-26 06:29:19 +07:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/** ตรวจรูป + alg บังคับ HS256 (กัน alg-confusion เช่น "none") + เทียบ signature
|
|
|
|
|
* แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) — ใช้ร่วมโดย
|
|
|
|
|
* verifyDocverToken และ verifyDocverLoginToken · คืน payload ที่ decode แล้ว */
|
|
|
|
|
function verifyTokenSignature(
|
2026-10-26 06:29:19 +07:00
|
|
|
token: string,
|
|
|
|
|
secret: string,
|
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
|
|
|
): Record<string, unknown> | null {
|
2026-10-26 06:29:19 +07:00
|
|
|
const segments = token.split(".");
|
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
|
|
|
if (segments.length !== 3) return null;
|
2026-10-26 06:29:19 +07:00
|
|
|
const [headerSegment, payloadSegment, signatureSegment] = segments;
|
|
|
|
|
|
|
|
|
|
const header = decodeJsonSegment(headerSegment);
|
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
|
|
|
if (header === null || header.alg !== "HS256") return null;
|
2026-10-26 06:29:19 +07:00
|
|
|
|
|
|
|
|
const expected = createHmac("sha256", secret)
|
|
|
|
|
.update(`${headerSegment}.${payloadSegment}`)
|
|
|
|
|
.digest();
|
|
|
|
|
// ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง
|
|
|
|
|
// (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง —
|
|
|
|
|
// timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน)
|
|
|
|
|
const provided = createHash("sha256")
|
|
|
|
|
.update(Buffer.from(signatureSegment, "base64url"))
|
|
|
|
|
.digest();
|
|
|
|
|
const expectedHash = createHash("sha256").update(expected).digest();
|
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
|
|
|
if (!timingSafeEqual(provided, expectedHash)) return null;
|
2026-10-26 06:29:19 +07:00
|
|
|
|
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
|
|
|
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);
|
2026-10-26 06:29:19 +07:00
|
|
|
if (payload === null) return { ok: false };
|
|
|
|
|
const exp = payload.exp;
|
|
|
|
|
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
|
|
|
|
|
if (exp <= Math.floor(Date.now() / 1000)) return { ok: false };
|
|
|
|
|
|
|
|
|
|
const claims = claimsFromPayload(payload);
|
|
|
|
|
if (claims === null) return { ok: false };
|
|
|
|
|
return { ok: true, claims };
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/** ตรวจ 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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 06:29:19 +07:00
|
|
|
/** 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
|
2026-10-26 18:37:02 +07:00
|
|
|
const envKey = process.env.DOCVER_API_KEY?.trim();
|
|
|
|
|
if (envKey === undefined || envKey === "") {
|
|
|
|
|
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
|
|
|
|
|
}
|
2026-10-26 06:29:19 +07:00
|
|
|
const provided = request.headers.get("x-api-key");
|
|
|
|
|
if (provided === null || provided === "") {
|
|
|
|
|
return docverError("API key ไม่ถูกต้อง", 401);
|
|
|
|
|
}
|
|
|
|
|
const providedHash = createHash("sha256").update(provided).digest();
|
|
|
|
|
const envHash = createHash("sha256").update(envKey).digest();
|
|
|
|
|
return timingSafeEqual(providedHash, envHash)
|
|
|
|
|
? null
|
|
|
|
|
: docverError("API key ไม่ถูกต้อง", 401);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-31 11:09:42 +07:00
|
|
|
/** auth ของ GET /api/documents/latest — x-api-key เทียบ env DOCVER_API_KEY
|
|
|
|
|
* หรือ EXTERNAL_API_KEY (ตัวใดตัวหนึ่งตรง = ผ่าน — ผู้เรียกเป็น backend e-Court
|
|
|
|
|
* ที่ถือ key สาย N10 หรือสาย docver ก็ได้ ต่างจาก /token ที่รับ DOCVER_API_KEY
|
|
|
|
|
* อย่างเดียว) · ทั้งคู่ไม่ตั้ง/ว่าง = 401 fail closed · ข้อความ 401 ของ "ไม่ใส่
|
|
|
|
|
* key" กับ "key ผิด" เป็นอันเดียวกันเหมือน requireDocverApiKey
|
|
|
|
|
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
|
|
|
|
|
export function requireDocumentApiKey(request: Request): Response | null {
|
|
|
|
|
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
|
|
|
|
|
const envKeys = [process.env.DOCVER_API_KEY, process.env.EXTERNAL_API_KEY]
|
|
|
|
|
.map((value) => value?.trim())
|
|
|
|
|
.filter((value): value is string => value !== undefined && value !== "");
|
|
|
|
|
if (envKeys.length === 0) {
|
|
|
|
|
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
|
|
|
|
|
}
|
|
|
|
|
const provided = request.headers.get("x-api-key");
|
|
|
|
|
if (provided === null || provided === "") {
|
|
|
|
|
return docverError("API key ไม่ถูกต้อง", 401);
|
|
|
|
|
}
|
|
|
|
|
const providedHash = createHash("sha256").update(provided).digest();
|
|
|
|
|
const matched = envKeys.some((envKey) => {
|
|
|
|
|
const envHash = createHash("sha256").update(envKey).digest();
|
|
|
|
|
return timingSafeEqual(providedHash, envHash);
|
|
|
|
|
});
|
|
|
|
|
return matched ? null : docverError("API key ไม่ถูกต้อง", 401);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 06:29:19 +07:00
|
|
|
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
|
|
|
|
|
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
|
|
|
|
|
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";
|
|
|
|
|
|
|
|
|
|
/** บังคับ Authorization: Bearer <token> ของกลุ่ม docver — เรียกเป็นอย่างแรกของ
|
|
|
|
|
* ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี
|
|
|
|
|
* token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */
|
2026-10-26 12:29:08 +07:00
|
|
|
export function requireDocverBearer(
|
|
|
|
|
request: Request,
|
|
|
|
|
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
|
|
|
|
|
const denied = () =>
|
|
|
|
|
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
|
2026-10-26 18:37:02 +07:00
|
|
|
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
|
|
|
|
if (secret === undefined || secret === "") return denied();
|
2026-10-26 06:29:19 +07:00
|
|
|
const header = request.headers.get("authorization");
|
|
|
|
|
if (header === null) return denied();
|
|
|
|
|
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
|
|
|
|
if (match === null) return denied();
|
|
|
|
|
const verified = verifyDocverToken(match[1], secret);
|
|
|
|
|
if (!verified.ok) return denied();
|
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
|
|
|
// D4 (PLAN-SSO.md): login-token เป็นของ SSO ของแอป note เท่านั้น — เอามายิง
|
|
|
|
|
// API กลุ่ม docver ไม่ได้ (scope escalation) ตอบ 401 เดียวกับ token เน่า
|
|
|
|
|
if (verified.claims.purpose === "login") return denied();
|
2026-10-26 06:29:19 +07:00
|
|
|
return { ok: true, claims: verified.claims };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม
|
|
|
|
|
* convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */
|
|
|
|
|
const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const;
|
|
|
|
|
|
2026-10-26 12:29:08 +07:00
|
|
|
export function parseTokenRequestBody(
|
|
|
|
|
raw: unknown,
|
|
|
|
|
): { ok: true; claims: DocverTokenClaims } | DocverError {
|
2026-10-26 06:29:19 +07:00
|
|
|
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 (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${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 ตัวอักษร" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 ตัวอักษร" };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 10:05:01 +07:00
|
|
|
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 ตัวอักษร" };
|
2026-10-26 06:29:19 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rawTemplate = record.template ?? null;
|
|
|
|
|
if (rawTemplate !== null && typeof rawTemplate !== "string") {
|
|
|
|
|
return { ok: false, error: "template ต้องเป็น string" };
|
|
|
|
|
}
|
|
|
|
|
const template = rawTemplate?.trim() || null;
|
|
|
|
|
if (template !== null && template.length > 100) {
|
|
|
|
|
return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 10:05:01 +07:00
|
|
|
return { ok: true, claims: { userid, username, fid, template } };
|
2026-10-26 06:29:19 +07:00
|
|
|
}
|