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

@ -4,7 +4,7 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม
**ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview)
สถานะช่วงนี้: **ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — auth เข้าทางเดียวคือ e-Court pass-through จาก token/owner ในไฟล์ ดูหัว "ทดสอบ e-Court pass-through") ส่วน **API ยังเป็น mock** (version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง
สถานะช่วงนี้: **ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — auth เข้าทางเดียวคือ **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ในไฟล์): add-in ตรวจ HMAC เอง → ขอ token ที่ `/api/documents/token` → เก็บใน localStorage ตาม fid ดูหัว "ทดสอบ e-Court ticket" และแผน `docs/PLAN-ADDIN-AUTH.md`) ส่วน **API ยังเป็น mock** (version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง
## ไฟล์หลัก
@ -13,7 +13,7 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม
| `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` |
| `app/wordaddin/manifest.xml/route.ts` | serve manifest พร้อม **rewrite origin ตามโฮสต์ที่ขอ** (อ่าน `x-forwarded-proto`/`x-forwarded-host` ก่อนแล้ว fallback host ตรง ผ่าน `utils/request-origin.ts` ที่แชร์กับ origin ของ deep link N10 — โฮสต์ที่ไม่ใช่ localhost บังคับ https เพราะ Office รับ https เท่านั้น) |
| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/no-access/untracked/tracking` + ทุก handler |
| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `versioningService` เป็น mock (`authService` = pass-through จริง ไม่มี login) |
| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `versioningService` เป็น mock (`authService` + `ticketService` = ticket auth จริง ไม่มี login — PLAN-ADDIN-AUTH.md) |
| `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ |
| `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) |
| `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) |
@ -22,8 +22,9 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม
| ตัวแปร | default | ใช้เมื่อไหร่ |
|---|---|---|
| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) |
| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อ fallback — ใช้ตอนไฟล์มี token แต่ไม่มี owner/uid และโหมด `?addindemo` (แสดง header + เป็น `createdByUserId` ของ mock) |
| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) — ไม่เกี่ยวกับการขอ token (ยิง same-origin `/docedit/api/documents/token` เสมอ) |
| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อ fallback ของโหมด `?addindemo` (แสดง header + เป็น `createdByUserId` ของ mock) |
| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate) — ต้องตรงกับตัว stamp ของ e-Court และ `ADDIN_TICKET_SECRET`/`SECRET` ฝั่ง server (PLAN-ADDIN-AUTH.md §7) · **production ต้องตั้งค่าจริงทั้งสองฝั่ง** |
## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word)
@ -56,15 +57,28 @@ Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแ
- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + ตั้ง session mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected
- หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง
## ทดสอบ e-Court pass-through (auth ทางเข้าเดียว)
## ทดสอบ e-Court ticket (auth ทางเข้าเดียว)
ไฟล์ที่ e-Court stamp custom properties `token` และ/หรือ `owner` ไว้ จะใช้ได้เลย — ไม่มี login ให้กดตั้งแต่ยกเลิก Keycloak (2026-10-27): `detect()` อ่านค่าทั้งสองตอนเปิด task pane แล้วเรียก `authService.signInFromDocument()` (username ได้จาก `owner` → ถอด `uid` ใน JWT payload → `NEXT_PUBLIC_ADDIN_MOCK_USERNAME`, token จากไฟล์เก็บใน memory ให้ `getAccessToken()` ใช้ต่อ) · ไฟล์ที่ไม่มีทั้งคู่ = phase `no-access` (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต)
ไฟล์ที่ e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ปัจจุบัน `super_secret`) ไว้ จะใช้ได้เลย — ไม่มี login ให้กด: `detect()` อ่านคู่นี้ตอนเปิด task pane แล้วเรียก `authService.signInWithTicket()` ตาม flow ของ `docs/requirement/addin-workflow.md` (ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage `wordaddin-auth` ตาม uid/fid → ขอ access_token ที่ `/docedit/api/documents/token` แบบ body ticket → เก็บ token ตาม fid) · ไฟล์ที่ไม่มีคู่นี้ / HMAC ไม่ตรง / ticket หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ
ทดสอบเองใน Word โดยไม่ต้องรอ e-Court: เปิดไฟล์ → **File → Info → Properties → Advanced Properties → แท็บ Custom** → เพิ่ม property ชื่อ `token` (ค่า JWT ตัวอย่าง `eyJ...` — payload มี `uid` ก็แสดงเป็นชื่อผู้ใช้ได้) และ/หรือ `owner` (เช่น `u1001`) → เปิด task pane ใหม่ → ต้องเข้าเฟส untracked/tracking ทันทีไม่ผ่านหน้า no-access · ไฟล์ที่ไม่ใส่อะไรเลย = หน้า no-access
ทดสอบเองใน Word โดยไม่ต้องรอ e-Court — สร้างไฟล์ทดสอบด้วยสคริปต์ (คำนวณ HMAC ตรงกับตัว stamp จริงของกลุ่ม template):
```bash
# สร้าง <file>.ticket.docx (ไม่แตะไฟล์เดิม) exp +5 นาที เท่า TICKET_SEC ของตัว stamp
node scripts/make-ticket-docx.mjs path/to/file.docx --fid DEMO_FID_001 --uid oom --uname "สรวิชญ์" --tid T12
# ดู custom properties ที่มีในไฟล์
node scripts/make-ticket-docx.mjs path/to/file.ticket.docx --list
# ทดสอบ phase tracking ด้วย (stamp SavedByAddIn + DocumentTrackingId เพิ่ม)
node scripts/make-ticket-docx.mjs path/to/file.docx --tracked --in-place
```
แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case: แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · สร้างด้วย `--exp +1` รอ 1 นาทีแล้วเปิด → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ (จน ticket หมดอายุแต่ token ยังอยู่ใน localStorage) → ยังใช้ได้ตาม requirement ขั้น 7
หมายเหตุ: การขอ token ยิง same-origin `/docedit/api/documents/token` จริง (ไม่ผูก `NEXT_PUBLIC_ADDIN_API_BASE_URL`) — dev server ต้องรันอยู่และ secret ฝั่ง server (`ADDIN_TICKET_SECRET` → `SECRET` → default `super_secret`) ตรงกับ `--secret` ของสคริปต์
## การสลับกลับของจริง (วันหน้า)
- **Auth:** ไม่มี login ให้สลับแล้ว — **ยกเลิก Keycloak ถาวร 2026-10-27** (ต้นฉบับ OIDC/PKCE + Office Dialog API ของ poc เป็น historical record ที่ `D:\word-addin-poc\addin` เท่านั้น) · auth ของ add-in คือ e-Court pass-through ผ่าน `signInFromDocument` (token/owner ในไฟล์) — งานต่อยอดคือรูปแบบ/วิธี verify ของ credential ที่ e-Court stamp (ร่าง: `docs/requirement/addin-workflow.md`) · โดน 401 กลาง flow = `AuthRequiredError` → หน้า `no-access` (token จากไฟล์ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court)
- **Auth:** ไม่มี login ให้สลับแล้ว — **ยกเลิก Keycloak ถาวร 2026-10-27** · auth ของ add-in คือ ticket ของ e-Court ผ่าน `signInWithTicket` (ticket_data + HmacSha256 ในไฟล์ — แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): add-in ตรวจ HMAC ฝั่ง client (UX gate) แล้วขอ access_token ที่ `/api/documents/token` ซึ่ง server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server · โดน 401 กลาง flow = ลอง renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` (ticket ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court)
- **API:** `wordaddin/services/versioningService.ts` เก็บชื่อ/ลายเซ็นเหมือนต้นฉบับเป๊ะ — ก้อบ body เดิม (multipart POST ผ่าน `apiClient` ซึ่งพอร์ตไว้แล้ว) กลับมา + ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` (contract ปัจจุบันของกลุ่ม docver สรุปไว้ใน docblock ของไฟล์นั้น + docs/PLAN-DOCVER.md — identity/docId แกะจาก Bearer token ทั้งหมด)
- ชุด unit test ของ services (jest → vitest) ยังไม่พอร์ต — ต้นฉบับอยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` + mock ที่ `src/test/`