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>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-27 11:21:15 +07:00
parent 8ebe5f8644
commit cffc571da5
21 changed files with 1725 additions and 124 deletions

View file

@ -0,0 +1,148 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
addinTicketSecret,
parseTicketTokenBody,
ticketToDocverClaims,
verifyAddinTicket,
} from "@/lib/addin-ticket";
// PLAN-ADDIN-AUTH.md §3 — pure ล้วน (verifyAddinTicket รับ secret ทาง parameter)
// ไม่แตะ @/db/minio — รันได้โดยไม่ต้องตั้ง env อะไร (เหมือน docver-token.test.ts)
const SECRET = "super_secret";
/** ตัว ticket ตัวอย่าง — key เรียงตามตัวอักษรเหมือนตัว stamp จริง (sortObjectKeys ของ lib-utils) */
const TICKET_DATA = JSON.stringify({
exp: Math.floor(Date.now() / 1000) + 300,
fid: "T12_FD155_3s-long_ZZZ",
tid: "T12",
uid: "oom",
uname: "สรวิชญ์",
});
function sign(message: string, secret: string = SECRET): string {
return createHmac("sha256", secret).update(message, "utf8").digest("hex");
}
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
expect(result.ok).toBe(true);
}
describe("verifyAddinTicket", () => {
it("ticket ถูกต้อง + HMAC ตรง = ผ่าน ได้ ticket แกะแล้ว", () => {
const result = verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA), SECRET);
ok(result);
expect(result.ticket.fid).toBe("T12_FD155_3s-long_ZZZ");
expect(result.ticket.uid).toBe("oom");
expect(result.ticket.tid).toBe("T12");
});
it("HMAC ผิด (แก้ ticket 1 ตัวอักษร / secret ผิด / digest เพี้ยน) = ไม่ผ่านทุกกรณี", () => {
const digest = sign(TICKET_DATA);
const tampered = `${TICKET_DATA.slice(0, -2)}xx}`;
expect(verifyAddinTicket(tampered, digest, SECRET).ok).toBe(false);
expect(verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA, "wrong-secret"), SECRET).ok).toBe(false);
const flipped = `${digest.slice(0, -1)}${digest.endsWith("0") ? "1" : "0"}`;
expect(verifyAddinTicket(TICKET_DATA, flipped, SECRET).ok).toBe(false);
});
it("digest รูปไม่ใช่ (ไม่ใช่ hex / สั้น / ว่าง) = ไม่ผ่าน · uppercase hex normalize แล้วผ่านได้", () => {
expect(verifyAddinTicket(TICKET_DATA, "zz".repeat(32), SECRET).ok).toBe(false);
expect(verifyAddinTicket(TICKET_DATA, "abcd", SECRET).ok).toBe(false);
expect(verifyAddinTicket(TICKET_DATA, "", SECRET).ok).toBe(false);
expect(verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA).toUpperCase(), SECRET).ok).toBe(true);
});
it("ticket หมดอายุ (exp อดีต / เท่ากับ now) = ไม่ผ่าน แม้ HMAC ถูก", () => {
const now = Math.floor(Date.now() / 1000);
for (const exp of [now - 1, now]) {
const expired = JSON.stringify({ exp, fid: "f", uid: "u", uname: "n" });
expect(verifyAddinTicket(expired, sign(expired), SECRET, now).ok, `exp=${exp}`).toBe(false);
}
const alive = JSON.stringify({ exp: now + 1, fid: "f", uid: "u", uname: "n" });
expect(verifyAddinTicket(alive, sign(alive), SECRET, now).ok).toBe(true);
});
it("HMAC ถูกแต่ ticket ผิดกติกา (field หลุด allowlist / exp ไม่ใช่ integer) = ไม่ผ่าน", () => {
const extra = JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 60, fid: "f", uid: "u", uname: "n", oops: 1 });
expect(verifyAddinTicket(extra, sign(extra), SECRET).ok).toBe(false);
const badExp = JSON.stringify({ exp: "123", fid: "f", uid: "u", uname: "n" });
expect(verifyAddinTicket(badExp, sign(badExp), SECRET).ok).toBe(false);
});
});
describe("ticketToDocverClaims", () => {
it("แปลง uid→userid, uname→username, tid→template · tid ไม่มี = null", () => {
expect(
ticketToDocverClaims({ exp: 1, fid: "F1", tid: "T12", uid: "oom", uname: "สรวิชญ์" }),
).toEqual({ userid: "oom", username: "สรวิชญ์", fid: "F1", template: "T12" });
expect(
ticketToDocverClaims({ exp: 1, fid: "F1", tid: null, uid: "oom", uname: "สรวิชญ์" }),
).toEqual({ userid: "oom", username: "สรวิชญ์", fid: "F1", template: null });
});
});
describe("parseTicketTokenBody", () => {
it("ครบทั้งคู่ = ได้ค่าตรง ๆ (ไม่แตะค่า — HMAC ต้องคำนวณบน string ดิบ)", () => {
const digest = sign(TICKET_DATA);
const result = parseTicketTokenBody({ ticket_data: TICKET_DATA, HmacSha256: digest });
ok(result);
expect(result.ticketData).toBe(TICKET_DATA);
expect(result.hmacHex).toBe(digest);
});
it("ไม่ใช่ object / มี field อื่นปน = invalid (→ 400)", () => {
for (const raw of [[], "text", null, { ticket_data: "x", HmacSha256: "y", extra: 1 }]) {
const result = parseTicketTokenBody(raw);
expect(result.ok, JSON.stringify(raw)).toBe(false);
if (!result.ok) expect(result.kind).toBe("invalid");
}
});
it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 401 กัน oracle)", () => {
for (const raw of [{}, { ticket_data: "" }, { HmacSha256: "y" }, { ticket_data: null, HmacSha256: "y" }]) {
const result = parseTicketTokenBody(raw);
expect(result.ok, JSON.stringify(raw)).toBe(false);
if (!result.ok) expect(result.kind).toBe("missing");
}
});
it("field ไม่ใช่ string / ยาวเกิน cap = invalid", () => {
for (const raw of [
{ ticket_data: 123, HmacSha256: "y" },
{ ticket_data: "x", HmacSha256: "y".repeat(129) },
{ ticket_data: "x".repeat(2049), HmacSha256: "y" },
]) {
const result = parseTicketTokenBody(raw);
expect(result.ok, JSON.stringify(raw)).toBe(false);
if (!result.ok) expect(result.kind).toBe("invalid");
}
});
});
describe("addinTicketSecret", () => {
// env save/restore — addinTicketSecret อ่านตอนเรียก
const ORIG_ADDIN = process.env.ADDIN_TICKET_SECRET;
const ORIG_SECRET = process.env.SECRET;
it("ลำดับ ADDIN_TICKET_SECRET → SECRET → default \"super_secret\" (trim ทุกตัว)", () => {
delete process.env.ADDIN_TICKET_SECRET;
delete process.env.SECRET;
expect(addinTicketSecret()).toBe("super_secret");
process.env.SECRET = " tmpl-secret ";
expect(addinTicketSecret()).toBe("tmpl-secret");
process.env.ADDIN_TICKET_SECRET = " addin-secret ";
expect(addinTicketSecret()).toBe("addin-secret");
delete process.env.ADDIN_TICKET_SECRET;
process.env.SECRET = " "; // ว่างล้วน = ข้ามไปตัวถัดไป
expect(addinTicketSecret()).toBe("super_secret");
process.env.ADDIN_TICKET_SECRET = ORIG_ADDIN;
process.env.SECRET = ORIG_SECRET;
});
});

