eCourt-note/docs/PLAN-ADDIN-AUTH.md
Suphonchai Phoonsawat cffc571da5 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>
2026-10-27 11:21:15 +07:00

21 KiB
Raw Blame History

PLAN-ADDIN-AUTH — Add-in Ticket Authen (ticket_data + HmacSha256)

วันที่ตกลงแผน: 2026-10-27 · สถานะ: แผนรออนุมัติ · requirement ต้นทาง: docs/requirement/addin-workflow.md

0. บริบท/เป้าหมาย

เปลี่ยน auth ของ wordaddin จาก e-Court pass-through แบบเดิม (e-Court ขอ token เองแล้ว stamp token/owner ลงไฟล์) เป็น ticket ตาม requirement:

  • e-Court stamp custom properties 2 ค่าในไฟล์ .docx: ticket_data (JSON) + HmacSha256
  • addin ตรวจ HMAC เองฝั่ง client (gate เชิง UX — ตัดเคสไฟล์ถูกแก้ก่อนยิง network)
  • addin เป็นฝ่ายขอ access_token ที่ POST /api/documents/token เก็บใน localStorage ตาม fid (session ต่อ userId) — ไม่ใช่ e-Court อีกต่อไป
  • ถอดระบบเก่า token/owner ออกทั้งหมด (implement ไว้ 2026-10-27 ยังไม่มีไฟล์จริงใช้ — ไม่ต้องรองรับสองแบบพร้อมกัน)

1. ข้อตกลงที่ตัดสินใจแล้ว (2026-10-27)

# เรื่อง ข้อตกลง
D1 addin ยืนยันกับ /token อย่างไร ขยาย POST /api/documents/token รับ ticket เพิ่ม — body {ticket_data, HmacSha256} server ตรวจ HMAC+exp ด้วย secret ใน env ฝั่ง server ก่อนออก token · ทางเข้า x-api-key เดิม (e-Court server-to-server) ใช้ต่อคู่กัน
D2 โค้ดซ้ำสองฝั่ง แชร์ส่วน pure ผ่าน utils/docver-ticket.ts (โซนกลาง client ใช้ได้ — ไม่แตะกติกา "wordaddin ห้าม import จาก lib/") · ฝั่ง crypto ฉีดเข้าทาง parameter: client = Web Crypto, server = node:crypto
D3 client-side HMAC ทำตาม requirement (sign เทียบเองก่อน) โดย secret มาจาก NEXT_PUBLIC_ADDIN_TICKET_SECRET — ยอมรับว่าค่าใน bundle สกัดได้ จึงถือเป็นแค่ UX gate, ความมั่นคงจริงอยู่ที่ server verify
D4 รูปแบบ HMAC sign บน string ดิบของ ticket_data ตามที่อ่านได้จาก property (ห้าม trim/normalize) · digest เป็น hex lowercase 64 ตัว — การเทียบ normalize เป็น lowercase ก่อน (ฝั่ง e-Court stamp ตัวพิมพ์ใหญ่ก็ผ่าน)
D5 tid = template ของ claims (tid optional → template: null) · map uid→userid, uname→username, fid ตรง ๆ
D6 token หมดอายุ (TTL ~1 ชม.) แต่ ticket ยังไม่หมด เก็บ exp ของ token (decode จาก JWT) — ก่อนใช้ถ้าหมดอายุและ ticket ยังไม่หมด → renew อัตโนมัติ · โดน 401 กลาง session → renew จาก ticket 1 ครั้งแล้ว retry ก่อนไป no-access
D7 localStorage key เดียว wordaddin-auth เก็บ JSON {v:1, userId, userName, tokens: {[fid]: {token, exp}}} · เจอ user ใหม่ = เคลียร์ทั้งก้อน (รวม token ทุก fid ของ user เก่า) · webview block localStorage = fallback memory (pattern versioningService)
D8 "disable Add-in ทั้งหมด" = phase no-access ที่มีอยู่ โดยแยกข้อความตามสาเหตุ: ไม่พบ ticket / ไฟล์ถูกแก้ไข (HMAC) / ticket หมดอายุ
D9 ช่วง mock (NEXT_PUBLIC_ADDIN_API_BASE_URL ว่าง) addin ยิงขอ token จริงที่ same-origin withBasePath("/api/documents/token") (ไม่ผูกกับ apiBaseUrl ของ versioning) — versioning ยัง mock ต่อ
D10 เครื่องมือทดสอบ มีสคริปต์ stamp ticket ลงไฟล์ .docx ตัวอย่าง (scripts/make-ticket-docx.mjs) — ไม่งั้นทดสอบ flow นี้ใน Word จริงไม่ได้เลย

2. Contract ของ ticket (ต้องส่งให้ทีม e-Court ฝั่ง stamp)

