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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export async function registerExternalFile(
|
|||
rawFileUrl: string,
|
||||
rawFileId?: string,
|
||||
rawTypeId?: string,
|
||||
opts?: { trusted?: boolean },
|
||||
): Promise<RegisterExternalFileResult> {
|
||||
let trimmed = rawFileUrl.trim();
|
||||
if (!trimmed) return { ok: false, error: "ไม่ได้ระบุลิงก์ไฟล์" };
|
||||
|
|
@ -101,12 +102,21 @@ export async function registerExternalFile(
|
|||
};
|
||||
}
|
||||
|
||||
// แถว ecourt:<fileId> (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO route
|
||||
// (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId
|
||||
// พิมพ์เองโดยผู้มี session ใด ๆ เคยใช้เขียน/refresh pdf_url ของเอกสารคนอื่นให้ชี้
|
||||
// PDF ที่ตัวเองเลือก — ตอนนี้เปิดดูของที่รู้จักแล้วได้อย่างเดียว (แถว url:… ของ
|
||||
// samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง)
|
||||
const ecourtRow = externalId.startsWith("ecourt:");
|
||||
const trusted = opts?.trusted === true;
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.externalId, externalId))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
if (ecourtRow && !trusted) return { ok: true, fileId: existing.id };
|
||||
// fileId เดิมเจออีกครั้ง: refresh Temp link ใหม่ + ย้ายกลุ่มตาม typeId ล่าสุด
|
||||
// (ลิงก์ไม่ส่ง typeId มา = คงกลุ่มเดิม ห้ามเคลียร์ — PLAN-ECOURT.md การตัดสินใจ)
|
||||
const patch: { pdfUrl?: string; typeId?: string } = {};
|
||||
|
|
@ -118,6 +128,13 @@ export async function registerExternalFile(
|
|||
return { ok: true, fileId: existing.id };
|
||||
}
|
||||
|
||||
if (ecourtRow && !trusted) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "เอกสารของ e-Court รหัสนี้ยังไม่เคยเปิดในระบบ — กรุณาเปิดผ่านลิงก์เอกสารของ e-Court",
|
||||
};
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
try {
|
||||
await db.insert(files).values({
|
||||
|
|
|
|||
340
lib/note-auth.ts
Normal file
340
lib/note-auth.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
74
lib/note-session.ts
Normal file
74
lib/note-session.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// session ของแอป note ฝั่ง client (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
|
||||
// แบบ add-in" — pattern เดียวกับ wordaddin/services/authService): access_token
|
||||
// + identity เก็บใน localStorage ("note-auth") หลังแลกจาก POST /api/note/auth/
|
||||
// session · การเรียก API ของแอปผ่าน noteAuthFetch แนบ Authorization: Bearer
|
||||
// อัตโนมัติ · token หมดอายุแล้วถือว่าไม่มี session (ล้างทิ้ง) — localStorage อาจ
|
||||
// โยน (private mode/quota) ทุกจุดจึง try/catch
|
||||
//
|
||||
// client-only — ห้าม import จาก server component/module ฝั่ง server
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
export type NoteSession = {
|
||||
token: string;
|
||||
userid: string;
|
||||
username: string;
|
||||
/** exp epoch มิลลิวินาที — พ้นเวลานี้ถือว่าไม่มี session */
|
||||
expMs: number;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "note-auth";
|
||||
|
||||
export function readNoteSession(): NoteSession | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<NoteSession> | null;
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
typeof parsed.token !== "string" ||
|
||||
parsed.token === "" ||
|
||||
typeof parsed.userid !== "string" ||
|
||||
parsed.userid === "" ||
|
||||
typeof parsed.username !== "string" ||
|
||||
typeof parsed.expMs !== "number"
|
||||
) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
if (parsed.expMs <= Date.now()) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
return parsed as NoteSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveNoteSession(session: NoteSession): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
} catch {
|
||||
// private mode/quota — เงียบไว้: ผู้ใช้เข้าผ่านลิงก์ e-Court ใหม่ได้เสมอ
|
||||
}
|
||||
}
|
||||
|
||||
export function clearNoteSession(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** fetch ของแอปพร้อม Bearer — path เป็น route API ของแอป ("/api/note/…")
|
||||
* ไม่มี session = throw "no-session" ให้ caller เปลี่ยนเป็นหน้า gate เอง */
|
||||
export async function noteAuthFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const session = readNoteSession();
|
||||
if (session === null) throw new Error("no-session");
|
||||
return fetch(withBasePath(path), {
|
||||
...init,
|
||||
headers: { ...(init.headers ?? {}), authorization: `Bearer ${session.token}` },
|
||||
});
|
||||
}
|
||||
32
lib/note-token.ts
Normal file
32
lib/note-token.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// session ของแอป note ฝั่ง server (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
|
||||
// แบบ add-in") — แกะ identity จาก access_token (purpose="login" ออกที่
|
||||
// POST /api/note/auth/session อายุ NOTE_SESSION_TTL) ให้ Server Actions และ API
|
||||
// ของแอปใช้ร่วม: token ผิด/หมดอายุ/secret ไม่ตั้ง = null เสมอ (fail closed)
|
||||
// · ห้าม log ค่า token ดิบ
|
||||
//
|
||||
// แยกจาก lib/note-auth.ts (pure ใช้ร่วมกับ client ได้) เพราะต้อง import
|
||||
// lib/docver-token ซึ่งใช้ node:crypto — ห้ามให้ client component import ไฟล์นี้
|
||||
import { verifyDocverLoginToken } from "@/lib/docver-token";
|
||||
|
||||
/** อายุ access_token ของ session note (วินาที) — 8 ชม. เท่า session สมัย cookie:
|
||||
* เว็บไม่มี ticket ฝังไฟล์แบบ add-in ให้ renew อัตโนมัติ หมดแล้วผู้ใช้กลับ
|
||||
* e-Court กดลิงก์ใหม่ */
|
||||
export const NOTE_SESSION_TTL = 8 * 60 * 60;
|
||||
|
||||
export type NoteUserIdentity = { userid: string; username: string };
|
||||
|
||||
/** identity จาก token — null = ไม่ผ่านทุกกรณี (ไม่ตั้ง secret รวม) */
|
||||
export function noteUserFromToken(token: string | null | undefined): NoteUserIdentity | null {
|
||||
if (typeof token !== "string" || token === "") return null;
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
if (secret === undefined || secret === "") return null;
|
||||
const verified = verifyDocverLoginToken(token, secret);
|
||||
return verified.ok ? { userid: verified.claims.userid, username: verified.claims.username } : null;
|
||||
}
|
||||
|
||||
/** อ่าน `Authorization: Bearer <token>` ของ request → identity (route ใช้) */
|
||||
export function noteUserFromRequest(request: Request): NoteUserIdentity | null {
|
||||
const header = request.headers.get("authorization");
|
||||
if (header === null || !header.startsWith("Bearer ")) return null;
|
||||
return noteUserFromToken(header.slice("Bearer ".length).trim());
|
||||
}
|
||||
|
|
@ -55,8 +55,9 @@ export const openApiSpec = {
|
|||
{
|
||||
name: "Judge's Note",
|
||||
description:
|
||||
"API ภายนอก (N10) — backend e-Court เรียกแบบ server-to-server ของ (Judge's Note)" +
|
||||
"ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้",
|
||||
"API ภายนอกของแอป Judge's Note — N10: backend e-Court เรียกแบบ " +
|
||||
"server-to-server ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้ · " +
|
||||
"SSO: เส้น mint login-token สำหรับประกอบลิงก์เข้าสู่ระบบ (PLAN-SSO.md)",
|
||||
},
|
||||
],
|
||||
paths: {
|
||||
|
|
@ -615,6 +616,74 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
"/api/note/auth/token": {
|
||||
post: {
|
||||
tags: ["Judge's Note"],
|
||||
summary: "mint login-token สำหรับลิงก์ SSO เข้าแอป note (PLAN-SSO.md)",
|
||||
description:
|
||||
"ออก `access_token` ชนิด `purpose=login` ไว้ฝังเป็น param เดียว `t=` ของลิงก์ " +
|
||||
"`/note?fileUrl=…&t=<access_token>` ที่ให้ผู้ใช้คลิกจากหน้า e-Court " +
|
||||
"(ทางเข้า session ทางเดียวของแอป note — หน้าแรกแลก credential เป็น session " +
|
||||
"token ที่ POST /api/note/auth/session แบบ add-in)\n\n" +
|
||||
"- token ใช้ได้กับ**การเข้าแอป note เท่านั้น** — เอาไปยิง `/api/documents/*` " +
|
||||
"ของกลุ่ม docver ไม่ได้ (401)\n" +
|
||||
"- แนะนำให้ mint แบบ real-time ตอน render หน้า/ปุ่มให้ user ปัจจุบันเท่านั้น — " +
|
||||
"**ห้ามเก็บ token ลง DB / ฝังอีเมล / แชท** (อายุสั้นตาม ttl_seconds)\n" +
|
||||
"- `userid`/`username` = ค่าเดียวกับ `u_userid`/`u_userlogin` ของ e-Court " +
|
||||
"(สิทธิ์เจ้าของ annotation ของแอป note ผูกกับ userid — ส่งผิดคน = ข้อมูลผูกคนผิด)\n" +
|
||||
"- `fileUrl` ของลิงก์ปลายทางต้องผ่าน allowlist ของ N8 (admincourt.go.th + `EXTERNAL_FILE_HOSTS`)",
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["userid", "username", "fid"],
|
||||
properties: {
|
||||
userid: { type: "string", description: "u_userid ของ e-Court (1–100 ตัวอักษร)" },
|
||||
username: { type: "string", description: "u_userlogin ของ e-Court (1–200 ตัวอักษร)" },
|
||||
fid: { type: "string", description: "fileId ของ e-Court — ใช้เป็น fileId ของ N8 เสมอ (1–200 ตัวอักษร)" },
|
||||
ttl_seconds: {
|
||||
type: "integer",
|
||||
minimum: 60,
|
||||
maximum: 600,
|
||||
default: 300,
|
||||
description: "อายุ token ทางเลือกช่วง 60–600 วินาที (credential อยู่ใน URL ต้องอายุสั้น)",
|
||||
},
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
"mint ให้ผู้ใช้ oom เปิดไฟล์ F123": {
|
||||
value: { userid: "oom", username: "สรวิชญ์", fid: "F123", ttl_seconds: 300 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "login-token พร้อมฝังเป็น param t ของลิงก์ e-court",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/TokenResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
401: {
|
||||
description:
|
||||
"ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY — ข้อความเดียวกันทุกสาเหตุ)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/token": {
|
||||
post: {
|
||||
tags: ["documents"],
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
// อ่าน identity จาก cookie oacCase ฝั่ง server (mock login e-Court) — เรียกจากทุก page
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { OAC_CASE_COOKIE, parseOacCase, type OacUser } from "@/utils/user";
|
||||
|
||||
export function getSessionUser(): OacUser | null {
|
||||
return parseOacCase(cookies().get(OAC_CASE_COOKIE)?.value);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue