From cffc571da5d8e11ccb9e1424a9918c6c35e14bab Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Tue, 27 Oct 2026 11:21:15 +0700 Subject: [PATCH] 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 --- .env.example | 11 + CLAUDE.md | 13 +- app/api/documents/token/route.ts | 52 +++- app/wordaddin/error.tsx | 37 +++ components/wordaddin/TaskpaneApp.tsx | 48 ++-- docs/PLAN-ADDIN-AUTH.md | 215 ++++++++++++++ docs/PLAN-DOCVER.md | 8 + docs/WORDADDIN-DEV.md | 30 +- docs/addin-ui-flow.md | 34 +-- lib/addin-ticket.ts | 124 +++++++++ lib/openapi.ts | 61 +++- scripts/make-ticket-docx.mjs | 263 ++++++++++++++++++ tests/lib/addin-ticket.test.ts | 148 ++++++++++ tests/utils/docver-ticket.test.ts | 118 ++++++++ .../wordaddin/ticket-auth-crash-repro.test.ts | 115 ++++++++ utils/docver-ticket.ts | 136 +++++++++ wordaddin/config.ts | 9 +- wordaddin/services/apiClient.ts | 32 ++- wordaddin/services/authService.ts | 192 ++++++++++--- wordaddin/services/metadataService.ts | 27 +- wordaddin/services/ticketService.ts | 176 ++++++++++++ 21 files changed, 1725 insertions(+), 124 deletions(-) create mode 100644 app/wordaddin/error.tsx create mode 100644 docs/PLAN-ADDIN-AUTH.md create mode 100644 lib/addin-ticket.ts create mode 100644 scripts/make-ticket-docx.mjs create mode 100644 tests/lib/addin-ticket.test.ts create mode 100644 tests/utils/docver-ticket.test.ts create mode 100644 tests/wordaddin/ticket-auth-crash-repro.test.ts create mode 100644 utils/docver-ticket.ts create mode 100644 wordaddin/services/ticketService.ts diff --git a/.env.example b/.env.example index d9988b6..39c60cb 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,14 @@ DOCVER_API_KEY= # ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret DOCVER_TOKEN_SECRET= # DOCVER_TOKEN_TTL_SECONDS=3600 + +# add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token +# ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp +# มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง +# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET +# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement) +# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้ +# ADDIN_TICKET_SECRET=super_secret +# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง +# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน) +# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret diff --git a/CLAUDE.md b/CLAUDE.md index 7ea752d..f7d1fe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ - Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit` + route ทั้งหมดของแอปใต้ `app/note/**` (URL ปลายทางยังเป็น `/docedit/note/…` เหมือนเดิม — basePath `/docedit` เป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin, `next.config.mjs`) - pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/note/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6) - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) -- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — **ไม่มี login ยกเลิก Keycloak 2026-10-27**: auth = e-Court pass-through (token/owner ใน custom properties ของไฟล์) ส่วน API ยังเป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) +- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — **ไม่มี login ยกเลิก Keycloak 2026-10-27**: auth = **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ใน custom properties — add-in ตรวจ HMAC เองฝั่ง client แล้วขอ token ที่ `/api/documents/token` แบบ body ticket เก็บใน localStorage `wordaddin-auth` ตาม fid, `docs/PLAN-ADDIN-AUTH.md`) ส่วน API ยังเป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) - Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) - mock login e-Court — หน้า `/note/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) @@ -39,6 +39,7 @@ docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ) scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile) scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน) +scripts/make-ticket-docx.mjs ← dev tool รันมือ (ไม่ใช่ postinstall): stamp ticket ขอ e-Court (`ticket_data` + `HmacSha256`) ลงไฟล์ .docx ใด ๆ ผ่าน pizzip เพื่อทดสอบ ticket auth ของ add-in ใน Word จริงโดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง (key เรียงตามตัวอักษรหลังเติม exp + เซ็นบน string ดิบ hex lowercase) · ตัวเลือก --fid/--uid/--uname/--tid/--exp /--secret/--tracked/--in-place/--list (PLAN-ADDIN-AUTH.md §9) db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — ไม่มี login: auth เป็น e-Court pass-through จาก token/owner ในไฟล์), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) @@ -51,12 +52,13 @@ lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — require lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) -lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) +lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) +lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicket (HMAC timing-safe บน ticket_data ดิบ + exp ผ่าน utils/docver-ticket.ts) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService = e-Court pass-through ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — surface เหลือ signInForDemo (dev-only) / signInFromDocument / getAccessToken / getUsername; ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลย ไม่มี = phase `no-access` — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ; apiClient โดน 401 = AuthRequiredError → no-access เลย token ต่ออายุไม่ได้) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`) +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → exp → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → ขอ token ที่ `/api/documents/token` แบบ body ticket ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -65,6 +67,7 @@ utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wr utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure) utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) +utils/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย ให้สองฝั่งฉีด crypto ของตัวเอง: lib/addin-ticket.ts = node:crypto / wordaddin/services/ticketService.ts = Web Crypto): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint) + isTicketExpired + normalizeHexDigest + timingSafeEqualHex + type HmacSha256Hex — unit test tests/utils/docver-ticket.test.ts utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) @@ -79,7 +82,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) -app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath +app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component) app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line) @@ -89,7 +92,7 @@ app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+ app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client) app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น) components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + ตั้ง session mock (`signInForDemo`) + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง; เส้นทางจริงไม่มี demo จะจบที่หน้า no-access เพราะอ่าน properties ไม่ได้) -components/wordaddin/TaskpaneApp.tsx ← state machine checking/no-access/untracked/tracking (ไม่มี login — ยกเลิก Keycloak 2026-10-27: detect อ่าน token/owner จากไฟล์ผ่าน signInFromDocument ไม่มี = no-access · AuthRequiredError จาก flow ก็ไป no-access) + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (เปลี่ยนแค่ JSX → Genesis + ถอดหน้า login/ปุ่มออกจากระบบ — header เหลือ chip username เฉย ๆ) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** +components/wordaddin/TaskpaneApp.tsx ← state machine checking/no-access/untracked/tracking (ไม่มี login — ยกเลิก Keycloak 2026-10-27: detect อ่าน ticket_data/HmacSha256 จากไฟล์ผ่าน signInWithTicket ไม่ผ่าน = no-access แยกข้อความตามสาเหตุ · AuthRequiredError จาก flow ก็ไป no-access) + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (เปลี่ยนแค่ JSX → Genesis + ถอดหน้า login/ปุ่มออกจากระบบ — header เหลือ chip username เฉย ๆ) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้ components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) diff --git a/app/api/documents/token/route.ts b/app/api/documents/token/route.ts index 07cd544..2173fe2 100644 --- a/app/api/documents/token/route.ts +++ b/app/api/documents/token/route.ts @@ -1,20 +1,43 @@ // PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver -// ยืนยันด้วย header "x-api-key" เทียบ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ -// ตั้ง/ว่าง) — token เป็น JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env +// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง): +// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY +// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง) +// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server +// ตรวจ HMAC+exp ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → +// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket +// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) +// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env // DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ // endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] // (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว import { docverError } from "@/lib/document-versioning"; +import { + addinTicketSecret, + parseTicketTokenBody, + ticketToDocverClaims, + verifyAddinTicket, +} from "@/lib/addin-ticket"; import { issueDocverToken, parseTokenRequestBody, requireDocverApiKey, + type DocverTokenClaims, } from "@/lib/docver-token"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */ +const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ"; + export async function POST(request: Request) { + return request.headers.get("x-api-key") !== null + ? postWithApiKey(request) + : postWithTicket(request); +} + +/** ทางเข้า 1 (เดิม) — e-Court server-to-server */ +async function postWithApiKey(request: Request): Promise { const apiKeyFail = requireDocverApiKey(request); if (apiKeyFail !== null) return apiKeyFail; @@ -26,7 +49,30 @@ export async function POST(request: Request) { } const parsed = parseTokenRequestBody(raw); if (!parsed.ok) return docverError(parsed.error, 400); + return issueToken(parsed.claims); +} +/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */ +async function postWithTicket(request: Request): Promise { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return docverError("body ต้องเป็น JSON", 400); + } + const parsed = parseTicketTokenBody(raw); + if (!parsed.ok) { + // missing = ไม่มี ticket มาเล่น ๆ (401 เดียวกับตอน verify ไม่ผ่าน) + // invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ + return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401); + } + const verified = verifyAddinTicket(parsed.ticketData, parsed.hmacHex, addinTicketSecret()); + if (!verified.ok) return docverError(TICKET_DENIED, 401); + return issueToken(ticketToDocverClaims(verified.ticket)); +} + +/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */ +async function issueToken(claims: DocverTokenClaims): Promise { // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); // const secret = @@ -39,7 +85,7 @@ export async function POST(request: Request) { const ttlSeconds = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600; - const accessToken = issueDocverToken(parsed.claims, secret, ttlSeconds); + const accessToken = issueDocverToken(claims, secret, ttlSeconds); return new Response( JSON.stringify({ access_token: accessToken, diff --git a/app/wordaddin/error.tsx b/app/wordaddin/error.tsx new file mode 100644 index 0000000..ac42382 --- /dev/null +++ b/app/wordaddin/error.tsx @@ -0,0 +1,37 @@ +"use client"; + +// [DBG-r3p1] error boundary ของ taskpane — แสดง error จริงแทนหน้า +// "Application error: a client-side exception has occurred" ของ Next +// (ไว้ใช้ debug ใน webview ของ Word ที่เปิด devtools ไม่ได้ — คงไว้ถาวร +// เพราะดีกว่าข้อความกลาง ๆ ของ Next เสมอ สำหรับ add-in ที่ผู้ใช้เปิด console ไม่ได้) +export default function WordAddinError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+

task pane ทำงานผิดพลาด

+

+ เกิดข้อผิดพลาดฝั่งหน้าเว็บ — แจ้งข้อความด้านล่าง (ถ่ายภาพหน้าจอนี้มาด้วย) แล้วกดลองใหม่ +

+
+        {error.message}
+        {error.digest ? `\n[digest ${error.digest}]` : ""}
+        {error.stack ? `\n\n${error.stack}` : ""}
+      
+ +
+ ); +} diff --git a/components/wordaddin/TaskpaneApp.tsx b/components/wordaddin/TaskpaneApp.tsx index 1b71ed2..35895b7 100644 --- a/components/wordaddin/TaskpaneApp.tsx +++ b/components/wordaddin/TaskpaneApp.tsx @@ -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 }) =>

เอกสารนี้ยังใช้งาน add-in ไม่ได้

- {noAccessNotice ?? "ไม่พบ token/owner ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"} + {noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}

) : null} diff --git a/docs/PLAN-ADDIN-AUTH.md b/docs/PLAN-ADDIN-AUTH.md new file mode 100644 index 0000000..12fda16 --- /dev/null +++ b/docs/PLAN-ADDIN-AUTH.md @@ -0,0 +1,215 @@ +# 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 = = 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 เลย) + +```ts +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 // 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 [options]`: + +- option: `--fid --uid --uname --tid --exp --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`** + ไม่งั้นเขียนไฟล์ใหม่ `.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 ให้) diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index fff1526..652097f 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -41,6 +41,14 @@ middleware เหมือน `api/template`) auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed · ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ +**ทางเข้าที่สอง (2026-10-27, add-in ใน Word — PLAN-ADDIN-AUTH.md):** ไม่มี header +`x-api-key` + body `{ticket_data, HmacSha256}` (ชื่อ field เหมือน custom properties +ในไฟล์) — server ตรวจ HMAC-SHA256(secret, ticket_data ดิบ) + exp ผ่าน +`lib/addin-ticket.ts` (env `ADDIN_TICKET_SECRET` → `SECRET` → default `"super_secret"`) +แล้วออก token จาก claims ใน ticket (`uid→userid`, `uname→username`, `fid`, `tid→template`) +· response/error รูปเดียวกับทางแรกทุกอย่าง (HMAC ผิด/หมดอายุ/ไม่ส่งมา = 401 ข้อความ +เดียวกัน · body ผิดรูป = 400) + JSON body (allowlist — key อื่น = 400 กัน typo): | field | type | หมายเหตุ | diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index 55a3826..bb97631 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -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 +# สร้าง .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/` diff --git a/docs/addin-ui-flow.md b/docs/addin-ui-flow.md index 6f5f7bc..d47ba2a 100644 --- a/docs/addin-ui-flow.md +++ b/docs/addin-ui-flow.md @@ -12,19 +12,19 @@ Task pane ทั้งหมดเป็น state machine เดียว **ไ ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอนเข้า `no-access` กัน surprise · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ -**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ e-Court pass-through: `detect()` อ่าน custom properties `token`/`owner` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInFromDocument()` ผ่านเข้าเลย +**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ **ticket ของ e-Court**: `detect()` อ่าน custom properties `ticket_data` + `HmacSha256` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInWithTicket()` — ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage ตาม uid/fid → ขอ access_token ที่ `/api/documents/token` เก็บตาม fid (flow เต็ม: `docs/requirement/addin-workflow.md` + แผน `docs/PLAN-ADDIN-AUTH.md`) ``` -checking ──(ไม่มี token/owner ในไฟล์)──→ no-access +checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access │ - ├─(มี token + ไม่ใช่ tracked doc)──→ untracked - └─(มี token + tracked doc)────────→ tracking + ├─(ticket ผ่าน + ไม่ใช่ tracked doc)──→ untracked + └─(ticket ผ่าน + tracked doc)────────→ tracking ``` การเปลี่ยน phase อื่นๆ: - `untracked` → (Start Versioning สำเร็จ) → `tracking` -- ทุก phase → (ไม่มี token ตอน detect ใหม่ / 401 = token หมดอายุไม่มีทางต่ออายุ) → `no-access` +- ทุก phase → (ไม่มี ticket ตอน detect ใหม่ / 401 หลังพยายาม renew จาก ticket แล้ว = ticket หมดอายุไม่มีทางต่ออายุ) → `no-access` - ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) State ที่ UI ถือนอกเหนือจาก phase: @@ -32,11 +32,11 @@ State ที่ UI ถือนอกเหนือจาก phase: | State | ความหมาย | |---|---| | `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 | -| `username` | ชื่อเจ้าของไฟล์จาก pass-through (owner → uid ใน token → fallback) — แสดงเป็น chip เฉย ๆ ใน header | +| `username` | ชื่อจาก `uname` ของ ticket — แสดงเป็น chip เฉย ๆ ใน header | | `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) | | `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) | | `versions` + `historyLoading` + `historyError` | version history ของ `documentId` | -| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี token/owner ในไฟล์ หรือ token หมดอายุจาก 401) | +| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / HMAC ไม่ตรง = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) | | `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version | | `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน | | `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) | @@ -54,7 +54,7 @@ State ที่ UI ถือนอกเหนือจาก phase: ### Header (ทุก phase) - ชื่อแอป: **e-Court Documents Versioning** (semibold) -- ฝั่งขวา (เมื่อผ่าน pass-through แล้วและไม่ใช่ `no-access`/`checking`): chip เล็กพื้น `canvas` แสดง `username` เฉย ๆ — ไม่ใช่ปุ่ม (ไม่มี login จึงไม่มีออกจากระบบ) · จอแคบตัดด้วย truncate ชื่อเต็มอยู่ใน `title` +- ฝั่งขวา (เมื่อผ่าน ticket auth แล้วและไม่ใช่ `no-access`/`checking`): chip เล็กพื้น `canvas` แสดง `username` เฉย ๆ — ไม่ใช่ปุ่ม (ไม่มี login จึงไม่มีออกจากระบบ) · จอแคบตัดด้วย truncate ชื่อเต็มอยู่ใน `title` ### `checking` @@ -65,8 +65,10 @@ State ที่ UI ถือนอกเหนือจาก phase: - ข้อความ: "เอกสารนี้ยังใช้งาน add-in ไม่ได้" - คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ): - - ตอน detect ไม่เจอข้อมูลในไฟล์: "ไม่พบ token/owner ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น" - - ตอนโดน 401 กลาง flow: "token ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" + - ไม่มี ticket ในไฟล์: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น" + - ticket รูปผิด / HMAC ไม่ตรง: "ตรวจสอบ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย" + - ticket หมดอายุ (ตอนเปิด หรือโดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" + - ขอ token ไม่สำเร็จ (server ลง/network): "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง" - **ไม่มีปุ่มใด ๆ** — ไม่มี login ให้กด (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court แล้วเปิดใหม่เท่านั้น ### `untracked` @@ -164,8 +166,8 @@ Dialog เดียวใช้ 2 flow (ปรับข้อความด้ ### Detect (FR-1) — ตอนเปิด task pane / กดลองใหม่ -1. อ่าน custom properties ครั้งเดียวทั้งชุด: `token`, `owner`, `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId` -2. e-Court pass-through: ไม่มี `token`/`owner` เลย (หรืออ่านไฟล์ไม่ได้) → `no-access` · มีอย่างน้อยหนึ่งตัว → `signInFromDocument` (username จาก owner → uid ใน token) แล้วไปต่อ +1. อ่าน custom properties ครั้งเดียวทั้งชุด: `ticket_data`, `HmacSha256`, `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId` +2. ticket auth (`signInWithTicket` — PLAN-ADDIN-AUTH.md §5): อ่านไฟล์ไม่ได้ / ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ (ตามเงื่อนไข uid/fid ของ session) / ขอ token ไม่สำเร็จ → `no-access` แยกข้อความตามสาเหตุ · ผ่าน = มี access_token ของ fid นี้เก็บใน localStorage `wordaddin-auth` แล้วไปต่อ 3. `tracked = (SavedByAddIn === GUID ของ add-in)`: - tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history - ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้) @@ -251,17 +253,17 @@ interface VersionRecord { response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น -### Auth — e-Court pass-through (ไม่มี login) +### Auth — e-Court ticket (ไม่มี login) -ยกเลิก Keycloak OIDC/PKCE แล้ว (2026-10-27) — ไม่มี Office Dialog ไม่มีหน้า sign-in: e-Court stamp custom properties `token`/`owner` ลงไฟล์ตอนสร้าง แล้ว `signInFromDocument()` ใช้ได้ทันที · token เก็บ **ใน memory เท่านั้น** (ห้าม localStorage) และ**ต่ออายุไม่ได้** — โดน 401 = `AuthRequiredError` → หน้า `no-access` ให้ขอไฟล์ใหม่จาก e-Court · token นี้คือสิ่งที่ `apiClient` แนบเป็น `Authorization: Bearer` ทุก request ตอนต่อ backend จริง (กลุ่ม docver บังคับ Bearer — docs/PLAN-DOCVER.md) · **รูปแบบ/วิธี verify ของ credential ที่ e-Court stamp ลงไฟล์ยังเป็นงานออกแบบต่อ** (ร่าง requirement: `docs/requirement/addin-workflow.md`) +ยกเลิก Keycloak OIDC/PKCE แล้ว (2026-10-27) — ไม่มี Office Dialog ไม่มีหน้า sign-in: e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ที่ config ได้ — ปัจจุบัน `super_secret`) ลงไฟล์ตอนสร้าง แล้ว `signInWithTicket()` ใช้ได้ทันที · flow ตาม `docs/requirement/addin-workflow.md` (แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): ตรวจ HMAC ฝั่ง client (UX gate) → ตรวจ exp → session ใน localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ session เก่าทั้งก้อน) → **ขอ access_token ที่ `POST /api/documents/token`** แบบ body `{ticket_data, HmacSha256}` (server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server) → เก็บ token ตาม fid ("คีย์คือ fid") — กลับมาเปิดเอกสารเดิม = ใช้ token เดิมไม่ต้องขอใหม่ · token หมดอายุกลางใช้งาน (โดน 401) = ลองขอใหม่จาก ticket 1 ครั้ง (ticket ต้องยังไม่หมดอายุ) ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` ให้ขอไฟล์ใหม่จาก e-Court · token นี้คือสิ่งที่ `apiClient` แนบเป็น `Authorization: Bearer` ทุก request ตอนต่อ backend จริง (กลุ่ม docver บังคับ Bearer — docs/PLAN-DOCVER.md) -> **หมายเหตุสถานะปัจจุบัน:** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — auth เป็น e-Court pass-through จริง (`wordaddin/services/authService.ts` — ไม่มี mock ให้ login แล้ว) ส่วน API ทั้งสามข้างบนยังเป็น in-memory mock (`wordaddin/services/versioningService.ts`) · วิธีสลับกลับของจริง: `docs/WORDADDIN-DEV.md` +> **หมายเหตุสถานะปัจจุบัน:** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — auth เป็น ticket auth จริงครบ flow (`wordaddin/services/authService.ts` + `ticketService.ts` + ทางเข้า ticket ของ `/api/documents/token`) ส่วน API ทั้งสามข้างบนยังเป็น in-memory mock (`wordaddin/services/versioningService.ts`) · วิธีสลับกลับของจริง: `docs/WORDADDIN-DEV.md` · สร้างไฟล์ทดสอบพร้อม ticket: `node scripts/make-ticket-docx.mjs` --- ## 8. กติกา error ที่ UI ต้องรองรับ -1. **`AuthRequiredError` (ไม่มี token / 401)** — ทุก flow ต้อง catch แล้ว: กลับ `no-access` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `noAccessNotice` = "token ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase** +1. **`AuthRequiredError` (ไม่มี token / 401 หลัง renew-retry)** — ทุก flow ต้อง catch แล้ว: กลับ `no-access` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `noAccessNotice` = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase** 2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection 3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว 4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow diff --git a/lib/addin-ticket.ts b/lib/addin-ticket.ts new file mode 100644 index 0000000..766db45 --- /dev/null +++ b/lib/addin-ticket.ts @@ -0,0 +1,124 @@ +// 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; + 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 }; +} diff --git a/lib/openapi.ts b/lib/openapi.ts index a407cdf..4dc3b66 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -39,8 +39,9 @@ export const openApiSpec = { description: "จัด version เอกสาร (docver): เอกสารเดียวต่อ docId แต่ละ " + "version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " + - "ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " + - "DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " + + "ของกลุ่มนี้: ขอที่ POST /api/documents/token ได้ 2 ทาง (x-api-key + body claims " + + "สำหรับระบบภายนอก หรือ body ticket_data+HmacSha256 สำหรับ add-in — PLAN-ADDIN-AUTH.md) " + + "แล้วแนบ Authorization: Bearer ทุก request — identity/template " + "และ docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด " + "ไม่รับจาก URL/multipart form อีก", }, @@ -613,18 +614,28 @@ export const openApiSpec = { summary: "ขอ access_token ของกลุ่ม documents (docver)", description: "ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " + - "JWT HS256 ฝัง claims {userid, username, fid, template} ตามที่ส่งมา อายุตาม env " + + "JWT HS256 ฝัง claims {userid, username, fid, template} อายุตาม env " + "DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName), " + "templateId และ docId (= fid) ตอนทำงานแกะจาก claims ของ token นี้ — " + - "fid จึงบังคับ", + "fid จึงบังคับ · ทางเข้า 2 ทาง (header x-api-key เป็นตัวตั้ง): " + + "(1) ระบบภายนอก server-to-server — header x-api-key (env DOCVER_API_KEY) + body " + + "claims ตรง ๆ (2) add-in ใน Word — ไม่มี x-api-key + body {ticket_data, " + + "HmacSha256} ตามที่ e-Court stamp มากับไฟล์ — server ตรวจ HMAC-SHA256 ด้วย env " + + "ADDIN_TICKET_SECRET (ไม่ตั้ง = env SECRET ไม่ตั้ง = \"super_secret\") และ exp " + + "ของ ticket ก่อนออก token จาก claims ในตัว ticket เอง", security: [{ ApiKeyAuth: [] }], requestBody: { required: true, content: { "application/json": { - schema: { $ref: "#/components/schemas/TokenRequest" }, + schema: { + oneOf: [ + { $ref: "#/components/schemas/TokenRequest" }, + { $ref: "#/components/schemas/TicketTokenRequest" }, + ], + }, examples: { - "ครบทุก claim": { + "server-to-server ด้วย x-api-key (ครบทุก claim)": { value: { userid: "123", username: "Sorawit Bholsithi", @@ -632,9 +643,17 @@ export const openApiSpec = { template: "T12", }, }, - "ขั้นต่ำ (template เป็นทางเลือก)": { + "server-to-server ขั้นต่ำ (template เป็นทางเลือก)": { value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" }, }, + "add-in ด้วย ticket จากไฟล์ (ไม่ต้องมี x-api-key)": { + value: { + ticket_data: + '{"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}', + HmacSha256: + "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9", + }, + }, }, }, }, @@ -650,7 +669,9 @@ export const openApiSpec = { }, 400: { $ref: "#/components/responses/BadRequest" }, 401: { - description: "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY)", + description: + "ทางเข้า x-api-key: ไม่ได้ส่ง header หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY) · " + + "ทางเข้า ticket: ticket ไม่ถูกต้อง/หมดอายุ หรือไม่ได้ส่งมา (ข้อความเดียวกันทุกสาเหตุ)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, @@ -880,7 +901,8 @@ export const openApiSpec = { }, TokenRequest: { type: "object", - description: "claims ที่จะฝังใน access_token ของกลุ่ม documents — key อื่น = 400", + description: + "claims ที่จะฝังใน access_token (ทางเข้า x-api-key) — key อื่น = 400", required: ["userid", "username", "fid"], properties: { userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" }, @@ -898,6 +920,27 @@ export const openApiSpec = { }, }, }, + TicketTokenRequest: { + type: "object", + description: + "ทางเข้าของ add-in (ไม่ต้องมี x-api-key) — ส่ง ticket ที่ e-Court stamp มากับ " + + "ไฟล์ .docx ใน custom properties ตามเป๊ะ · key อื่น = 400 · server ตรวจ " + + "HMAC-SHA256(ลับ, ticket_data ดิบ) เทียบ HmacSha256 + exp ของ ticket — " + + "ไม่ผ่าน = 401 ข้อความเดียวกันทุกสาเหตุ", + required: ["ticket_data", "HmacSha256"], + properties: { + ticket_data: { + type: "string", + description: + 'JSON string ของ ticket เช่น {"exp":1793067433,"fid":"…","tid":"…","uid":"…","uname":"…"} ' + + "(exp = unix วินาที · tid optional · ส่งตรง ๆ ไม่แก้ string — HMAC ผูกกับค่าดิบ)", + }, + HmacSha256: { + type: "string", + description: "hex 64 ตัว ของ HMAC-SHA256(secret, ticket_data ดิบ)", + }, + }, + }, TokenResponse: { type: "object", description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)", diff --git a/scripts/make-ticket-docx.mjs b/scripts/make-ticket-docx.mjs new file mode 100644 index 0000000..e22f6d0 --- /dev/null +++ b/scripts/make-ticket-docx.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +// PLAN-ADDIN-AUTH.md §9 — dev tool stamp ticket ของ e-Court (custom properties +// `ticket_data` + `HmacSha256`) ลงไฟล์ .docx เพื่อทดสอบ auth ของ add-in ใน Word จริง +// โดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง +// (app/api/template/render/route.ts: key เรียงตามตัวอักษรหลังเติม exp → +// HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase) +// +// ใช้: node scripts/make-ticket-docx.mjs [options] +// --fid default DEMO_FID_001 +// --uid default oom +// --uname default "สรวิชญ์" +// --tid template id (optional — ไม่ส่ง = ไม่มี tid ใน ticket) +// --exp default +300 (5 นาที เท่า TICKET_SEC ของตัว stamp) +// --secret default "super_secret" (ต้องตรง env ฝั่ง server/add-in) +// --tracked [id] stamp SavedByAddIn + DocumentTrackingId ด้วย (ทดสอบ phase tracking) +// --in-place เขียนทับไฟล์เดิม (ปกติเขียน <ชื่อเดิม>.ticket.docx) +// --list แค่อ่าน custom properties ที่มีในไฟล์แล้วออกมาดู +// +// รันใน repo เท่านั้น (import pizzip จาก node_modules) — ไม่ใช่ postinstall + +import { createHmac, randomUUID } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import PizZip from "pizzip"; + +const CUSTOM_PATH = "docProps/custom.xml"; +const CT_PATH = "[Content_Types].xml"; +const RELS_PATH = "_rels/.rels"; +const CUSTOM_FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; +/** ต้องตรง metadataService.ADDIN_SIGNATURE_GUID ฝั่ง add-in */ +const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D"; + +function usage(code = 1) { + console.log(`ใช้: node scripts/make-ticket-docx.mjs [options] + --fid --uid --uname --tid + --exp --secret + --tracked [trackingId] --in-place --list`); + process.exit(code); +} + +function parseArgs(argv) { + const options = { + file: null, + fid: "DEMO_FID_001", + uid: "oom", + uname: "สรวิชญ์", + tid: null, + exp: "+300", + secret: "super_secret", + tracked: false, + trackingId: null, + inPlace: false, + list: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = () => { + const value = argv[++i]; + if (value === undefined) usage(); + return value; + }; + switch (arg) { + case "--fid": options.fid = next(); break; + case "--uid": options.uid = next(); break; + case "--uname": options.uname = next(); break; + case "--tid": options.tid = next(); break; + case "--exp": options.exp = next(); break; + case "--secret": options.secret = next(); break; + case "--tracked": + options.tracked = true; + if (argv[i + 1] && !argv[i + 1].startsWith("--")) options.trackingId = next(); + break; + case "--in-place": options.inPlace = true; break; + case "--list": options.list = true; break; + case "--help": case "-h": usage(0); break; + default: + if (arg.startsWith("--")) usage(); + if (options.file !== null) usage(); + options.file = arg; + } + } + if (options.file === null) usage(); + return options; +} + +function resolveExp(raw) { + if (raw.startsWith("+")) { + const seconds = Number(raw.slice(1)); + if (!Number.isFinite(seconds) || seconds <= 0) throw new Error("--exp +<วินาที> ไม่ถูกต้อง"); + return Math.floor(Date.now() / 1000) + Math.floor(seconds); + } + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error("--exp ต้องเป็น unix วินาที หรือ +<วินาที>"); + return value; +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** ticket_data ตามรูปตัว stamp จริง — key เรียงตามตัวอักษรหลังเติม exp */ +function buildTicketData(options) { + const ticket = { exp: resolveExp(options.exp), fid: options.fid, uid: options.uid, uname: options.uname }; + if (options.tid !== null) ticket.tid = options.tid; + const sorted = Object.keys(ticket) + .sort() + .reduce((result, key) => { + result[key] = ticket[key]; + return result; + }, {}); + return JSON.stringify(sorted); +} + +function unescapeXml(value) { + return String(value) + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +/** ถอด ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว */ +function readProperties(xml) { + const properties = []; + const regex = /]*name="([^"]*)"[^>]*>([\s\S]*?)<\/Property>|]*name="([^"]*)"[^>]*\/>/g; + let match; + while ((match = regex.exec(xml)) !== null) { + const name = match[1] ?? match[3]; + const inner = match[2] ?? ""; + const value = inner.replace(/<[^>]+>/g, ""); + properties.push({ name: unescapeXml(name), value: unescapeXml(value.trim()) }); + } + return properties; +} + +function usedPids(xml) { + const pids = new Set([1]); + const regex = /pid="(\d+)"/g; + let match; + while ((match = regex.exec(xml)) !== null) pids.add(Number(match[1])); + return pids; +} + +function propertyXml(name, pid, value) { + return ` ${escapeXml(value)}\n`; +} + +/** upsert property ลง custom.xml (สร้างไฟล์ใหม่ถ้ายังไม่มี) — คืน XML ที่พร้อมเขียนกลับ zip */ +function upsertProperties(existingXml, entries) { + // เอา Property เก่าของชื่อที่จะเขียนทิ้งก่อน (replace แบบตัวเอง) + let xml = existingXml; + for (const entry of entries) { + const removal = new RegExp( + `\\s*]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)`, + "g", + ); + xml = xml.replace(removal, ""); + } + const pids = usedPids(xml); + let nextPid = 2; + const takePid = () => { + while (pids.has(nextPid)) nextPid++; + pids.add(nextPid); + return nextPid++; + }; + const block = entries.map((entry) => propertyXml(entry.name, takePid(), entry.value)).join(""); + const hasRoot = xml.includes(""); + if (hasRoot) { + xml = xml.replace(/<\/Properties>/, `${block}`); + } else { + xml = + `\n` + + `\n${block}`; + } + return xml; +} + +/** ผูก docProps/custom.xml เข้ากับแพ็กเกจ — Word ไม่เห็น part ที่ไม่มี relationship/content-type */ +function ensurePartRegistered(zip) { + const ct = zip.file(CT_PATH); + if (ct && !ct.asText().includes('PartName="/docProps/custom.xml"')) { + const updated = ct + .asText() + .replace( + /<\/Types>/, + ` \n`, + ); + zip.file(CT_PATH, updated); + } + const rels = zip.file(RELS_PATH); + if (rels && !rels.asText().includes('Target="docProps/custom.xml"')) { + const match = rels.asText().match(/Id="rId(\d+)"/g); + const maxId = match ? Math.max(...match.map((item) => Number(item.match(/\d+/)[0]))) : 0; + const updated = rels + .asText() + .replace( + /<\/Relationships>/, + ` \n`, + ); + zip.file(RELS_PATH, updated); + } +} + +// --------------------------------------------------------------------------- + +const options = parseArgs(process.argv.slice(2)); +const source = readFileSync(options.file); + +if (source.subarray(0, 2).toString("latin1") !== "PK") { + console.error(`✗ ${options.file} ไม่ใช่ไฟล์ .docx (zip)`); + process.exit(1); +} + +const zip = new PizZip(source); + +if (options.list) { + const custom = zip.file(CUSTOM_PATH); + if (!custom) { + console.log(`(ไม่มี ${CUSTOM_PATH} ในไฟล์นี้)`); + } else { + for (const property of readProperties(custom.asText())) { + console.log(`${property.name} = ${property.value}`); + } + } + process.exit(0); +} + +const ticketData = buildTicketData(options); +const hmac = createHmac("sha256", options.secret).update(ticketData, "utf8").digest("hex"); +if (ticketData.length > 255) { + console.warn(`⚠ ticket_data ยาว ${ticketData.length} ตัวอักษร — เกินขีดจำกัด ~255 ของ Word custom property อาจถูกตัด`); +} + +const entries = [ + { name: "ticket_data", value: ticketData }, + { name: "HmacSha256", value: hmac }, +]; +if (options.tracked) { + entries.push({ name: "SavedByAddIn", value: ADDIN_SIGNATURE_GUID }); + entries.push({ name: "DocumentTrackingId", value: options.trackingId ?? randomUUID() }); +} + +const existing = zip.file(CUSTOM_PATH); +zip.file(CUSTOM_PATH, upsertProperties(existing ? existing.asText() : "", entries)); +ensurePartRegistered(zip); + +const output = zip.generate({ type: "nodebuffer", compression: "DEFLATE" }); +const target = options.inPlace + ? options.file + : `${options.file.replace(/\.docx$/i, "")}.ticket.docx`; +writeFileSync(target, output); + +console.log(`✓ stamp ticket ลง ${path.relative(process.cwd(), target)}${options.inPlace ? " (ทับไฟล์เดิม)" : ""}`); +console.log(` ticket_data : ${ticketData}`); +console.log(` HmacSha256 : ${hmac}`); +console.log(` secret : ${options.secret}`); +console.log(` exp : ${new Date(resolveExp(options.exp) * 1000).toLocaleString("th-TH")}`); diff --git a/tests/lib/addin-ticket.test.ts b/tests/lib/addin-ticket.test.ts new file mode 100644 index 0000000..5c75931 --- /dev/null +++ b/tests/lib/addin-ticket.test.ts @@ -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(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; + }); +}); diff --git a/tests/utils/docver-ticket.test.ts b/tests/utils/docver-ticket.test.ts new file mode 100644 index 0000000..bdde305 --- /dev/null +++ b/tests/utils/docver-ticket.test.ts @@ -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(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); + }); +}); diff --git a/tests/wordaddin/ticket-auth-crash-repro.test.ts b/tests/wordaddin/ticket-auth-crash-repro.test.ts new file mode 100644 index 0000000..abecb22 --- /dev/null +++ b/tests/wordaddin/ticket-auth-crash-repro.test.ts @@ -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([]); + }); +}); diff --git a/utils/docver-ticket.ts b/utils/docver-ticket.ts new file mode 100644 index 0000000..14130c6 --- /dev/null +++ b/utils/docver-ticket.ts @@ -0,0 +1,136 @@ +// PLAN-ADDIN-AUTH.md §4 — ticket ของ add-in (custom properties `ticket_data` + +// `HmacSha256` ที่ e-Court ผ่านกลุ่ม template stamp ลงไฟล์) — ส่วน pure ที่ +// ใช้ร่วมกันทั้งฝั่ง server (lib/addin-ticket.ts — route /api/documents/token) +// และฝั่ง client ของ add-in (wordaddin/services/ticketService.ts) +// +// กติกา (docs/requirement/addin-workflow.md + lib/lib-utils.ts generateHmacSha256 +// ของตัว stamp ฝั่ง template): +// - ticket_data เป็น JSON string ตามที่ stamp ไว้เป๊ะ (key เรียงตามตัวอักษร +// หลังเติม exp) — **HMAC ต้องคำนวณบน string ดิบนี้ ห้าม trim/normalize** +// - HmacSha256 เป็น hex lowercase 64 ตัว (การเทียบ normalize เป็น lowercase +// ให้ก่อน — ฝั่ง stamp ตัวพิมพ์ใหญ่ก็ผ่านได้) +// - ห้าม import อะไรที่เป็น platform-specific (node:crypto/crypto.subtle) — +// module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word โดยส่ง +// ตัวคำนวณ HMAC เข้ามาทาง parameter แทน + +/** ticket ที่แกะจาก ticket_data — ชื่อ field ตามที่ e-Court กำหนด (requirement) */ +export interface DocverTicket { + /** หมดอายุเมื่อไร — unix epoch วินาที */ + exp: number; + /** fileId ของ e-Court — เป็น docId ของทุก operation ของกลุ่ม docver ตรง ๆ */ + fid: string; + /** template ต้นทาง — optional (ไม่มี = null) */ + tid: string | null; + /** รหัสผู้ใช้ → claim `userid` */ + uid: string; + /** ชื่อผู้ใช้ → claim `username` */ + uname: string; +} + +/** ความยาว cap ต่อ field — ตาม parseTokenRequestBody ของ /api/documents/token */ +const TICKET_LIMITS = { + uid: 100, + uname: 200, + fid: 200, + tid: 100, +} as const; + +/** allowlist ของ ticket_data — field อื่นปนมา = reject (กัน typo เงียบ ๆ ตาม convention ของ repo) */ +const TICKET_FIELDS = ["exp", "fid", "tid", "uid", "uname"] as const; + +export type DocverTicketParseResult = + | { ok: true; ticket: DocverTicket } + | { ok: false; error: string }; + +/** + * แกะ + ตรวจ ticket จาก string ดิบของ property `ticket_data` + * คืนค่าที่ trim แล้ว (uid/uname/fid/tid) — exp เป็น number ตรง ๆ + */ +export function parseDocverTicket(raw: string): DocverTicketParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, error: "ticket_data ไม่ใช่ JSON ที่ถูกต้อง" }; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, error: "ticket_data ต้องเป็น JSON object" }; + } + const record = parsed as Record; + for (const key of Object.keys(record)) { + if (!(TICKET_FIELDS as readonly string[]).includes(key)) { + return { + ok: false, + error: `ticket_data มี field ที่ไม่รู้จัก '${key}' (รับเฉพาะ ${TICKET_FIELDS.join(", ")})`, + }; + } + } + + const exp = record.exp; + if (typeof exp !== "number" || !Number.isSafeInteger(exp)) { + return { ok: false, error: "ticket_data ต้องมี exp เป็นจำนวนเต็ม (unix วินาที)" }; + } + + const field = (name: "uid" | "uname" | "fid"): string | null => { + const value = record[name]; + if (typeof value !== "string" || value.trim() === "") { + return null; + } + const trimmed = value.trim(); + return trimmed.length <= TICKET_LIMITS[name] ? trimmed : null; + }; + + const uid = field("uid"); + if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" }; + const uname = field("uname"); + if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" }; + const fid = field("fid"); + if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" }; + + let tid: string | null = null; + if (record.tid !== undefined) { + if (typeof record.tid !== "string") { + return { ok: false, error: "ticket_data ของ tid ต้องเป็น string" }; + } + const trimmed = record.tid.trim(); + if (trimmed !== "") { + if (trimmed.length > TICKET_LIMITS.tid) { + return { ok: false, error: "ticket_data ของ tid ยาวเกิน 100 ตัวอักษร" }; + } + tid = trimmed; + } + } + + return { ok: true, ticket: { exp, fid, tid, uid, uname } }; +} + +/** exp หมดอายุไหม — exp เท่ากับ now พอดีก็ถือว่าหมดแล้ว */ +export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean { + return exp <= nowSec; +} + +/** + * normalize ค่า digest ที่ได้รับ (property `HmacSha256` / body field) — + * trim + lowercase แล้วตรวจว่าเป็น hex ครบ 64 ตัว คืน null เมื่อรูปไม่ใช่ + */ +export function normalizeHexDigest(value: string): string | null { + const normalized = value.trim().toLowerCase(); + return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null; +} + +/** + * เทียบ hex digest สองค่าแบบ constant-time (best-effort ของ pure JS — + * ฝั่ง server จริงใช้ node:crypto timingSafeEqual ผ่าน lib/addin-ticket.ts + * ตัวนี้ใช้กับ UX gate ฝั่ง client เป็นหลัก) — ค่าที่ส่งเข้าควร normalize แล้ว + */ +export function timingSafeEqualHex(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +/** ตัวคำนวณ HMAC-SHA256 คืนค่าเป็น hex lowercase — สองฝั่งฉีดของตัวเอง (Web Crypto / node:crypto) */ +export type HmacSha256Hex = (message: string, secret: string) => Promise; diff --git a/wordaddin/config.ts b/wordaddin/config.ts index e142b70..646079e 100644 --- a/wordaddin/config.ts +++ b/wordaddin/config.ts @@ -2,12 +2,19 @@ * Config ของ wordaddin — ค่าจาก NEXT_PUBLIC_* env (Next inline ตอน build) * (ต้นฉบับ D:\word-addin-poc\addin inject ผ่าน webpack DefinePlugin — พอร์ตมาเป็น env ของ Next) * - * ช่วง mock: authService/versioningService เป็น mock ไม่ใช้ apiBaseUrl — + * ช่วง mock: versioningService เป็น mock ไม่ใช้ apiBaseUrl — * ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http) + * ส่วนการขอ token (ticket auth) ยิง same-origin /api/documents/token เสมอ ไม่ผูกกับ apiBaseUrl */ export const config = { /** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */ apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "", /** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */ mockUsername: process.env.NEXT_PUBLIC_ADDIN_MOCK_USERNAME ?? "tester01", + /** + * secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate — ตัดเคสไฟล์ถูกแก้ก่อนยิง network; + * ความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น เพราะค่าใน bundle สกัดได้) — ต้องตรงกับ + * ตัว stamp ของ e-Court และ env ADDIN_TICKET_SECRET/SECRET ฝั่ง server (PLAN-ADDIN-AUTH.md §7) + */ + ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret", }; diff --git a/wordaddin/services/apiClient.ts b/wordaddin/services/apiClient.ts index 73cf510..5db4122 100644 --- a/wordaddin/services/apiClient.ts +++ b/wordaddin/services/apiClient.ts @@ -2,17 +2,18 @@ import { config } from "../config"; import * as authService from "./authService"; /** - * fetch wrapper ของ add-in — แนบ Bearer ทุก request (token จาก custom properties ของไฟล์) - * โดน 401 → AuthRequiredError ทันที: token จากไฟล์ต่ออายุไม่ได้ (ไม่มี refresh token - * ตั้งแต่ยกเลิก Keycloak) — UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court + * fetch wrapper ของ add-in — แนบ Bearer ทุก request (access_token ที่ authService + * ขอจาก /api/documents/token ด้วย ticket ของไฟล์ — PLAN-ADDIN-AUTH.md) + * โดน 401 → ลองขอ token ใหม่จาก ticket แล้ว retry 1 ครั้ง ยังไม่ผ่าน → AuthRequiredError: + * UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court * พอร์ตจาก D:\word-addin-poc\addin\src\services\apiClient.ts * (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError * ถูก TaskpaneApp ใช้อยู่แล้ว) */ -/** บอกว่า token จากไฟล์ใช้ไม่ได้ (ไม่มี / หมดอายุ / ไม่ถูกต้อง) — ต้องได้ไฟล์ใหม่จาก e-Court */ +/** บอกว่า ticket ใช้ไม่ได้แล้ว (หมดอายุ / token ขอไม่ได้) — ต้องได้ไฟล์ใหม่จาก e-Court */ export class AuthRequiredError extends Error { - constructor(message = "token ของ e-Court หมดอายุหรือไม่ถูกต้อง") { + constructor(message = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง") { super(message); this.name = "AuthRequiredError"; } @@ -54,8 +55,10 @@ async function doFetch( } /** - * request พร้อม Bearer — 401 = token จากไฟล์หมดอายุ/ไม่ถูกต้อง → AuthRequiredError - * Response ที่ได้คืนไปให้ caller เช็ค ok เอง + * request พร้อม Bearer — โดน 401 → ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน + * (renewTokenFromTicket — ticket ยังไม่หมดอายุเท่านั้น) แล้ว retry 1 ครั้ง + * ยัง 401 อีก / renew ไม่ได้ = AuthRequiredError (UI พาไป no-access ให้ขอไฟล์ + * ใหม่จาก e-Court) · Response ที่ได้คืนไปให้ caller เช็ค ok เอง */ export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise { const accessToken = await authService.getAccessToken(); @@ -63,10 +66,19 @@ export async function apiFetch(path: string, options: ApiRequestOptions = {}): P throw new AuthRequiredError(); } const response = await doFetch(path, options, accessToken); - if (response.status === 401) { - throw new AuthRequiredError(); + if (response.status !== 401) { + return response; } - return response; + if (await authService.renewTokenFromTicket()) { + const retryToken = await authService.getAccessToken(); + if (retryToken) { + const retry = await doFetch(path, options, retryToken); + if (retry.status !== 401) { + return retry; + } + } + } + throw new AuthRequiredError(); } /** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */ diff --git a/wordaddin/services/authService.ts b/wordaddin/services/authService.ts index b625ff3..fe92661 100644 --- a/wordaddin/services/authService.ts +++ b/wordaddin/services/authService.ts @@ -1,60 +1,176 @@ /** - * Auth ของ add-in — e-Court pass-through ทางเดียว (ยกเลิก Keycloak 2026-10-27 ตามคำขอผู้ใช้) - * ไม่มีขั้นตอน login ใน add-in อีกต่อไป: ไฟล์ที่ e-Court stamp custom properties - * `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = ใช้งานได้ทันที ไม่ต้องกดปุ่มใด ๆ - * ไฟล์ที่ไม่มีทั้งคู่ = phase no-access ของ TaskpaneApp (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) + * Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม + * docs/requirement/addin-workflow.md) * - * token เก็บใน memory เท่านั้น — getAccessToken คืน docToken ให้ apiClient แนบ Bearer - * (ช่วงไม่ต่อ backend จริง versioningService เป็น mock ไม่เรียก apiClient อยู่แล้ว) + * ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data` + * + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ + * HMAC → exp → session ใน localStorage ตาม uid/fid แล้วขอ access_token จาก + * /api/documents/token เก็บตาม fid ("คีย์คือ fid") + * + * token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่ + * ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew + * ตอน token หมดอายุกลาง session */ import { config } from "../config"; +import { isTicketExpired, parseDocverTicket } from "@/utils/docver-ticket"; +import * as ticketService from "./ticketService"; -/** state เดียว — null = ยังไม่เคย pass-through สำเร็จ (จากไฟล์ หรือ ?addindemo) */ +/** สาเหตุที่เข้าไม่ได้ — UI แยกข้อความตาม reason (TaskpaneApp) */ +export type SignInFailureReason = "missing" | "tampered" | "expired" | "token-request-failed"; + +export type SignInResult = { ok: true } | { ok: false; reason: SignInFailureReason; message: string }; + +/** state ของ taskpane instance นี้ (ต่อเอกสารที่เปิด) — null = ยังไม่ได้ sign in */ let username: string | null = null; -/** token จาก custom properties ของไฟล์ (e-Court pass-through) — เก็บใน memory เท่านั้น */ -let docToken: string | null = null; +/** fid ของเอกสารปัจจุบัน — token ที่ใช้ผูกกับ fid นี้เสมอ */ +let currentFid: string | null = null; +/** ticket ดิบของเอกสารปัจจุบัน — ไว้ขอ token ใหม่ตอน 401/token หมดอายุ */ +let currentTicket: { data: string; hmac: string } | null = null; /** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */ export function signInForDemo(): void { username = config.mockUsername; -} - -/** ถอด field จาก JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ดู uid ประกอบชื่อผู้ใช้เท่านั้น */ -function decodeJwtField(token: string, field: string): string | undefined { - const payload = token.split(".")[1]; - if (!payload) return undefined; - try { - // atob คืน binary string (1 char = 1 byte) — ต้องถอด UTF-8 ด้วย TextDecoder ก่อน JSON.parse - // ไม่งั้น uid ที่ไม่ใช่ ASCII (เช่น ชื่อไทย) จะกลายเป็น mojibake - const bytes = Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0)); - const json: unknown = JSON.parse(new TextDecoder().decode(bytes)); - const value = typeof json === "object" && json !== null ? (json as Record)[field] : undefined; - return typeof value === "string" && value !== "" ? value : undefined; - } catch { - return undefined; - } + currentFid = null; + currentTicket = null; } /** - * e-Court pass-through — จุดเข้าเดียวของ auth: ไฟล์ที่ stamp `token`/`owner` ไว้ - * username ได้จาก owner → uid ใน JWT → mockUsername (ตามลำดับ) - * คืน true = ผ่านเข้าได้ (มี token หรือ owner อย่างน้อยหนึ่งตัว) + * e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7): + * 1. ตรวจมี ticket_data + HmacSha256 ครบ + * 2. parse + ตรวจ HMAC ฝั่ง client (UX gate) + * 3. session ใน localStorage: ยังไม่มี/user ใหม่ → ตรวจ exp → เคลียร์ session เก่า + * แล้วขอ token ใหม่ · user เดิม + fid เดิม → ผ่านเลย (renew เงียบ ๆ ถ้า token + * หมดอายุแต่ ticket ยังไม่หมด) · user เดิม + fid ใหม่ → ตรวจ exp → ขอ token เก็บเพิ่ม */ -export function signInFromDocument(input: { token?: string; owner?: string }): boolean { - const token = input.token?.trim() || undefined; - const owner = input.owner?.trim() || (token ? decodeJwtField(token, "uid") : undefined); - if (!token && !owner) { - return false; +export async function signInWithTicket(input: { + ticketData?: string; + ticketHmac?: string; +}): Promise { + const ticketData = input.ticketData ?? ""; + const ticketHmac = input.ticketHmac ?? ""; + // Word custom property จำกัด value ~255 ตัวอักษร — เกิน = ไฟล์เสียหาย (ถูกตัด) + if (ticketData === "" || ticketHmac === "" || ticketData.length > 255 || ticketHmac.length > 255) { + return { + ok: false, + reason: "missing", + message: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น", + }; } - username = owner ?? config.mockUsername; - docToken = token ?? null; - return true; + + const parsed = parseDocverTicket(ticketData); + if (!parsed.ok) { + return { + ok: false, + reason: "tampered", + message: "ตรวจสอบ ticket ไม่ผ่าน (รูปแบบไม่ถูกต้อง) — ไฟล์ถูกแก้ไขหรือเสียหาย", + }; + } + if (!(await ticketService.verifyTicketHmacWeb(ticketData, ticketHmac, config.ticketSecret))) { + return { + ok: false, + reason: "tampered", + message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไข", + }; + } + const ticket = parsed.ticket; + const ticketAlive = !isTicketExpired(ticket.exp); + + const session = ticketService.loadSession(); + if (session === null || session.userId !== ticket.uid) { + // ไม่มี session / user ใหม่ — ตรวจ exp ก่อนแล้วเริ่ม session ใหม่ทั้งก้อน + if (!ticketAlive) { + return { + ok: false, + reason: "expired", + message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", + }; + } + ticketService.clearSession(); + let entry: ticketService.SessionTokenEntry; + try { + entry = await ticketService.requestDocverToken(ticketData, ticketHmac); + } catch { + return { + ok: false, + reason: "token-request-failed", + message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", + }; + } + ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: entry } }); + } else { + // คนเดิม — userName อัปเดตตามไฟล์ล่าสุด (ชื่อเปลี่ยนได้) + session.userName = ticket.uname; + let entry = session.tokens[ticket.fid] ?? null; + if (entry !== null && ticketService.isTokenExpired(entry)) { + // token หมดอายุ — renew ได้เฉพาะตอน ticket ยังไม่หมด; ticket หมดด้วยก็เก็บของ + // เก่าไว้ (requirement ขั้น 7: ผ่านเลยไม่ตรวจ exp) ให้ server ตัดสินตอนใช้งาน + if (ticketAlive) { + try { + entry = await ticketService.requestDocverToken(ticketData, ticketHmac); + session.tokens[ticket.fid] = entry; + } catch { + // ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access + } + } + } + if (entry === null) { + // เอกสารใหม่ของคนเดิม — ตรวจ exp แล้วขอ token เก็บเพิ่ม + if (!ticketAlive) { + return { + ok: false, + reason: "expired", + message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", + }; + } + try { + session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac); + } catch { + return { + ok: false, + reason: "token-request-failed", + message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", + }; + } + } + ticketService.saveSession(session); + } + + currentFid = ticket.fid; + currentTicket = { data: ticketData, hmac: ticketHmac }; + username = ticket.uname; + return { ok: true }; } -/** Bearer ของ apiClient — docToken จากไฟล์ (ส่วน demo/mock คืน token ปลอม) */ +/** + * ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน (ticket ต้องยังไม่หมดอายุ) — + * เรียกจาก apiClient ตอนโดน 401 ก่อน throw AuthRequiredError 1 รอบ + * คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่) + */ +export async function renewTokenFromTicket(): Promise { + if (currentTicket === null || currentFid === null) return false; + const parsed = parseDocverTicket(currentTicket.data); + if (!parsed.ok || isTicketExpired(parsed.ticket.exp)) return false; + try { + const entry = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac); + const session = + ticketService.loadSession() ?? + { userId: parsed.ticket.uid, userName: parsed.ticket.uname, tokens: {} }; + session.userName = parsed.ticket.uname; + session.tokens[parsed.ticket.fid] = entry; + ticketService.saveSession(session); + return true; + } catch { + return false; + } +} + +/** Bearer ของ apiClient — token ของ fid ปัจจุบัน (stale ได้ — apiClient เป็นคนตัดสิน 401) */ export async function getAccessToken(): Promise { - if (docToken) return docToken; + if (currentFid !== null) { + return ticketService.loadSession()?.tokens[currentFid]?.token ?? null; + } + // demo mode (?addindemo) — ไม่มี ticket ใช้ token ปลอมเหมือนเดิม return username ? `mock-token-${username}` : null; } diff --git a/wordaddin/services/metadataService.ts b/wordaddin/services/metadataService.ts index 74c33d3..68a8693 100644 --- a/wordaddin/services/metadataService.ts +++ b/wordaddin/services/metadataService.ts @@ -23,9 +23,10 @@ export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp"; /** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */ export const TEMPLATE_ID_PROPERTY = "TemplateId"; -/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลย (auth ทางเข้าเดียว) */ -export const ECOURT_TOKEN_PROPERTY = "token"; -export const ECOURT_OWNER_PROPERTY = "owner"; +/** e-Court ticket — ไฟล์ที่ e-Court stamp คู่นี้มาใน custom properties คือ auth ทางเข้าเดียวของ add-in + * (PLAN-ADDIN-AUTH.md §2: HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase 64 ตัว) */ +export const TICKET_DATA_PROPERTY = "ticket_data"; +export const TICKET_HMAC_PROPERTY = "HmacSha256"; /** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */ export interface TrackedDocumentProperties { @@ -35,9 +36,9 @@ export interface TrackedDocumentProperties { lastVersionTimestamp?: string; /** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */ templateId?: string; - /** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ใช้งานได้ทันที (username จาก owner หรือ uid ใน token) */ - ecourtToken?: string; - ecourtOwner?: string; + /** e-Court ticket — มีครบทั้งคู่ = ตรวจ HMAC แล้วเข้าใช้งานได้ (auth ทางเข้าเดียว) */ + ticketData?: string; + ticketHmac?: string; } /** item ใน Custom XML Part `` */ @@ -49,7 +50,7 @@ export interface XmlVersionEntry { } /** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16 - * + token/owner ที่ e-Court stamp มากับไฟล์) */ + * + ticket_data/HmacSha256 ที่ e-Court stamp มากับไฟล์) */ export async function readTrackedProperties(): Promise { return Word.run(async (context) => { const properties = context.document.properties.customProperties; @@ -58,16 +59,16 @@ export async function readTrackedProperties(): Promise @@ -81,8 +82,8 @@ export async function readTrackedProperties(): Promise; +} + +/** memory fallback — ใช้เมื่อ localStorage โดน block (webview บางตัว/private mode) */ +let memorySession: AddinSession | null = null; + +function parseSession(raw: string | null): AddinSession | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as AddinSession; + if ( + typeof parsed.userId !== "string" || + typeof parsed.userName !== "string" || + parsed.userId === "" || + typeof parsed.tokens !== "object" || + parsed.tokens === null || + Array.isArray(parsed.tokens) + ) { + return null; + } + for (const entry of Object.values(parsed.tokens)) { + if (typeof entry.token !== "string" || typeof entry.exp !== "number") return null; + } + return parsed; + } catch { + return null; + } +} + +export function loadSession(): AddinSession | null { + if (typeof window === "undefined") return memorySession; // SSR ของ client component + try { + return parseSession(window.localStorage.getItem(SESSION_STORAGE_KEY)) ?? memorySession; + } catch { + return memorySession; + } +} + +/** เขียน session — localStorage พัง = เก็บ memory ต่อ (ไม่ throw) */ +export function saveSession(session: AddinSession): void { + memorySession = session; + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); + } catch { + // ignore — quota/private mode: มี memory ของ session นี้อยู่ + } +} + +/** ล้างทั้งก้อน — ใช้ตอนเจอ user ใหม่ (requirement ขั้น 6: เคลียร์ Session เดิม) */ +export function clearSession(): void { + memorySession = null; + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(SESSION_STORAGE_KEY); + } catch { + // ignore + } +} + +/** token หมดอายุหรือยัง (มี margin 30 วิ กันขอบเขตตกหล่น) */ +export function isTokenExpired(entry: SessionTokenEntry): boolean { + return entry.exp * 1000 <= Date.now() - 30_000; +} + +// --------------------------------------------------------------------------- +// HMAC ฝั่ง client — Web Crypto (crypto.subtle ให้ promise เสมอ) +// --------------------------------------------------------------------------- + +/** คำนวณ HMAC-SHA256 คืน hex lowercase — รูปแบบเดียวกับ generateHmacSha256 ฝั่ง server */ +export async function hmacSha256HexWeb(message: string, secret: string): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message)); + return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * ตรวจ HMAC ของ ticket ฝั่ง client — message คือ string ดิบของ ticket_data ตามที่อ่านจาก + * property (ห้าม trim — ตัว stamp เซ็นบนค่าดิบ) · เทียบหลัง normalize lowercase + */ +export async function verifyTicketHmacWeb(ticketData: string, expectedHex: string, secret: string): Promise { + const expected = normalizeHexDigest(expectedHex); + if (expected === null) return false; + try { + return (await hmacSha256HexWeb(ticketData, secret)) === expected; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// ขอ token จาก /api/documents/token (ทางเข้า ticket — PLAN-ADDIN-AUTH.md §3) +// --------------------------------------------------------------------------- + +interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +/** ถอด exp จาก JWT payload โดยไม่ตรวจ signature (แม่นกว่า expires_in — ใช้นาฬิกา server) */ +function jwtExp(token: string): number | undefined { + const payload = token.split(".")[1]; + if (!payload) return undefined; + try { + const bytes = Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0)); + const json: unknown = JSON.parse(new TextDecoder().decode(bytes)); + const exp = typeof json === "object" && json !== null ? (json as Record).exp : undefined; + return typeof exp === "number" && Number.isFinite(exp) ? exp : undefined; + } catch { + return undefined; + } +} + +/** + * ขอ access_token ด้วย ticket — โยน Error เมื่อ status ไม่สำเร็จ + * (401 = ticket ไม่ผ่านการตรวจฝั่ง server — ให้ caller ไป no-access ตาม flow) + */ +export async function requestDocverToken(ticketData: string, ticketHmac: string): Promise { + const response = await fetch(withBasePath("/api/documents/token"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }), + }); + if (!response.ok) { + throw new Error(`ขอ token ไม่สำเร็จ (HTTP ${response.status})`); + } + let data: TokenResponse; + try { + data = (await response.json()) as TokenResponse; + } catch { + throw new Error("ขอ token ไม่สำเร็จ (response ไม่ใช่ JSON)"); + } + if (typeof data.access_token !== "string" || data.access_token === "") { + throw new Error("ขอ token ไม่สำเร็จ (ไม่มี access_token)"); + } + const exp = + jwtExp(data.access_token) ?? + (typeof data.expires_in === "number" ? Math.floor(Date.now() / 1000) + data.expires_in : 0); + return { token: data.access_token, exp }; +}