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>
136 lines
6.5 KiB
TypeScript
136 lines
6.5 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;
|
||
|
||
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>;
|