View file

@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import {
isTicketExpired,
normalizeHexDigest,
parseDocverTicket,
timingSafeEqualHex,
} from "@/utils/docver-ticket";
// PLAN-ADDIN-AUTH.md §4 — pure ล้วนไม่แตะ env/DB อะไรเลย
// รูป ticket_data อ้างอิงจากตัว stamp จริงของกลุ่ม template (app/api/template/
// render/route.ts applyTemplate — key เรียงตามตัวอักษรหลังเติม exp)
const VALID = JSON.stringify({
exp: 1793067433,
fid: "T12_FD155_3s-long_ZZZ",
tid: "T12",
uid: "oom",
uname: "สรวิชญ์",
});
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
expect(result.ok).toBe(true);
}
describe("parseDocverTicket", () => {
it("ticket ครบทุก field = แกะได้ (trim ค่า string)", () => {
const result = parseDocverTicket(VALID);
ok(result);
expect(result.ticket).toEqual({
exp: 1793067433,
fid: "T12_FD155_3s-long_ZZZ",
tid: "T12",
uid: "oom",
uname: "สรวิชญ์",
});
});
it("tid optional — ไม่ส่ง/ส่งค่าว่าง = null", () => {
const noTid = parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }));
ok(noTid);
expect(noTid.ticket.tid).toBeNull();
const blankTid = parseDocverTicket(
JSON.stringify({ exp: 1, fid: "f", tid: " ", uid: "u", uname: "n" }),
);
ok(blankTid);
expect(blankTid.ticket.tid).toBeNull();
});
it("JSON ไม่ผ่าน / ไม่ใช่ object (array/string/null) = ไม่ผ่าน", () => {
for (const raw of ["not json", "[1,2]", '"text"', "null", "{}"]) {
expect(parseDocverTicket(raw).ok, raw).toBe(false);
}
});
it("field อื่นนอก allowlist = ไม่ผ่าน (กัน typo/field หลุดจาก spec)", () => {
const result = parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", oops: 1 }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("oops");
});
it("exp ไม่ใช่จำนวนเต็ม / หาย / uid-uname-fid หายหรือไม่ใช่ string = ไม่ผ่าน", () => {
for (const ticket of [
{ fid: "f", uid: "u", uname: "n" }, // ไม่มี exp
{ exp: "1793067433", fid: "f", uid: "u", uname: "n" }, // exp เป็น string
{ exp: 1.5, fid: "f", uid: "u", uname: "n" }, // exp ไม่ใช่ integer
{ exp: 1, uid: "u", uname: "n" }, // ไม่มี fid
{ exp: 1, fid: "f", uname: "n" }, // ไม่มี uid
{ exp: 1, fid: "f", uid: "u" }, // ไม่มี uname
{ exp: 1, fid: "", uid: "u", uname: "n" }, // fid ว่าง
{ exp: 1, fid: 3, uid: "u", uname: "n" }, // fid เป็น number
{ exp: 1, fid: "f", uid: "u", uname: "n", tid: 12 }, // tid ไม่ใช่ string
]) {
expect(parseDocverTicket(JSON.stringify(ticket)).ok, JSON.stringify(ticket)).toBe(false);
}
});
it("ความยาวเกิน cap: uid > 100 / uname > 200 / fid > 200 / tid > 100 = ไม่ผ่าน", () => {
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u".repeat(101), uname: "n" })).ok).toBe(false);
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n".repeat(201) })).ok).toBe(false);
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false);
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false);
});
});
describe("isTicketExpired", () => {
it("exp อนาคต = ยังไม่หมด · exp ตรง now พอดี/อดีต = หมดแล้ว", () => {
const now = 1_793_000_000;
expect(isTicketExpired(now + 1, now)).toBe(false);
expect(isTicketExpired(now, now)).toBe(true);
expect(isTicketExpired(now - 1, now)).toBe(true);
});
});
describe("normalizeHexDigest", () => {
const HEX64 = "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9";
it("hex lowercase ครบ 64 = ผ่าน · trim และ uppercase ก็ normalize ให้", () => {
expect(normalizeHexDigest(HEX64)).toBe(HEX64);
expect(normalizeHexDigest(` ${HEX64} `)).toBe(HEX64);
expect(normalizeHexDigest(HEX64.toUpperCase())).toBe(HEX64);
});
it("รูปไม่ใช่ (สั้น/ยาว/ไม่ใช่ hex/ว่าง) = null", () => {
for (const bad of ["", "abc", `${HEX64}00`, `g${HEX64.slice(1)}`, " "]) {
expect(normalizeHexDigest(bad), bad).toBeNull();
}
});
});
describe("timingSafeEqualHex", () => {
it("ค่าเท่ากัน = true ต่างกัน = false · ความยาวไม่เท่า = false ทันที", () => {
const a = "ab".repeat(32);
expect(timingSafeEqualHex(a, a)).toBe(true);
expect(timingSafeEqualHex(a, "cd".repeat(32))).toBe(false);
expect(timingSafeEqualHex(a, "ab")).toBe(false);
});
});

View file

@ -0,0 +1,115 @@
// @vitest-environment jsdom
// [DBG-r3p1] repro ชั่วคราว — หา client-side crash ของ taskpane หลัง implement ticket auth
// (ไม่ใช่ regression test ถาวร — ถอดทิ้งเมื่อหาสาเหตุเจอแล้ว; import .tsx ไม่ได้ใน vitest ปัจจุบัน
// จึงโฟกัส service layer ที่เป็นโค้ดใหม่ทั้งหมด — component repro ใช้ Chrome headless แทน)
import { createHmac, webcrypto } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as authService from "@/wordaddin/services/authService";
import * as ticketService from "@/wordaddin/services/ticketService";
// จับ error ทุกทางที่จะกลายเป็น "Application error: a client-side exception" บน Next
const errors: unknown[] = [];
function trapErrors() {
window.addEventListener("error", (event) => errors.push(event.error ?? event.message));
window.addEventListener("unhandledrejection", (event) => errors.push(event.reason));
}
function makeTicket(): { ticketData: string; ticketHmac: string } {
const ticket = {
exp: Math.floor(Date.now() / 1000) + 300,
fid: "DBG_FID_001",
tid: "T12",
uid: "oom",
uname: "สรวิชญ์",
};
const ticketData = JSON.stringify(ticket);
const ticketHmac = createHmac("sha256", "super_secret").update(ticketData, "utf8").digest("hex");
return { ticketData, ticketHmac };
}
afterEach(() => {
window.localStorage.clear();
// memory fallback ของ session เป็น module state — เคลียร์ด้วยไม่งั้นค้างข้ามเคส
ticketService.clearSession();
authService.signInForDemo(); // รีเซ็ต state ของ authService (username/fid/ticket)
vi.restoreAllMocks();
vi.unstubAllGlobals();
errors.length = 0;
});
describe("[DBG-r3p1] ticket auth crash repro (service layer)", () => {
it("B: signInWithTicket happy-path (มี crypto.subtle + fetch ตอบ 200) ไม่ต้อง throw", async () => {
trapErrors();
// jsdom ไม่มี crypto.subtle — ใส่ webcrypto ของ node แทน (secure context ของ webview จริงมีอยู่แล้ว)
vi.stubGlobal("crypto", webcrypto);
const { ticketData, ticketHmac } = makeTicket();
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
access_token: [
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
Buffer.from(
JSON.stringify({ userid: "oom", fid: "DBG_FID_001", exp: Math.floor(Date.now() / 1000) + 3600 }),
).toString("base64url"),
"sig",
].join("."),
token_type: "Bearer",
expires_in: 3600,
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
const first = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(first.ok, JSON.stringify(first)).toBe(true);
// เปิดเอกสารเดิมซ้ำ — ต้องไม่ยิง /token ซ้ำ (ใช้ token เดิมจาก localStorage)
await authService.signInWithTicket({ ticketData, ticketHmac });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(errors).toEqual([]);
expect(window.localStorage.getItem("wordaddin-auth")).toBeTruthy();
});
it("C: signInWithTicket แบบไม่มี crypto.subtle (webview เก่า) ต้องจบเป็น no-access ไม่ใช่ throw", async () => {
trapErrors();
const noSubtle = { getRandomValues: () => new Uint8Array(0) };
vi.stubGlobal("crypto", noSubtle); // ไม่มี .subtle เลย
const { ticketData, ticketHmac } = makeTicket();
const result = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(result.ok).toBe(false);
expect(errors).toEqual([]);
});
it("D: ticket ผิดทุกแบบ (ขาด/hex เพี้ยน/parse ไม่ผ่าน/หมดอายุ/fetch 401) ต้องคืน result ไม่ใช่ throw", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 })),
);
const { ticketData, ticketHmac } = makeTicket();
const bad = [
{ ticketData: undefined, ticketHmac: undefined },
{ ticketData: "not-json", ticketHmac },
{ ticketData, ticketHmac: "zz".repeat(32) },
{
ticketData: JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), // หมดอายุ + HMAC ถูก
ticketHmac: createHmac("sha256", "super_secret")
.update(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), "utf8")
.digest("hex"),
},
{ ticketData, ticketHmac }, // ถูก แต่ server 401 → token-request-failed
] satisfies { ticketData?: string; ticketHmac?: string }[];
for (const input of bad) {
const result = await authService.signInWithTicket(input);
expect(result.ok, JSON.stringify(input)).toBe(false);
if (!result.ok) expect(typeof result.message).toBe("string");
}
expect(errors).toEqual([]);
});
});