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

@ -24,12 +24,13 @@ import VersionHistoryPanel from "./VersionHistoryPanel";
/**
* Task pane หลัก — state machine:
* checking → (ไม่มี token/owner ในไฟล์) no-access
* → (มี token/owner + ไม่ใช่ tracked document) untracked
* → (มี token/owner + tracked document) tracking
* ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ e-Court
* pass-through: `detect()` อ่าน token/owner จาก custom properties แล้วเรียก
* `authService.signInFromDocument` ผ่านเลย
* checking → (ไม่มี/ตรวจไม่ผ่าน ticket ในไฟล์) no-access
* → (ticket ผ่าน + ไม่ใช่ tracked document) untracked
* → (ticket ผ่าน + tracked document) tracking
* ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ ticket
* ของ e-Court: `detect()` อ่าน ticket_data + HmacSha256 จาก custom properties
* แล้วเรียก `authService.signInWithTicket` (ตรวจ HMAC → exp → session → ขอ token
* ตาม fid — PLAN-ADDIN-AUTH.md)
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — เปลี่ยน: import path,
* JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, ถอดหน้า login,
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
@ -138,8 +139,9 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
/** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */
const [hasUncommitted, setHasUncommitted] = useState(false);
/** AuthRequiredError (ไม่มี token / 401) → หน้า no-access — token จากไฟล์ต่ออายุไม่ได้
* ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
/** AuthRequiredError (ticket หมดอายุ/token ขอไม่ได้ หลัง renew-retry ของ apiClient)
* → หน้า no-access — ticket ต่ออายุไม่ได้ ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court
* จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
const handleAuthRequired = useCallback((error: unknown): boolean => {
if (error instanceof AuthRequiredError) {
setPhase("no-access");
@ -147,7 +149,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]);
setNoAccessNotice("token ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
setNoAccessNotice("ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
return true;
}
return false;
@ -170,11 +172,12 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
[handleAuthRequired],
);
/** ตรวจตอนเปิด task pane (FR-1): token/owner ในไฟล์ → SavedByAddIn → history
/** ตรวจตอนเปิด task pane (FR-1): ticket ในไฟล์ → SavedByAddIn → history
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
* e-Court pass-through — auth ทางเข้าเดียว: ไฟล์ที่ stamp token/owner มา = ผ่านเข้าเลย
* ไม่มีขั้นตอน login (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
* auth ทางเข้าเดียว: ticket_data + HmacSha256 ที่ e-Court stamp มา — signInWithTicket
* ตรวจ HMAC → exp → session/localStorage แล้วขอ token ตาม fid ให้ครบก่อนไหลต่อ
* (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
const detect = useCallback(async () => {
setPhase("checking");
setBootError(null);
@ -185,19 +188,22 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
} catch {
properties = null;
}
if (
!properties ||
!authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner })
) {
setNoAccessNotice(
"ไม่พบ token/owner ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น",
);
if (!properties) {
setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง");
setPhase("no-access");
return;
}
const signIn = await authService.signInWithTicket({
ticketData: properties.ticketData,
ticketHmac: properties.ticketHmac,
});
if (!signIn.ok) {
setNoAccessNotice(signIn.message);
setPhase("no-access");
return;
}
setUsername(authService.getUsername() ?? null);
properties ??= await metadataService.readTrackedProperties();
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
const id = tracked ? properties.documentTrackingId : undefined;
if (!id) {
@ -458,7 +464,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
<div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เอกสารนี้ยังใช้งาน add-in ไม่ได้</p>
<p className="text-sm text-ink-soft">
{noAccessNotice ?? "ไม่พบ token/owner ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
</p>
</div>
) : null}