เปลี่ยน 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>
150 lines
7.6 KiB
TypeScript
150 lines
7.6 KiB
TypeScript
// PLAN-ADDIN-AUTH.md §4 — ticket ของ add-in (custom properties `ticket_data` +
|
||
// `HmacSha256` ที่ e-Court ผ่านกลุ่ม template stamp ลงไฟล์) — ส่วน pure ที่
|
||
// ใช้ร่วมกันทั้งฝั่ง server (lib/addin-ticket.ts — route /api/documents/token)
|
||
// และฝั่ง client ของ add-in (wordaddin/services/ticketService.ts)
|
||
//
|
||
// กติกา (docs/requirement/addin-workflow.md + lib/lib-utils.ts generateHmacSha256
|
||
// ของตัว stamp ฝั่ง template):
|
||
// - ticket_data เป็น JSON string ตามที่ stamp ไว้เป๊ะ (key เรียงตามตัวอักษร
|
||
// หลังเติม exp) — **HMAC ต้องคำนวณบน string ดิบนี้ ห้าม trim/normalize**
|
||
// - HmacSha256 เป็น hex lowercase 64 ตัว (การเทียบ normalize เป็น lowercase
|
||
// ให้ก่อน — ฝั่ง stamp ตัวพิมพ์ใหญ่ก็ผ่านได้)
|
||
// - ห้าม import อะไรที่เป็น platform-specific (node:crypto/crypto.subtle) —
|
||
// module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word โดยส่ง
|
||
// ตัวคำนวณ HMAC เข้ามาทาง parameter แทน
|
||
|
||
/** ticket ที่แกะจาก ticket_data — ชื่อ field ตามที่ e-Court กำหนด (requirement) */
|
||
export interface DocverTicket {
|
||
/** หมดอายุเมื่อไร — unix epoch วินาที */
|
||
exp: number;
|
||
/** fileId ของ e-Court — เป็น docId ของทุก operation ของกลุ่ม docver ตรง ๆ */
|
||
fid: string;
|
||
/** template ต้นทาง — optional (ไม่มี = null) */
|
||
tid: string | null;
|
||
/** รหัสผู้ใช้ → claim `userid` */
|
||
uid: string;
|
||
/** ชื่อผู้ใช้ → claim `username` */
|
||
uname: string;
|
||
}
|
||
|
||
/** ความยาว cap ต่อ field — ตาม parseTokenRequestBody ของ /api/documents/token */
|
||
const TICKET_LIMITS = {
|
||
uid: 100,
|
||
uname: 200,
|
||
fid: 200,
|
||
tid: 100,
|
||
} as const;
|
||
|
||
/** allowlist ของ ticket_data — field อื่นปนมา = reject (กัน typo เงียบ ๆ ตาม convention ของ repo) */
|
||
const TICKET_FIELDS = ["exp", "fid", "tid", "uid", "uname"] as const;
|
||
|
||
/** อักขระที่ uid/uname ใช้ไม่ได้ — & = + % เป็นชุดที่ตีความต่างกันตาม context
|
||
* ง่าย (query string/URL decode/ฟอร์ม) และค่า identity นี้ไหลลง DB เป็น
|
||
* user_id/user_name ของ annotation/bookmark (แสดง UI + เงื่อนไขค้นหา) —
|
||
* เคยทำ identity เพี้ยนในรอบ cookie (payload ถูก parse ผ่าน URLSearchParams —
|
||
* ถอดแล้ว 2026-10-30) จึงกันไว้ตั้งแต่แกะ ticket (เกณฑ์เดียวกับ
|
||
* parseLoginTokenBody ของ lib/note-auth.ts) */
|
||
const UNSAFE_IDENTITY_RE = /[&=+%]/;
|
||
|
||
export type DocverTicketParseResult =
|
||
| { ok: true; ticket: DocverTicket }
|
||
| { ok: false; error: string };
|
||
|
||
/**
|
||
* แกะ + ตรวจ ticket จาก string ดิบของ property `ticket_data`
|
||
* คืนค่าที่ trim แล้ว (uid/uname/fid/tid) — exp เป็น number ตรง ๆ
|
||
*/
|
||
export function parseDocverTicket(raw: string): DocverTicketParseResult {
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(raw);
|
||
} catch {
|
||
return { ok: false, error: "ticket_data ไม่ใช่ JSON ที่ถูกต้อง" };
|
||
}
|
||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
return { ok: false, error: "ticket_data ต้องเป็น JSON object" };
|
||
}
|
||
const record = parsed as Record<string, unknown>;
|
||
for (const key of Object.keys(record)) {
|
||
if (!(TICKET_FIELDS as readonly string[]).includes(key)) {
|
||
return {
|
||
ok: false,
|
||
error: `ticket_data มี field ที่ไม่รู้จัก '${key}' (รับเฉพาะ ${TICKET_FIELDS.join(", ")})`,
|
||
};
|
||
}
|
||
}
|
||
|
||
const exp = record.exp;
|
||
if (typeof exp !== "number" || !Number.isSafeInteger(exp)) {
|
||
return { ok: false, error: "ticket_data ต้องมี exp เป็นจำนวนเต็ม (unix วินาที)" };
|
||
}
|
||
|
||
const field = (name: "uid" | "uname" | "fid"): string | null => {
|
||
const value = record[name];
|
||
if (typeof value !== "string" || value.trim() === "") {
|
||
return null;
|
||
}
|
||
const trimmed = value.trim();
|
||
return trimmed.length <= TICKET_LIMITS[name] ? trimmed : null;
|
||
};
|
||
|
||
const uid = field("uid");
|
||
if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" };
|
||
if (UNSAFE_IDENTITY_RE.test(uid)) {
|
||
return { ok: false, error: "ticket_data ของ uid มีอักขระ & = + % ที่รองรับไม่ได้" };
|
||
}
|
||
const uname = field("uname");
|
||
if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" };
|
||
if (UNSAFE_IDENTITY_RE.test(uname)) {
|
||
return { ok: false, error: "ticket_data ของ uname มีอักขระ & = + % ที่รองรับไม่ได้" };
|
||
}
|
||
const fid = field("fid");
|
||
if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" };
|
||
|
||
let tid: string | null = null;
|
||
if (record.tid !== undefined) {
|
||
if (typeof record.tid !== "string") {
|
||
return { ok: false, error: "ticket_data ของ tid ต้องเป็น string" };
|
||
}
|
||
const trimmed = record.tid.trim();
|
||
if (trimmed !== "") {
|
||
if (trimmed.length > TICKET_LIMITS.tid) {
|
||
return { ok: false, error: "ticket_data ของ tid ยาวเกิน 100 ตัวอักษร" };
|
||
}
|
||
tid = trimmed;
|
||
}
|
||
}
|
||
|
||
return { ok: true, ticket: { exp, fid, tid, uid, uname } };
|
||
}
|
||
|
||
/** exp หมดอายุไหม — exp เท่ากับ now พอดีก็ถือว่าหมดแล้ว */
|
||
export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean {
|
||
return exp <= nowSec;
|
||
}
|
||
|
||
/**
|
||
* normalize ค่า digest ที่ได้รับ (property `HmacSha256` / body field) —
|
||
* trim + lowercase แล้วตรวจว่าเป็น hex ครบ 64 ตัว คืน null เมื่อรูปไม่ใช่
|
||
*/
|
||
export function normalizeHexDigest(value: string): string | null {
|
||
const normalized = value.trim().toLowerCase();
|
||
return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null;
|
||
}
|
||
|
||
/**
|
||
* เทียบ hex digest สองค่าแบบ constant-time (best-effort ของ pure JS —
|
||
* ฝั่ง server จริงใช้ node:crypto timingSafeEqual ผ่าน lib/addin-ticket.ts
|
||
* ตัวนี้ใช้กับ UX gate ฝั่ง client เป็นหลัก) — ค่าที่ส่งเข้าควร normalize แล้ว
|
||
*/
|
||
export function timingSafeEqualHex(a: string, b: string): boolean {
|
||
if (a.length !== b.length) return false;
|
||
let diff = 0;
|
||
for (let i = 0; i < a.length; i++) {
|
||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||
}
|
||
return diff === 0;
|
||
}
|
||
|
||
/** ตัวคำนวณ HMAC-SHA256 คืนค่าเป็น hex lowercase — สองฝั่งฉีดของตัวเอง (Web Crypto / node:crypto) */
|
||
export type HmacSha256Hex = (message: string, secret: string) => Promise<string>;
|