eCourt-note/utils/docver-ticket.ts
Suphonchai Phoonsawat cffc571da5 feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)
Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):

- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
  client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
  {ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
  secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
  unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
  wordaddin-auth keyed by fid, same-origin token request), authService
  rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
  metadataService reads ticket_data/HmacSha256, no-access messages per
  cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
  for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
  in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
  crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
  request bodies for /token), CLAUDE.md, .env.example

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 11:21:15 +07:00

136 lines
6.5 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;
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)" };
const uname = field("uname");
if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" };
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>;