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>
124 lines
6.4 KiB
TypeScript
124 lines
6.4 KiB
TypeScript
// PLAN-ADDIN-AUTH.md §3 — ตรวจ ticket ของ add-in ฝั่ง server (ทางเข้าที่สองของ
|
|
// POST /api/documents/token) — secret อยู่ฝั่ง server อย่างเดียว เพราะทางเข้านี้
|
|
// ผู้เรียกคือ add-in ใน webview ของ Word ซึ่งแนบ x-api-key (server-to-server
|
|
// ของ e-Court) มาไม่ได้ — ตัวพิสูจน์ตัวตนคือ ticket_data + HmacSha256 ที่ e-Court
|
|
// stamp มากับไฟล์ (คำนวณ HMAC ตรงกับ lib/lib-utils.ts generateHmacSha256
|
|
// ของกลุ่ม template: hex lowercase บน string ดิบ)
|
|
//
|
|
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรัน
|
|
// ได้โดยไม่ต้องตั้ง env อะไรเลย (pattern เดียวกับ lib/docver-token.ts)
|
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
|
|
import {
|
|
isTicketExpired,
|
|
normalizeHexDigest,
|
|
parseDocverTicket,
|
|
type DocverTicket,
|
|
} from "@/utils/docver-ticket";
|
|
import type { DocverTokenClaims } from "@/lib/docver-token";
|
|
|
|
/**
|
|
* secret ของ ticket — อ่านตอนเรียก ไม่ใช่ตอน module load
|
|
* ลำดับ: ADDIN_TICKET_SECRET (ของกลุ่มนี้) → SECRET (env เดียวกับตัว stamp
|
|
* ของกลุ่ม template — deployment เดียวกันมักตั้งค่านี้อยู่แล้ว) → "super_secret"
|
|
* (ค่าตาม requirement — dev ใช้ได้ทันที แต่ production ต้องตั้งค่าจริงเพราะ
|
|
* ค่าเดิมรู้กันหมด = ใครก็ปลอม ticket ได้) · ห้าม log ค่าดิบ
|
|
*/
|
|
export function addinTicketSecret(): string {
|
|
return (
|
|
process.env.ADDIN_TICKET_SECRET?.trim() ||
|
|
process.env.SECRET?.trim() ||
|
|
"super_secret"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา
|
|
* (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle —
|
|
* add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว)
|
|
*/
|
|
export function verifyAddinTicket(
|
|
ticketData: string,
|
|
hmacHex: string,
|
|
secret: string,
|
|
nowSec: number = Math.floor(Date.now() / 1000),
|
|
): { ok: true; ticket: DocverTicket } | { ok: false } {
|
|
const digest = normalizeHexDigest(hmacHex);
|
|
if (digest === null) return { ok: false };
|
|
|
|
// เทียบแบบ timing-safe ตาม pattern verifyDocverToken — hash ทั้งสองฝั่ง
|
|
// ด้วย SHA-256 ก่อนเพื่อให้ digest ยาวเท่ากันเสมอ (timingSafeEqual ไม่ throw)
|
|
const expected = createHmac("sha256", secret).update(ticketData, "utf8").digest();
|
|
const expectedHash = createHash("sha256").update(expected).digest();
|
|
const providedHash = createHash("sha256").update(Buffer.from(digest, "hex")).digest();
|
|
if (!timingSafeEqual(providedHash, expectedHash)) return { ok: false };
|
|
|
|
const parsed = parseDocverTicket(ticketData);
|
|
if (!parsed.ok) return { ok: false };
|
|
if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false };
|
|
return { ok: true, ticket: parsed.ticket };
|
|
}
|
|
|
|
/** แปลง ticket → claims ของ access_token (uid→userid, uname→username, tid→template) */
|
|
export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims {
|
|
return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid };
|
|
}
|
|
|
|
/** body ของทางเข้า ticket — allowlist 2 field เป๊ะ (ชื่อเดียวกับ custom properties) */
|
|
const TICKET_BODY_FIELDS = ["ticket_data", "HmacSha256"] as const;
|
|
|
|
export type TicketTokenBodyResult =
|
|
| { ok: true; ticketData: string; hmacHex: string }
|
|
| { ok: false; kind: "missing" | "invalid"; error: string };
|
|
|
|
/**
|
|
* parse body ของ POST /api/documents/token ทางเข้า ticket
|
|
* - `missing` = ไม่มี ticket มาเลย (route ตอบ 401 — คำเดียวกับตอน verify ไม่ผ่าน)
|
|
* - `invalid` = รูปผิด (route ตอบ 400 ตาม convention กัน typo เงียบ ๆ)
|
|
*/
|
|
export function parseTicketTokenBody(raw: unknown): TicketTokenBodyResult {
|
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
return { ok: false, kind: "invalid", error: "body ต้องเป็น JSON object" };
|
|
}
|
|
const record = raw as Record<string, unknown>;
|
|
for (const key of Object.keys(record)) {
|
|
if (!(TICKET_BODY_FIELDS as readonly string[]).includes(key)) {
|
|
return {
|
|
ok: false,
|
|
kind: "invalid",
|
|
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TICKET_BODY_FIELDS.join(", ")})`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const read = (name: (typeof TICKET_BODY_FIELDS)[number]): string | null => {
|
|
const value = record[name];
|
|
if (value === undefined || value === null || value === "") return null; // ไม่ส่ง = missing
|
|
if (typeof value !== "string") return ""; // ส่งผิดชนิด = invalid
|
|
return value;
|
|
};
|
|
|
|
const ticketData = read("ticket_data");
|
|
if (ticketData === null) {
|
|
return { ok: false, kind: "missing", error: "ต้องระบุ ticket_data (string)" };
|
|
}
|
|
if (ticketData === "") {
|
|
return { ok: false, kind: "invalid", error: "ticket_data ต้องเป็น string" };
|
|
}
|
|
if (ticketData.length > 2048) {
|
|
return { ok: false, kind: "invalid", error: "ticket_data ยาวเกินไป" };
|
|
}
|
|
|
|
const hmacHex = read("HmacSha256");
|
|
if (hmacHex === null) {
|
|
return { ok: false, kind: "missing", error: "ต้องระบุ HmacSha256 (string)" };
|
|
}
|
|
if (hmacHex === "") {
|
|
return { ok: false, kind: "invalid", error: "HmacSha256 ต้องเป็น string" };
|
|
}
|
|
if (hmacHex.length > 128) {
|
|
return { ok: false, kind: "invalid", error: "HmacSha256 ยาวเกินไป" };
|
|
}
|
|
|
|
return { ok: true, ticketData, hmacHex };
|
|
}
|