Custom properties ของไฟล์ .docx (ชื่อ key ต้องตรงเป๊ะ — case-sensitive):

ticket_data = {"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}
HmacSha256  = <hex 64 ตัว lowercase>  = HMAC-SHA256(secret, ticket_data ดิบทั้ง string ไม่ trim)
field type เงื่อนไข
exp int (unix วินาที) บังคับ · exp <= now = หมดอายุ
fid string บังคับ · trim 1–200 · เป็น docId ของทุก operation ของกลุ่ม docver
uid string บังคับ · trim 1–100
uname string บังคับ · trim 1–200
tid string optional · ≤100 · template ต้นทาง (ไม่มี = template: null)
  • field อื่นปนมา = reject (allowlist ตาม convention ของ repo)
  • secret ปัจจุบัน = "super_secret" — e-Court + ADDIN_TICKET_SECRET (server) + NEXT_PUBLIC_ADDIN_TICKET_SECRET (client) ต้องตรงกันทั้งสาม
  • ⚠️ custom property ของ Word จำกัด value ~255 ตัวอักษร — JSON ของ ticket_data ต้องออกแบบสั้น (fid/tid/uname ยาวมากจะทะลุ; ฝั่ง addin เจอค่าเกิน = ถือว่าไฟล์เสียหาย → no-access)

3. ฝั่ง server — POST /api/documents/token รับ ticket เพิ่ม

Branch ตาม header (ตัวตั้ง = x-api-key):

เคส พฤติกรรม
มี header x-api-key ทางเดิมทุกอย่าง — requireDocverApiKey + parseTokenRequestBody (body allowlist userid/username/fid/template, ส่ง field ticket ปนมา = 400 ตาม allowlist เดิม)
ไม่มี x-api-key + body {ticket_data: string, HmacSha256: string} ทางใหม่ — verifyAddinTicket → map claims → issueDocverToken → response รูปเดียวกัน {access_token, token_type, expires_in}
ไม่มี x-api-key + body ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string 400 (รูปผิดก่อนถึงการ verify)
HMAC ผิด / parse ticket ไม่ผ่าน / exp หมดอายุ 401 ข้อความเดียวกันทุกสาเหตุ (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว)
ไม่มี x-api-key และไม่มี ticket fields เลย 401
DOCVER_TOKEN_SECRET ไม่ตั้ง 500 (เดิม)
  • การเทียบ digest ฝั่ง server: hash ทั้งสองฝั่งด้วย SHA-256 แล้ว timingSafeEqual — pattern verifyDocverToken ของ lib/docver-token.ts เป๊ะ
  • middleware ยกเว้น api/documents/* อยู่แล้ว — ไม่ต้องแต้

4. โค้ดแชร์ — utils/docver-ticket.ts (pure, ไม่มี dependency เลย)

export interface DocverTicket { exp: number; fid: string; tid: string | null; uid: string; uname: string }
export function parseDocverTicket(raw: string | unknown): { ok: true; ticket: DocverTicket } | { ok: false; error: string }
export function isTicketExpired(exp: number, nowSec?: number): boolean   // exp <= now
export function normalizeHexDigest(value: string): string | null        // trim+lowercase+ตรวจ ^[0-9a-f]{64}$
export function timingSafeEqualHex(a: string, b: string): boolean       // constant-time บน hex ที่ normalize แล้ว
export type HmacSha256Hex = (message: string, secret: string) => Promise<string>  // adapter ที่สองฝั่งฉีด
  • lib/ (server) กับ wordaddin/ (client) ใช้ parseDocverTicket/isTicketExpired ตัวเดียวกัน — schema drift กันได้จากฝั่งเดียว

5. ฝั่ง addin — flow ตอนเปิดเอกสาร (map ขั้นตอน 1–7 ของ requirement)

TaskpaneApp.detect() → authService.signInWithTicket({ticketData, ticketHmac}):

1. อ่าน properties ticket_data + HmacSha256 (readTrackedProperties)
2. ขาดข้อใดข้อหนึ่ง / ค่ายาวเกิน 255 → no-access "ไม่พบ ticket ของ e-Court ในไฟล์"
   parseDocverTicket ไม่ผ่าน → no-access "ไฟล์ถูกแก้ไข"
   HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข"
3. session = localStorage["wordaddin-auth"] (fallback memory)
4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่):
   · ticket หมดอายุ → no-access "ticket หมดอายุ"
   · เคลียร์ session ทั้งก้อน → ขอ token (ทาง ticket ของ /token) → fail → no-access "ขอ token ไม่สำเร็จ"
   · เก็บ session ใหม่ {userId: uid, userName: uname, tokens: {[fid]: {token, exp}}}
5. uid ตรงกัน (คนเดิม):
   · tokens[fid] มี (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ตรวจ ticket exp (ตามเอกสาร)
     — token หมดอายุ + ticket ยังไม่หมด → renew เงียบ ๆ ก่อน (D6)
     — token หมดอายุ + ticket หมดด้วย → เข้า UI ได้แต่ API จะ 401 → renew fail → no-access ตอนใช้งาน
   · tokens[fid] ไม่มี (เอกสารใหม่) → ตรวจ exp แล้วขอ token เก็บเหมือนข้อ 4
6. ตั้ง state ภายใน (fid/token/username/ticket ดิบไว้ renew) → ไหลต่อ phase
   untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน
  • authService surface ใหม่: signInWithTicket / getAccessToken (token ของ fid ปัจจุบัน) / renewTokenFromTicket / getUsername — ถอด signInFromDocument + decodeJwtField สำหรับ uid (ticket มี uid/uname ตรง ๆ แล้ว เหลือ decode เฉพาะ exp ของ JWT ตอนเก็บ token)
  • apiClient ตอนโดน 401: renewTokenFromTicket() → สำเร็จ = retry request 1 ครั้ง → ยัง 401/ไม่มี ticket = AuthRequiredError (UI ไป no-access — เดิมมีอยู่แล้ว)
  • ?addindemo ยังใช้ได้ (signInForDemo คงเดิม)

6. ไฟล์ที่สร้าง/แต้/ถอด

สร้าง

  • utils/docver-ticket.ts — §4
  • lib/addin-ticket.ts — server-only: addinTicketSecret() (env ADDIN_TICKET_SECRET trim หรือ default "super_secret") + verifyAddinTicket(ticketData, hmacHex, secret, nowSec?) (HMAC node:crypto + timing-safe + parse + exp) + ticketToDocverClaims(ticket) — ห้าม import @/db/minio (unit test รันได้เปล่า ๆ)
  • wordaddin/services/ticketService.ts — client: verifyTicketHmacWeb (crypto.subtle), session store (loadSession/saveSession/clearSession — try/catch + memory fallback), requestDocverToken (POST withBasePath("/api/documents/token") body ticket)
  • scripts/make-ticket-docx.mjs — §9
  • tests/utils/docver-ticket.test.ts, tests/lib/addin-ticket.test.ts (vitest — pattern tests/lib/docver-token.test.ts)

แต้

  • app/api/documents/token/route.ts — branch ตาม §3
  • wordaddin/config.ts — ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret"
  • wordaddin/services/metadataService.ts — ถอด ECOURT_TOKEN_PROPERTY/ECOURT_OWNER_PROPERTY → TICKET_DATA_PROPERTY = "ticket_data" + TICKET_HMAC_PROPERTY = "HmacSha256" (readTrackedProperties อ่านคู่นี้แทน ecourtToken/ecourtOwner)
  • wordaddin/services/authService.ts — rewrite ตาม §5
  • wordaddin/services/apiClient.ts — renew-retry ตอน 401
  • components/wordaddin/TaskpaneApp.tsx — detect() เรียก signInWithTicket + ข้อความ no-access 3 แบบ
  • lib/openapi.ts — POST /token มี 2 รูป body (oneOf: identity+x-api-key / ticket)
  • .env.example, docs/WORDADDIN-DEV.md, docs/addin-ui-flow.md, docs/PLAN-DOCVER.md (§token ชี้มาที่นี่), CLAUDE.md

ถอด

  • ระบบ token/owner ทั้งเส้น (properties + signInFromDocument + decodeJwtField ส่วน uid) — แทนด้วย ticket เต็มตัว

7. Env

env default หมายเหตุ
ADDIN_TICKET_SECRET SECRET ของกลุ่ม template → "super_secret" server ตรวจ HMAC ทาง ticket ของ /token — อ่านตามลำดับ ADDIN_TICKET_SECRET → SECRET (deployment เดียวกับตัว stamp ของกลุ่ม template มักตั้งไว้แล้ว) → "super_secret" · production ต้องตั้งค่าใหม่ (ค่า default รู้กันหมด = ปลอม ticket ได้) และต้องตรงกับฝั่ง e-Court · ห้าม log ค่าดิบ
NEXT_PUBLIC_ADDIN_TICKET_SECRET "super_secret" ฝั่ง addin (UX gate) — inline ลง bundle · ต้องตรงกับฝั่ง e-Court เช่นกัน

หมายเหตุจากตอน implement: ตัว stamp ticket จริงคือกลุ่ม template (app/api/template/render/route.ts → applyTemplate — sort key ตามตัวอักษรหลังเติม exp แล้ว HMAC hex lowercase ผ่าน lib/lib-utils.ts + stamp ลงไฟล์ด้วย setCustomDocProps ของ lib/lib-tmpl.ts อ่าน env SECRET กับ TICKET_SEC default 300 วิ — ไฟล์ยังมี property Ed25519 เพิ่มด้วยแต่ out of scope ของแผนนี้)

(เลือก default แทน fail-closed ตาม requirement ที่ระบุค่าปัจจุบันไว้ — dev ใช้ได้ทันที แต่ comment เตือน production ชัด ๆ ทั้ง .env.example และแผนนี้)

8. Edge cases

  • property ยาวเกิน 255 → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว — เช็คความยาวเพื่อข้อความที่ชัดกว่า)
  • localStorage ถูก block (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว (เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — ยอมรับได้)
  • uname ไทย — เป็น string ตรง ๆ ไม่ผ่าน base64 ไม่มีปัญหา mojibake (ต่างจาก decode JWT payload)
  • exp == now → หมดอายุ
  • 401 จาก endpoint อื่นระหว่างใช้งาน → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access
  • Word เปิดหลายเอกสารพร้อมกัน (user เดียวกัน fid ต่างกัน) — tokens map รองรับ; current fid เป็น state ต่อ taskpane instance (memory) — แยกกันอยู่แล้ว
  • ไฟล์ทั่วไปไม่มี ticket → no-access เหมือนเดิม (ข้อความเดิมปรับคำ)

9. สคริปต์ทดสอบ — scripts/make-ticket-docx.mjs

Dev tool รันมือ node scripts/make-ticket-docx.mjs <file.docx> [options]:

  • option: --fid --uid --uname --tid --exp <unix|+seconds> --secret <hmac-secret> และ --tracked (stamp SavedByAddIn+DocumentTrackingId เพื่อทดสอบ phase tracking ด้วย)
  • ทำงาน: สร้าง JSON ticket → HMAC-SHA256 hex → เขียน custom properties ลง docProps/custom.xml ผ่าน pizzip (dependency อยู่แล้วจากกลุ่ม template) — สร้าง custom.xml เองถ้าไฟล์ยังไม่มี (พร้อม override ใน [Content_Types].xml) · เขียนทับไฟล์เดิมเฉพาะเมื่อส่ง --in-place ไม่งั้นเขียนไฟล์ใหม่ <name>.ticket.docx (กฎ non-destructive ผูกกับ public/ เท่านั้นแต่ถือหลักเดียวกัน)
  • default secret/exp สอดคล้อง .env ตัวอย่าง

10. Verify criteria

  1. npx vitest run ผ่านทั้งชุด (ไฟล์ใหม่ + เดิม — โดยเฉพาะ tests/lib/docver-token.test.ts ไม่พัง)
  2. npx tsc --noEmit ผ่าน
  3. curl regression ที่ /token: (ก) x-api-key + identity body ออก token ได้เหมือนเดิม (ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400
  4. ทดสอบใน Word จริง (sideload ตาม docs/WORDADDIN-DEV.md) ด้วยไฟล์จาก make-ticket-docx: ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ ticket_data 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · exp อดีต → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ → ไม่ยิง /token ซ้ำ (ใช้ token เดิม) · ตั้ง DOCVER_TOKEN_TTL_SECONDS สั้น ๆ แล้วเปิดซ้ำหลังหมดอายุ → renew อัตโนมัติใช้ต่อได้ · เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์
  5. รัน plan-reviewer + code-reviewer ตามกติกา .claude/

11. ลำดับงาน

  1. utils/docver-ticket.ts + tests/utils/docver-ticket.test.ts
  2. lib/addin-ticket.ts + tests/lib/addin-ticket.test.ts
  3. app/api/documents/token/route.ts + lib/openapi.ts → verify 10.3 ด้วย curl
  4. wordaddin/config.ts + metadataService.ts (properties)
  5. ticketService.ts + authService.ts (rewrite)
  6. TaskpaneApp.tsx (detect + ข้อความ) + apiClient.ts (renew-retry)
  7. scripts/make-ticket-docx.mjs
  8. env/docs/CLAUDE.md sync → verify 10.1–10.5

12. Out of scope

  • ปลด mock versioningService (ยัง mock ต่อ — token/flow ที่ทำรอบนี้พร้อมใช้วันปลด)
  • ความไม่ตรงกันของ documentId (GUID ที่ stamp) กับ docId (= fid) ของกลุ่ม docver — เป็นเรื่องของวันปลด mock (token ผูก fid แต่ mock ยังเรียกด้วย GUID — ไม่กระทบ auth)
  • UI จัดการ session/ออกจากระบบ, refresh token แบบมี revoke, ฝั่ง e-Court stamp (เขาทำ — เราส่ง §2 ให้)