eCourt-note/utils/docver-ticket.ts
Your Name d37bc2a3f0 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

150 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>;