From c0833c00f719c6d6da4b1905be5325552aab9af6 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Wed, 4 Nov 2026 21:18:19 +0700 Subject: [PATCH] Refactor authentication flow and ticket handling in add-in - Removed client-side HMAC verification; server now handles all HMAC checks. - Updated ticket processing logic to improve UX and security. - Adjusted API request handling to return discriminated results based on HTTP status. - Enhanced error messages for better clarity on authentication failures. - Added unit tests for the new token endpoint to ensure compliance with updated contract. - Cleaned up unused code and comments related to previous HMAC handling. --- .env.example | 8 +- AGENTS.md | 8 +- CLAUDE.md | 8 +- app/api/documents/token/route.ts | 43 +-- components/wordaddin/TaskpaneApp.tsx | 17 +- components/wordaddin/WordAddinGate.tsx | 31 +- docs/PLAN-ADDIN-AUTH.md | 142 ++++--- docs/PLAN-DOCVER.md | 4 +- docs/PLAN-SSO.md | 10 +- docs/WORDADDIN-DEV.md | 9 +- docs/addin-ui-flow.md | 25 +- lib/addin-ticket.ts | 9 +- lib/docver-store.ts | 16 - lib/openapi.ts | 28 +- tests/app/api/documents/token.test.ts | 215 +++++++++++ tests/lib/addin-ticket.test.ts | 5 +- tests/utils/docver-ticket.test.ts | 10 - tests/wordaddin/ticket-auth-flow.test.ts | 429 ++++++++++++++-------- tests/wordaddin/versioningService.test.ts | 3 +- utils/docver-ticket.ts | 25 +- wordaddin/config.ts | 8 +- wordaddin/services/apiClient.ts | 6 +- wordaddin/services/authService.ts | 174 ++++----- wordaddin/services/ticketService.ts | 85 ++--- 24 files changed, 858 insertions(+), 460 deletions(-) create mode 100644 tests/app/api/documents/token.test.ts diff --git a/.env.example b/.env.example index d8250bd..5765e56 100644 --- a/.env.example +++ b/.env.example @@ -61,7 +61,7 @@ MINIO_SECRET_KEY=minioadmin # 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 ทั้งสองฝั่ง +# มากับไฟล์ — secret ต้องตรงกันสองฝั่ง: e-Court (ตัว stamp) กับ server นี้ (ตัว verify) # ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET # ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement) # ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้ @@ -69,8 +69,6 @@ MINIO_SECRET_KEY=minioadmin # 2026-10-30: ทาง ticket ของ SSO (POST /api/note/auth/session) ปฏิเสธค่า default # นี้เฉพาะใน production — dev ใช้ default ทดสอบโหมด ticket ได้เลย (เครื่องมือ dev # อย่าง make-login-link/make-ticket-docx ก็ default ค่าเดียวกัน) +# 2026-11-04: ฝั่ง add-in ไม่มี secret อีกต่อไป (ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET +# ตาม PLAN-SSO.md D13 — การตรวจ HMAC เป็นของ server อย่างเดียว) # ADDIN_TICKET_SECRET=super_secret -# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง -# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน · ตามคำขอผู้ใช้ 2026-10-29 -# แก้ add-in เป็นงานแยก — ยังคงไว้ตาม PLAN-ADDIN-AUTH.md D3) -# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret diff --git a/AGENTS.md b/AGENTS.md index ba98285..a3f9174 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,12 +54,12 @@ lib/external-api.ts ← N10 ส่วนกลางของ route — parseQ 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 · **field `fields` ก็ถอดออก 2026-10-30** server แกะจากไฟล์เองที่ route ผ่าน lib/lib-tmpl.ts + 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) -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") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + 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/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") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ลายเซ็นถูกแต่หมดอายุ = **403** — contract 2026-11-04 เลิก `hasVersionByUser`/requirement ขั้น 7 ของ 2026-10-28) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing กับ invalid → **400 ทั้งคู่** contract 2026-11-04): 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()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม 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) → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → **ต้องการ token เมื่อไหร่ยิงขอ `/api/documents/token` เสมอ แล้วให้ server ตัดสิน exp เอง (แก้ 2026-10-28 ตาม requirement ขั้น 7: ticket หมดอายุแต่ fid เคยมี version โดย uid เดียวกัน = ออกให้ — กลับมาทำเอกสารเดิมใช้ต่อได้แม้ token เก่าหมด/ localStorage หาย)** ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ+ไม่เคยทำงาน = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง (ไม่ตัด exp ฝั่ง client) ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) +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()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม 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 (**flow 2026-11-04 ตาม PLAN-SSO.md D13 — client ไม่ตรวจ HMAC เอง ไม่มี secret ใน bundle ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET**): pre-check ฝั่ง client (missing/ฟอร์แม็ตพัง · หมดอายุ+ไม่มี JWT ของ fid ใน session = เตะโดยไม่ยิง API) → **ยิง `/api/documents/token` ทุกครั้งที่เปิดเอกสาร** (ต้อง online) แล้วแยกตาม contract: 200 = JWT ใหม่ (user ใหม่ = เคลียร์ session `wordaddin-auth` ทั้งก้อน/คนเดิม = update userName+tokens[fid]) · 400/401 = ฟอร์แม็ต/ลายเซ็นพัง no-access · 403 = ลายเซ็นถูกแต่ ticket หมดอายุ มี JWT ของ fid ที่ยังไม่หมดใน session ก็ใช้ต่อ (เลิก hasVersionByUser — ไม่มี JWT ต้องขอไฟล์ใหม่จาก e-Court); ticketService.requestDocverToken คืน discriminated result {ok:true entry}/{kind: network|http status|malformed} ไม่โยน Error; apiClient โดน 401 = renewTokenFromTicket 1 ครั้ง (server ตอบ 403 = false) ยังไม่ผ่าน = AuthRequiredError → no-access; token TTL 7 วัน) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) 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 ใช้ร่วม) @@ -68,7 +68,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/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย · client ไม่คำนวณ HMAC อีกต่อไปหลังถอด client HMAC 2026-11-04 ตาม PLAN-SSO.md D13 — ถอด timingSafeEqualHex/HmacSha256Hex ออก): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint) + isTicketExpired + normalizeHexDigest — 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) @@ -83,7 +83,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-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 ไฟล์เปล่า = ไม่มี)+message/clientVersionId/fileName (**form field `fields` ยกเลิก 2026-10-30 ส่งมา = 400 — fields ของ version แกะจากไฟล์ .docx ฝั่ง server เก็บทั้งก้อนเหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` ผ่าน `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server ผ่าน `addinTicketSecret()` · เห็น CC ทุกชนิดเสมอไม่ขึ้นกับ WordApi ของ host แบบตอน add-in อ่านเอง; แกะไม่ได้ = 400 · row เก่าก่อน 2026-10-30 เก็บ map ดิบ — `FieldsSnapshot`/`readSnapshotField` ของ add-in แยกรูปเอง) → สร้างเอกสารอัตโนมัติถ้า 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 604800 = 7 วัน** — แก้ 2026-11-04 จาก 3600 ให้ token อายุยาวแทนระบบ refresh ของ add-in) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md — contract 2026-11-04): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง โดยแยก status: **400 ฟอร์แม็ตพัง (รวม missing) / 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ (client มี JWT เดิมของ fid ใน session ใช้ต่อ)** — route ไม่ import `@/db`/minio เลย (unit test tests/app/api/documents/token.test.ts) · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+message/clientVersionId/fileName (**form field `fields` ยกเลิก 2026-10-30 ส่งมา = 400 — fields ของ version แกะจากไฟล์ .docx ฝั่ง server เก็บทั้งก้อนเหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` ผ่าน `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server ผ่าน `addinTicketSecret()` · เห็น CC ทุกชนิดเสมอไม่ขึ้นกับ WordApi ของ host แบบตอน add-in อ่านเอง; แกะไม่ได้ = 400 · row เก่าก่อน 2026-10-30 เก็บ map ดิบ — `FieldsSnapshot`/`readSnapshotField` ของ add-in แยกรูปเอง) → สร้างเอกสารอัตโนมัติถ้า 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) diff --git a/CLAUDE.md b/CLAUDE.md index 5f39139..b902645 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,12 +58,12 @@ lib/external-api.ts ← N10 ส่วนกลางของ route — parseQ 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 · **field `fields` ก็ถอดออก 2026-10-30** server แกะจากไฟล์เองที่ route ผ่าน lib/lib-tmpl.ts + 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) — 2026-10-29 (PLAN-SSO.md D4): claims มี `purpose?: "docver"|"login"` (undefined = docver — token เก่าตอน roll ไม่ตาย), `issueDocverToken` รับ purpose ทาง param (default "docver"), `requireDocverBearer` ปฏิเสธ login-token (กัน scope escalation), **`verifyDocverLoginToken`** แยก reason expired/invalid + บังคับ purpose=login สำหรับ SSO route -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") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + 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/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") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ลายเซ็นถูกแต่หมดอายุ = **403** — contract 2026-11-04 เลิก `hasVersionByUser`/requirement ขั้น 7 ของ 2026-10-28) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing กับ invalid → **400 ทั้งคู่** contract 2026-11-04): 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()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม 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) → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → **ต้องการ token เมื่อไหร่ยิงขอ `/api/documents/token` เสมอ แล้วให้ server ตัดสิน exp เอง (แก้ 2026-10-28 ตาม requirement ขั้น 7: ticket หมดอายุแต่ fid เคยมี version โดย uid เดียวกัน = ออกให้ — กลับมาทำเอกสารเดิมใช้ต่อได้แม้ token เก่าหมด/ localStorage หาย)** ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ+ไม่เคยทำงาน = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง (ไม่ตัด exp ฝั่ง client) ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) +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()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม 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 (**flow 2026-11-04 ตาม PLAN-SSO.md D13 — client ไม่ตรวจ HMAC เอง ไม่มี secret ใน bundle ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET**): pre-check ฝั่ง client (missing/ฟอร์แม็ตพัง · หมดอายุ+ไม่มี JWT ของ fid ใน session = เตะโดยไม่ยิง API) → **ยิง `/api/documents/token` ทุกครั้งที่เปิดเอกสาร** (ต้อง online) แล้วแยกตาม contract: 200 = JWT ใหม่ (user ใหม่ = เคลียร์ session `wordaddin-auth` ทั้งก้อน/คนเดิม = update userName+tokens[fid]) · 400/401 = ฟอร์แม็ต/ลายเซ็นพัง no-access · 403 = ลายเซ็นถูกแต่ ticket หมดอายุ มี JWT ของ fid ที่ยังไม่หมดใน session ก็ใช้ต่อ (เลิก hasVersionByUser — ไม่มี JWT ต้องขอไฟล์ใหม่จาก e-Court); ticketService.requestDocverToken คืน discriminated result {ok:true entry}/{kind: network|http status|malformed} ไม่โยน Error; apiClient โดน 401 = renewTokenFromTicket 1 ครั้ง (server ตอบ 403 = false) ยังไม่ผ่าน = AuthRequiredError → no-access; token TTL 7 วัน) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) 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 ใช้ร่วม) @@ -72,7 +72,7 @@ utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wr utils/user.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — เดิม parse/build cookie oacCase + OAC_SIG_COOKIE/SESSION_MAX_AGE ของรอบ cookie 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 `registerExternalFile`, pure; ~~sanitizeNextPath~~ ถอดแล้วพร้อมหน้า login 2026-10-29) 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 + ห้ามอักขระ & = + % ใน uid/uname — เกณฑ์เดียวกับ parseLoginTokenBody: อักขระที่ตีความต่างกันตาม context ง่าย, 2026-10-30) + isTicketExpired + normalizeHexDigest + timingSafeEqualHex + type HmacSha256Hex — unit test tests/utils/docver-ticket.test.ts +utils/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย · client ไม่คำนวณ HMAC อีกต่อไปหลังถอด client HMAC 2026-11-04 ตาม PLAN-SSO.md D13 — ถอด timingSafeEqualHex/HmacSha256Hex ออก): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint + ห้ามอักขระ & = + % ใน uid/uname — เกณฑ์เดียวกับ parseLoginTokenBody: อักขระที่ตีความต่างกันตาม context ง่าย, 2026-10-30) + isTicketExpired + normalizeHexDigest — 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) @@ -91,7 +91,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-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 ไฟล์เปล่า = ไม่มี)+message/clientVersionId/fileName (**form field `fields` ยกเลิก 2026-10-30 ส่งมา = 400 — fields ของ version แกะจากไฟล์ .docx ฝั่ง server เก็บทั้งก้อนเหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` ผ่าน `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server ผ่าน `addinTicketSecret()` · เห็น CC ทุกชนิดเสมอไม่ขึ้นกับ WordApi ของ host แบบตอน add-in อ่านเอง; แกะไม่ได้ = 400 · row เก่าก่อน 2026-10-30 เก็บ map ดิบ — `FieldsSnapshot`/`readSnapshotField` ของ add-in แยกรูปเอง) → สร้างเอกสารอัตโนมัติถ้า 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 604800 = 7 วัน** — แก้ 2026-11-04 จาก 3600 ให้ token อายุยาวแทนระบบ refresh ของ add-in) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md — contract 2026-11-04): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง โดยแยก status: **400 ฟอร์แม็ตพัง (รวม missing) / 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ (client มี JWT เดิมของ fid ใน session ใช้ต่อ)** — route ไม่ import `@/db`/minio เลย (unit test tests/app/api/documents/token.test.ts) · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+message/clientVersionId/fileName (**form field `fields` ยกเลิก 2026-10-30 ส่งมา = 400 — fields ของ version แกะจากไฟล์ .docx ฝั่ง server เก็บทั้งก้อนเหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` ผ่าน `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server ผ่าน `addinTicketSecret()` · เห็น CC ทุกชนิดเสมอไม่ขึ้นกับ WordApi ของ host แบบตอน add-in อ่านเอง; แกะไม่ได้ = 400 · row เก่าก่อน 2026-10-30 เก็บ map ดิบ — `FieldsSnapshot`/`readSnapshotField` ของ add-in แยกรูปเอง) → สร้างเอกสารอัตโนมัติถ้า 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 ← **หน้าแรก = ข้อมูลเกี่ยวกับระบบ + client bootstrap ของลิงก์ e-Court** (2026-10-30 รอบสอง ตามคำขอผู้ใช้ — server shell บาง ๆ เนื้อหาทั้งหมดอยู่ใน `components/NoteHome.tsx`): "ระบบนี้ทำอะไรได้" (การ์ด 4 ใบ: มาร์ก/วาด, ความเห็น/ทำงานร่วม, คั่นหน้า/สรุป, เอกสารจาก e-Court) + วิธีเข้าใช้ (ลิงก์ e-Court, session 8 ชม.) + ลิงก์ /note/dev · มี credential ใน URL (t/ticket) = แลก token ที่ POST /api/note/auth/session → เก็บ localStorage → ตัด credential ทิ้ง → มี fileId พาเข้า viewer · แบนเนอร์ sso/fileError · public app/note/dev/page.tsx ← **เครื่องมือทดสอบ** (ย้ายมาจากหน้าแรก 2026-10-30): เปิดเอกสารตัวอย่าง (PLAN-SSO.md D14 2026-10-29 — dropdown 2 ระดับกลุ่ม→ไฟล์ รายการ fixed จาก public/note/samples/ ไม่ query DB + รายการลิงก์ GET แบบ /tmpl "A Link Get(new)" ชี้ `/api/note/files/open?fileUrl=/samples/…` เมื่อ mint ปิด / dev-link เมื่อ mint เปิด + section Flow ของแอป — 2026-10-30 รอบสอง) + **ฟอร์ม `MockEcourtForm` "จำลอง e-Court — ขอ token ด้วย API key" (2026-11-02, แสดงทุก environment)** — ใส่ `DOCVER_API_KEY` + uid/uname + เลือกไฟล์ → ยิง `POST /api/note/auth/token` (ทางเข้าจริงเหมือน e-Court) → redirect เข้า `/note?fileUrl=…&t=…` ให้ bootstrap แลก session ต่อ · **คู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (การ์ดทาง A JWT/ทาง B ticket + กล่อง dev `make-login-link` — ตัวอย่างเป็นรูปแบบ+ขั้นตอนเท่านั้น **ห้าม mint จริงจากหน้า public**) + ฟอร์ม mint ลิงก์ `DevLoginLinkForm` · **ทางเข้าหน้าคุมด้วย env `NOTE_SSO_DEV_MINT=1` อย่างเดียว (2026-11-02 — ใช้ได้ทุก environment รวม production ที่ตั้ง env ไว้) · 0/ไม่ตั้ง = `notFound()` 404 ทั้งหน้า ไม่ใช่หน้า public อีกต่อไป** · แบนเนอร์ `?sso=`/`?fileUrl=` ไม่อยู่ที่หน้านี้ (คงอยู่ที่ `/note` ตาม contract) diff --git a/app/api/documents/token/route.ts b/app/api/documents/token/route.ts index 7385650..9542f01 100644 --- a/app/api/documents/token/route.ts +++ b/app/api/documents/token/route.ts @@ -5,11 +5,15 @@ // 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server // ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → // "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket -// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) — ticket หมดอายุ -// ยังออกให้ต่อเมื่อ fid นี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7) +// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in — client ไม่ตรวจ HMAC +// เองแล้วตาม PLAN-SSO.md D13) · contract 2026-11-04: 400 = ฟอร์แม็ตพัง +// (รวม missing) / 401 = ลายเซ็นไม่ถูก / 403 = ลายเซ็นถูกแต่ ticket หมดอายุ +// (client ที่มี JWT เดิมของ fid นี้ใน session ใช้ต่อเอง — ไม่มีการออกซ้ำ +// จากหลักฐาน DB อีกต่อไป เลิก requirement ขั้น 7 ฉบับ 2026-10-28) // ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env -// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ -// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] +// DOCVER_TOKEN_TTL_SECONDS (default 604800 = 7 วัน — token อายุยาวแทนระบบ +// refresh/renew) แล้วนำไปแนบ Authorization: Bearer กับ endpoint อื่นของกลุ่ม +// (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] // (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว import { docverError } from "@/lib/document-versioning"; import { @@ -18,7 +22,6 @@ import { ticketToDocverClaims, verifyAddinTicketHmac, } from "@/lib/addin-ticket"; -import { hasVersionByUser } from "@/lib/docver-store"; import { isTicketExpired } from "@/utils/docver-ticket"; import { issueDocverToken, @@ -30,8 +33,10 @@ import { export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */ -const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ"; +/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด) กัน oracle · หมดอายุแยกเป็น 403 */ +const TICKET_DENIED = "ticket ไม่ถูกต้อง"; +/** 403 ของทางเข้า ticket — ลายเซ็นถูกแต่หมดอายุ (client มี JWT เดิมใน session ก็ใช้ต่อได้) */ +const TICKET_EXPIRED = "ticket ของ e-Court หมดอายุ"; export async function POST(request: Request) { return request.headers.get("x-api-key") !== null @@ -65,25 +70,20 @@ async function postWithTicket(request: Request): Promise { } 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); + // contract 2026-11-04: ฟอร์แม็ตพัง = 400 ทั้ง missing/invalid (เดิม missing เป็น 401) + return docverError(parsed.error, 400); } // ตรวจ HMAC ก่อนเสมอ (ตัวตนของผู้ถือไฟล์) — exp ไม่ใช่เกณฑ์ตัดสิทธิ์ ณ จุดนี้ const verified = verifyAddinTicketHmac(parsed.ticketData, parsed.hmacHex, addinTicketSecret()); if (!verified.ok) return docverError(TICKET_DENIED, 401); - // ticket หมดอายุ → ยังออก token ให้ต่อเมื่อ "เอกสารนี้เคยทำงานโดย uid เดียวกัน" - // (requirement ขั้น 7: กลับมาทำเอกสารเดิมผ่านได้แม้ ticket หมดอายุ — พิสูจน์จาก - // document_versions ของกลุ่มนี้เอง) · DB พัง = fail closed เป็น 401 เหมือนไม่มีสิทธิ์ + // ลายเซ็นถูกแต่ ticket หมดอายุ → 403 เสมอ (contract 2026-11-04 — เลิก requirement + // ขั้น 7 ฉบับ 2026-10-28: ไม่ตรวจ hasVersionByUser อีกต่อไป เพราะ ticket ฝังอยู่ใน + // ไฟล์ ใครได้ไฟล์ก็ได้ ticket การปล่อยให้ ticket เก่าแลก token ใหม่ได้ตลอดไป + // เท่ากันคือปล่อยสิทธิ์ตามการครอบครองไฟล์ — ฝั่ง client ที่มี JWT ของ fid นี้ + // ใน session ที่ยังไม่หมด (7 วัน) ใช้ต่อเอง ไม่มี = ขอไฟล์ใหม่จาก e-Court) if (isTicketExpired(verified.ticket.exp)) { - let workedBefore = false; - try { - workedBefore = await hasVersionByUser(verified.ticket.fid, verified.ticket.uid); - } catch { - workedBefore = false; - } - if (!workedBefore) return docverError(TICKET_DENIED, 401); + return docverError(TICKET_EXPIRED, 403); } return issueToken(ticketToDocverClaims(verified.ticket)); } @@ -96,8 +96,9 @@ async function issueToken(claims: DocverTokenClaims): Promise { return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); } const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS); + // default 7 วัน (2026-11-04) — token อายุยาวแทนระบบ refresh/renew ของ add-in const ttlSeconds = - Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600; + Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 604_800; const accessToken = issueDocverToken(claims, secret, ttlSeconds); return new Response( diff --git a/components/wordaddin/TaskpaneApp.tsx b/components/wordaddin/TaskpaneApp.tsx index f3ec027..269d695 100644 --- a/components/wordaddin/TaskpaneApp.tsx +++ b/components/wordaddin/TaskpaneApp.tsx @@ -114,6 +114,10 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => /** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */ const [noAccessNotice, setNoAccessNotice] = useState(null); + /** no-access ที่ "ลองใหม่" ช่วยได้ — เฉพาะเคสอ่านไฟล์ไม่สำเร็จ (เช่น office.js มาช้า + * บนเครื่องช้า หรือ Word สะดุดชั่วคราว) กดแล้ววน detect() ใหม่ · เคสอื่น (ticket + * หมดอายุ/ลายเซ็นไม่ผ่าน/ขอ token ไม่ได้) retry ไม่ช่วย ต้องขอไฟล์ใหม่/เน็ตกลับมา */ + const [noAccessRetryable, setNoAccessRetryable] = useState(false); const [starting, setStarting] = useState(false); const [saving, setSaving] = useState(false); @@ -183,6 +187,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => const detect = useCallback(async () => { setPhase("checking"); setBootError(null); + setNoAccessRetryable(false); try { let properties: metadataService.TrackedDocumentProperties | null = null; try { @@ -191,7 +196,8 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => properties = null; } if (!properties) { - setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง"); + setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองกด \"ลองใหม่\" หรือปิดแล้วเปิด task pane อีกครั้ง"); + setNoAccessRetryable(true); setPhase("no-access"); return; } @@ -469,6 +475,15 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>

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

+ {noAccessRetryable ? ( + + ) : null} ) : null} diff --git a/components/wordaddin/WordAddinGate.tsx b/components/wordaddin/WordAddinGate.tsx index 5df16c3..2deeb10 100644 --- a/components/wordaddin/WordAddinGate.tsx +++ b/components/wordaddin/WordAddinGate.tsx @@ -22,8 +22,14 @@ import TaskpaneApp from "./TaskpaneApp"; /** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */ const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js"); -/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */ +/** + * ช่วง poll ของ fallback — ทุก 5 วิเช็คว่า global `Office` โผล่แล้วหรือยัง: + * โผล่แล้วแต่ onReady เงียบ (webview แปลก ๆ) = proceed ตามเจตนาเดิม · ยังไม่โผล่ + * (เครื่อง/เน็ตช้า — script ยังโหลดไม่เสร็จ) = รอต่อรอบหน้า ไม่ proceed บอด ๆ + */ const ONREADY_FALLBACK_MS = 5_000; +/** เพดานรวมหลัง inject script — ครบแล้วยังไม่มี Office = ไปหน้า error (มีปุ่มลองใหม่) */ +const ONREADY_HARD_CAP_MS = 60_000; /** * โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module — @@ -43,7 +49,28 @@ function ensureOfficeJs(): Promise { return; } const script = document.createElement("script"); - const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS); + const startedAt = Date.now(); + let fallbackTimer: ReturnType; + const armFallback = () => { + fallbackTimer = setTimeout(() => { + // Office โผล่แล้ว (script evaluate แล้ว) = proceed ได้เลย ไม่รอ onReady — + // กัน webview ที่โหลดสำเร็จแต่ callback เงียบ (เจตนาเดิมของ fallback) + if (typeof Office !== "undefined") { + resolve(true); + return; + } + // ยังไม่มี Office global = เครื่องช้า script ยังไม่โหลดเสร็จ — รอต่อ (re-arm) + // จนครบเพดานค่อยยอมแพ้ไปหน้า error กัน proceed แล้ว Word.run พังกลางคัน + if (Date.now() - startedAt >= ONREADY_HARD_CAP_MS) { + officeReadyPromise = null; + script.remove(); + resolve(false); + return; + } + armFallback(); + }, ONREADY_FALLBACK_MS); + }; + armFallback(); // ตั้งก่อนเสียบ src เหมือนเดิม — ครอบทั้ง network fetch + evaluate + รอ onReady const onReady = () => { // เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload // แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป) diff --git a/docs/PLAN-ADDIN-AUTH.md b/docs/PLAN-ADDIN-AUTH.md index b2afb9f..e4097c6 100644 --- a/docs/PLAN-ADDIN-AUTH.md +++ b/docs/PLAN-ADDIN-AUTH.md @@ -1,6 +1,12 @@ # PLAN-ADDIN-AUTH — Add-in Ticket Authen (`ticket_data` + `HmacSha256`) > วันที่ตกลงแผน: 2026-10-27 · สถานะ: แผนรออนุมัติ · requirement ต้นทาง: `docs/requirement/addin-workflow.md` +> **แก้ใหญ่ 2026-11-04** (ตาม code review ของผู้ใช้): ถอด client HMAC + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` +> ออก (execute **D13 ของ PLAN-SSO.md** — secret ใน bundle สกัดได้ = ปลอม ticket ผ่าน server ได้จริง +> ไม่ใช่แค่ UX gate อย่าง D3 เดิม) · ทางเข้า ticket ของ /token แยก status ชัดเจน +> **200 / 400 ฟอร์แม็ตพัง / 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่หมดอายุ** (เลิก hasVersionByUser +> ของ requirement ขั้น 7 ฉบับ 2026-10-28) · token TTL default 604800 (7 วัน) แทนระบบ refresh · +> flow ฝั่ง addin เป็น pre-check + ยิง /token ทุกครั้งที่เปิดเอกสาร ## 0. บริบท/เป้าหมาย @@ -8,7 +14,9 @@ `token`/`owner` ลงไฟล์) เป็น **ticket** ตาม requirement: - e-Court stamp custom properties 2 ค่าในไฟล์ .docx: `ticket_data` (JSON) + `HmacSha256` -- addin ตรวจ HMAC เองฝั่ง client (gate เชิง UX — ตัดเคสไฟล์ถูกแก้ก่อนยิง network) +- addin ตรวจ **ฟอร์แม็ต + exp** เองฝั่ง client เป็น UX gate — การตรวจลายเซ็น (HMAC) เป็นของ + **server อย่างเดียว** ยิง /token ทุกครั้งที่เปิดเอกสาร (แก้ 2026-11-04 — เดิมตรวจ HMAC ฝั่ง client + ด้วย secret ใน bundle) - **addin เป็นฝ่ายขอ access_token** ที่ `POST /api/documents/token` เก็บใน localStorage ตาม fid (session ต่อ userId) — ไม่ใช่ e-Court อีกต่อไป - ถอดระบบเก่า `token`/`owner` ออกทั้งหมด (implement ไว้ 2026-10-27 ยังไม่มีไฟล์จริงใช้ — @@ -20,10 +28,10 @@ |---|---|---| | 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 | +| D3 | client-side HMAC | ~~ทำตาม requirement (sign เทียบเองก่อน) โดย secret มาจาก `NEXT_PUBLIC_ADDIN_TICKET_SECRET`~~ **แทนที่ 2026-11-04 ตาม PLAN-SSO.md D13**: client ไม่ตรวจ HMAC — เหลือ parse + exp เป็น UX gate การตรวจ "ไฟล์ถูกแก้" เกิดตอน server ตอบ 401 · ถอด `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก bundle ทั้งหมด (secret ใน bundle ต้องตรงกับของ server เสมอจึงผ่าน gate = แกะ bundle ก็ปลอม ticket ผ่าน server ได้จริง) | | 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 | +| D6 | token หมดอายุ | **แก้ 2026-11-04: TTL default 604800 (7 วัน)** แทนระบบ refresh/renew — renew จาก ticket (โดน 401 กลาง session → renew 1 ครั้งแล้ว retry) เหลือเป็นเส้นทางรองที่แทบไม่ถูกใช้ · ticket หมดอายุแล้ว server ตอบ 403 → renew ไม่ได้ → ใช้ JWT เดิมใน session ต่อ | | 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 ต่อ | @@ -47,8 +55,9 @@ HmacSha256 = = HMAC-SHA256(secret, ticket_data | `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) ต้องตรงกันทั้งสาม** +- secret ปัจจุบัน = `"super_secret"` — **e-Court (ตัว stamp) + `ADDIN_TICKET_SECRET` (server + ของเรา ตัว verify) ต้องตรงกันสองฝั่ง** (แก้ 2026-11-04 — เดิมต้องตรงสามฝั่งรวม + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ฝั่ง client ซึ่งถอดออกแล้ว) - ⚠️ **custom property ของ Word จำกัด value ~255 ตัวอักษร** — JSON ของ `ticket_data` ต้องออกแบบสั้น (fid/tid/uname ยาวมากจะทะลุ; ฝั่ง addin เจอค่าเกิน = ถือว่าไฟล์เสียหาย → no-access) @@ -59,13 +68,16 @@ 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}` | **ทางใหม่** — `verifyAddinTicketHmac` (HMAC+parse ไม่ตรวจ exp) → map claims → `issueDocverToken` → response รูปเดียวกัน `{access_token, token_type, expires_in}` | -| ไม่มี `x-api-key` + body ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string | 400 (รูปผิดก่อนถึงการ verify) | -| HMAC ผิด / parse ticket ไม่ผ่าน | 401 **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) | -| ticket **หมดอายุ** แต่ HMAC ถูก | แก้ 2026-10-28 (requirement ขั้น 7): ตรวจ DB ว่า `fid` นี้เคยมี version โดย `uid` เดียวกัน (`hasVersionByUser` ของ `lib/docver-store.ts`) → เคย = ออก token ให้ต่อ · ไม่เคย (หรือ DB พัง = fail closed) = 401 ข้อความเดียวกัน | -| ไม่มี `x-api-key` และไม่มี ticket fields เลย | 401 | +| ไม่มี `x-api-key` + body `{ticket_data: string, HmacSha256: string}` ถูกทั้งคู่ | **ทางใหม่** — `verifyAddinTicketHmac` (HMAC+parse ไม่ตรวจ exp) → `isTicketExpired` → `issueDocverToken` → 200 response รูปเดียวกัน `{access_token, token_type, expires_in}` | +| body ไม่ใช่ JSON / ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string / **ไม่มี ticket fields เลย (missing)** | **400** (ฟอร์แม็ตพัง — แก้ 2026-11-04: missing ย้ายจาก 401 มารวม 400) | +| HMAC ผิด / parse ticket ไม่ผ่าน | 401 `"ticket ไม่ถูกต้อง"` **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle) | +| ticket **หมดอายุ** แต่ HMAC ถูก | **403 `"ticket ของ e-Court หมดอายุ"** (แก้ 2026-11-04 — เลิก requirement ขั้น 7 ฉบับ 2026-10-28: ถอด `hasVersionByUser` ออก ไม่ตรวจ DB อีกต่อไป เพราะ ticket ฝังในไฟล์ ใครได้ไฟล์ก็ได้ ticket การปล่อยให้ ticket เก่าแลก token ใหม่ได้ตลอดไปเท่ากับปล่อยสิทธิ์ตามการครอบครองไฟล์ · client ที่มี JWT ของ fid นี้ใน session ที่ยังไม่หมดใช้ต่อเอง) | | `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 (เดิม) | +- token TTL: env `DOCVER_TOKEN_TTL_SECONDS` **default 604800 (7 วัน)** (แก้ 2026-11-04 จาก 3600) +- route ไม่ import `@/db`/minio เลยหลังถอด DB check — unit test ได้โดยไม่ต้องตั้ง DATABASE_URL + (`tests/app/api/documents/token.test.ts`) + - การเทียบ digest ฝั่ง server: hash ทั้งสองฝั่งด้วย SHA-256 แล้ว `timingSafeEqual` — pattern `verifyDocverToken` ของ `lib/docver-token.ts` เป๊ะ - middleware ยกเว้น `api/documents/*` อยู่แล้ว — ไม่ต้องแต้ @@ -76,36 +88,37 @@ Branch ตาม header (ตัวตั้ง = `x-api-key`): 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 ที่สองฝั่งฉีด +export function normalizeHexDigest(value: string): string | null // trim+lowercase+ตรวจ ^[0-9a-f]{64}$ (ใช้ฝั่ง server) ``` +(แก้ 2026-11-04: ถอด `timingSafeEqualHex` + `HmacSha256Hex` adapter ออก — client ไม่คำนวณ +HMAC แล้วจึงไม่ต้องฉีดตัวคำนวณ, timing-safe จริงอยู่ที่ `node:crypto` ฝั่ง server) + - `lib/` (server) กับ `wordaddin/` (client) ใช้ `parseDocverTicket`/`isTicketExpired` ตัวเดียวกัน — schema drift กันได้จากฝั่งเดียว -## 5. ฝั่ง addin — flow ตอนเปิดเอกสาร (map ขั้นตอน 1–7 ของ requirement) +## 5. ฝั่ง addin — flow ตอนเปิดเอกสาร (แก้ 2026-11-04 — pre-check + ยิง /token ทุกครั้ง) `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 ใหม่): - · ขอ token จาก /token เลย — **server เป็นคนตัดสิน exp** (แก้ 2026-10-28: ticket หมดอายุ - แต่ fid นี้เคยมี version โดย uid เดียวกัน = ออกให้ → คนที่กลับมาทำเอกสารเดิม - ผ่านได้แม้ session หาย/เคลียร์ · 401 + ticket หมดอายุ → no-access "ticket หมดอายุ") - · สำเร็จ → เคลียร์ session เก่า → เก็บ session ใหม่ {userId: uid, …} -5. uid ตรงกัน (คนเดิม): - · tokens[fid] มีและยังไม่หมดอายุ (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ยิง /token - · tokens[fid] มีแต่ token หมดอายุ → ยิง renew เสมอ (ticket ยังไม่หมด = ออกให้เสมอ · - หมดแล้ว = ออกเฉพาะ "เคยทำงาน" ตามข้อ 4) → fail = เก็บ token เก่าไว้ - (ถ้าเน่าจริง apiClient จะ 401 → renew 1 ครั้ง → ยัง fail = no-access ตอนใช้งาน) - · tokens[fid] ไม่มี (เอกสารใหม่) → ขอ token เก็บเพิ่มเหมือนข้อ 4 -6. ตั้ง state ภายใน (fid/token/username/ticket ดิบไว้ renew) → ไหลต่อ phase +2. pre-check ฝั่ง client (ไม่ยิง network): + · ขาดข้อใดข้อหนึ่ง / ค่ายาวเกิน 255 → no-access "ไม่พบ ticket ของ e-Court ในไฟล์" + · parseDocverTicket ไม่ผ่าน → no-access "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง" + · ticket หมดอายุ และ ไม่มี JWT ของ fid นี้ใน session (uid ตรงกัน) → no-access + "ticket หมดอายุ" (มีใน session = มีโอกาสเป็นไฟล์เก่าที่เคยแก้ → ไปให้ server ตัดสินต่อ) +3. ยิง POST /api/documents/token **ทุกครั้งที่เปิดเอกสาร** (ตรวจลายเซ็นที่ server — + client ไม่มี secret) แล้วแยกทางตาม status: + · 200 → ได้ JWT ใหม่: uid ≠ session.userId (user ใหม่) = เคลียร์ session เก่าทั้งก้อน + สร้างใหม่ · คนเดิม = update userName + tokens[fid] + · 403 (ลายเซ็นถูกแต่ ticket หมดอายุ) → มี JWT ของ fid ใน session ที่ยังไม่หมด + (uid ตรง) = refresh userName แล้ว**ใช้ JWT เดิมต่อ** · ไม่มี/หมดแล้ว = no-access + "ticket หมดอายุ" + · 400 → no-access "รูปแบบ ticket ไม่ถูกต้อง" · 401 → no-access "ตรวจสอบลายเซ็น (HMAC) + กับระบบไม่ผ่าน" (ทั้งคู่ = ไฟล์ถูกแก้/เสียหาย ต้องขอไฟล์ใหม่) + · network/5xx/ตอบผิดรูป → no-access "ขอ token จากระบบไม่สำเร็จ" (ลองใหม่ได้เมื่อเน็ตกลับ) +4. ตั้ง state ภายใน (fid/username/ticket ดิบไว้ renew) → ไหลต่อ phase untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน ``` @@ -123,22 +136,33 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise - `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 +- `wordaddin/services/ticketService.ts` — client: ~~`verifyTicketHmacWeb` (crypto.subtle)~~ + (ถอด 2026-11-04), session store (`loadSession`/`saveSession`/`clearSession` — try/catch + memory fallback), `requestDocverToken` - (POST `withBasePath("/api/documents/token")` body ticket) + (POST `withBasePath("/api/documents/token")` body ticket — แก้ 2026-11-04: คืน discriminated + result แยก network/http status/malformed ไม่โยน Error) - `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/config.ts` — ~~`ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret"`~~ + (ถอด 2026-11-04 — เหลือ `apiBaseUrl` + `mockUsername`) - `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/authService.ts` — rewrite ตาม §5 (แก้ 2026-11-04: pre-check + แยก status + 200/400/401/403 ผ่าน result ของ requestDocverToken) - `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) +- `components/wordaddin/TaskpaneApp.tsx` — `detect()` เรียก `signInWithTicket` + ข้อความ no-access + (แก้ 2026-11-04: no-access เคส "อ่านข้อมูลเอกสารไม่สำเร็จ" มีปุ่ม "ลองใหม่" เรียก detect() ซ้ำ) +- `components/wordaddin/WordAddinGate.tsx` — แก้ 2026-11-04: fallback onReady เดิม 5 วิแล้ว proceed + บอด ๆ ทำให้เครื่องช้าจบ no-access ไม่มีทางออก — ตอนนี้ poll ทุก 5 วิว่า global `Office` โผล่หรือยัง + (โผล่แล้ว = proceed ตามเจตนาเดิม · ยังไม่โผล่ = รอต่อ) จนถึง hard cap 60 วิ ค่อยไปหน้า error +- `lib/openapi.ts` — POST /token มี 2 รูป body (oneOf: identity+x-api-key / ticket) + คำอธิบาย + 400/401/403 ตาม §3 (แก้ 2026-11-04) +- `tests/app/api/documents/token.test.ts` — ใหม่ 2026-11-04: route unit test ครอบ contract + (ทำได้เพราะ route ไม่ import @/db แล้ว) - `.env.example`, `docs/WORDADDIN-DEV.md`, `docs/addin-ui-flow.md`, `docs/PLAN-DOCVER.md` (§token ชี้มาที่นี่), `CLAUDE.md` **ถอด** @@ -150,7 +174,7 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise | 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 เช่นกัน | +| ~~`NEXT_PUBLIC_ADDIN_TICKET_SECRET`~~ | — | **ถอด 2026-11-04** (PLAN-SSO.md D13) — ฝั่ง add-in ไม่มี secret อีกต่อไป | > หมายเหตุจากตอน implement: ตัว stamp ticket จริงคือกลุ่ม template > (`app/api/template/render/route.ts` → `applyTemplate` — sort key ตามตัวอักษรหลังเติม @@ -163,16 +187,24 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise ## 8. Edge cases -- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว — +- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse ไม่ผ่านอยู่แล้ว — เช็คความยาวเพื่อข้อความที่ชัดกว่า) -- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว - (เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — และหลังแก้ 2026-10-28 ขอใหม่ได้**แม้ ticket หมดอายุ** - ตราบใดที่เอกสารนี้เคยมี version โดย uid เดียวกัน: server ออกให้จากหลักฐานใน DB - ไม่ต้องพึ่ง token เก่าในเครื่องอีกต่อไป — ข้อจำกัดเหลือเฉพาะเอกสารที่เคยเปิดแต่ - **ยังไม่เคยสร้าง version** ซึ่งไม่มีร่องรอยฝั่ง server ต้องขอไฟล์ใหม่จาก e-Court) +- **เปิดเอกสาร = ต้อง online ทุกครั้ง** (ยิง /token เพื่อตรวจลายเซ็นที่ server — แก้ 2026-11-04) + — offline ได้ no-access "ขอ token ไม่สำเร็จ" · ระหว่าง session ใช้ต่อได้เพราะ JWT อายุ 7 วัน + (apiClient ไม่ยิง /token จนกว่าจะโดน 401) +- **ticket หมดอายุ + มี JWT ของ fid ใน session ที่ยังไม่หมด** → ผ่านด้วย JWT เดิม (server ตอบ 403) + · **ticket หมดอายุ + ไม่มี session / JWT หมดด้วย / localStorage หาย** → ต้องขอไฟล์ใหม่จาก + e-Court (แก้ 2026-11-04 — เดิม server ออก token ให้จากหลักฐาน DB `hasVersionByUser` ซึ่งถอดแล้ว) +- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ task pane + instance เดียว: ปิด-เปิดใหม่ + ticket หมดอายุ = no-access (ไม่มี JWT ในเครื่อง) · ticket ยังไม่หมด + = ขอใหม่ได้ปกติ - **uname ไทย** — เป็น string ตรง ๆ ไม่ผ่าน base64 ไม่มีปัญหา mojibake (ต่างจาก decode JWT payload) - **exp == now** → หมดอายุ -- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access +- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง (server ตอบ 200 = ได้ใหม่ · 403 = ticket + หมด → false) → retry → ยังไม่ผ่าน → no-access +- **เครื่องโหลดช้า จน office.js ยังไม่ evaluate ตอน taskpane เริ่ม** → WordAddinGate รอต่อ (poll + ทุก 5 วิ) จนมี global `Office` ถึง mount TaskpaneApp — ไม่ proceed บอด ๆ แล้ว (แก้ 2026-11-04); + ถ้า detect อ่านไฟล์ไม่ได้จริง หน้า no-access มีปุ่ม "ลองใหม่" เรียก detect() ซ้ำ - **Word เปิดหลายเอกสารพร้อมกัน (user เดียวกัน fid ต่างกัน)** — tokens map รองรับ; current fid เป็น state ต่อ taskpane instance (memory) — แยกกันอยู่แล้ว - **ไฟล์ทั่วไปไม่มี ticket** → no-access เหมือนเดิม (ข้อความเดิมปรับคำ) @@ -191,17 +223,21 @@ Dev tool รันมือ `node scripts/make-ticket-docx.mjs [opti ## 10. Verify criteria -1. `npx vitest run` ผ่านทั้งชุด (ไฟล์ใหม่ + เดิม — โดยเฉพาะ `tests/lib/docver-token.test.ts` ไม่พัง) -2. `npx tsc --noEmit` ผ่าน +1. `npx vitest run` ผ่านทั้งชุด (รวม `tests/app/api/documents/token.test.ts` — route contract + 400/401/403/200 ที่เพิ่ม 2026-11-04 และ `tests/wordaddin/ticket-auth-flow.test.ts` ฉบับใหม่) +2. `npx tsc --noEmit` ผ่าน (error เดิมของ `piscina`/`docx-templates` ไม่เกี่ยวกับแผนนี้) 3. curl regression ที่ /token: (ก) `x-api-key` + identity body ออก token ได้เหมือนเดิม - (ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400 + (ข) ticket body ออก token ได้ `expires_in` = 604800 (ค) HMAC ผิด = 401 ข้อความเดียว + (ง) body ปนกัน/ไม่ส่ง ticket = 400 (จ) exp อดีตลายเซ็นถูก = **403** 4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx: - ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ `ticket_data` 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · - exp อดีตของเอกสารที่ไม่เคยสร้าง version → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ → - ไม่ยิง /token ซ้ำ (ใช้ token เดิม) · **ticket หมดอายุ + เอกสารเคยสร้าง version ≥1 โดย uid เดียวกัน → - เปิดใหม่ใช้ต่อได้แม้เคลียร์ localStorage `wordaddin-auth` ก่อนเปิด (server ออก token ใหม่ — แก้ 2026-10-28)** · - ตั้ง `DOCVER_TOKEN_TTL_SECONDS` สั้น ๆ แล้วเปิดซ้ำหลังหมดอายุ → renew อัตโนมัติใช้ต่อได้ · - เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์ + ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · **dev log เห็น POST /token ทุกครั้งที่เปิด task pane** · + แก้ `ticket_data` 1 ตัวอักษร → no-access ข้อความลายเซ็น (server 401) · + **ticket หมดอายุ + มี session (JWT 7 วันยังไม่หมด) → ผ่านด้วย JWT เดิม (Network เห็น 403)** · + **ticket หมดอายุ + เคลียร์ localStorage `wordaddin-auth` → no-access "ticket หมดอายุ"** (แก้ 2026-11-04: + ไม่มีทางกลับจากหลักฐาน DB อีก) · หยุด dev server → no-access "ขอ token ไม่สำเร็จ" · + ตั้ง `DOCVER_TOKEN_TTL_SECONDS` สั้น ๆ แล้วใช้จน JWT หมด → apiClient renew อัตโนมัติ (ticket ยังไม่หมด) · + เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์ · browser ธรรมดา → no-access "อ่านข้อมูลเอกสารไม่สำเร็จ" + + กด "ลองใหม่" ได้ 5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/` ## 11. ลำดับงาน diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index 166d583..8dea3bc 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -62,7 +62,7 @@ JSON body (allowlist — key อื่น = 400 กัน typo): - env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า" (ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret) - response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }` - — JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600) + — JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 604800 = 7 วัน — แก้ 2026-11-04 จาก 3600 ให้ token อายุยาวแทนระบบ refresh/renew ของ add-in) · verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe - **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer `** — ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย @@ -261,7 +261,7 @@ read-only ของ N10 แต่ตัวนี้เขียนได้) | `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL | | `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ · GET /latest ใช้ตัวนี้**หรือ** `EXTERNAL_API_KEY` ตัวใดตัวหนึ่ง | | `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ | -| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) | +| `DOCVER_TOKEN_TTL_SECONDS` | `604800` | อายุ access_token (วินาที) — default 7 วัน (แก้ 2026-11-04 จาก 3600) | | `TICKET_SEC` | `300` | TTL (วินาที) ของ `exp` ที่ stamp ตอน GET /latest type=docx — env เดียวกับกลุ่ม template (`app/api/template/*`) | | `PRIVATE_KEY_HEX` | — optional | private key Ed25519 (hex seed) — ตั้งแล้ว GET /latest type=docx จะ stamp prop `Ed25519` เพิ่ม · ไม่ตั้ง = ไม่ใส่ (chain ของ add-in verify แค่ `HmacSha256`) · env เดียวกับกลุ่ม template | | `ADDIN_TICKET_SECRET` | chain → `SECRET` → `"super_secret"` | secret ของ HMAC ที่ stamp ที่ /latest — ใช้ `addinTicketSecret()` ให้ตรงฝั่ง verify ของ /token ทาง ticket (ดู `docs/PLAN-ADDIN-AUTH.md`) | diff --git a/docs/PLAN-SSO.md b/docs/PLAN-SSO.md index 28b9731..821c480 100644 --- a/docs/PLAN-SSO.md +++ b/docs/PLAN-SSO.md @@ -25,7 +25,7 @@ middleware รวมเป็น**หน้าแรก `/note` ที่เป ยิงเส้น mint ใหม่ ส่วนทาง B ใช้ตัว stamp เดียวกับ .docx ของ add-in - ticket (B) เก็บไว้เพราะ mint ได้**แบบ offline** — ครอบคลุมเคสที่ e-Court สร้างลิงก์ล่วงหน้า/ ไม่ยิง API ตอน render · แลกมาด้วยเงื่อนไขบังคับ **D13** (ตัด secret ออกจาก bundle ของ add-in - ก่อน — ไม่งั้นใครแกะ bundle ก็ปลอม ticket เข้า login ได้) + ก่อน — ไม่งั้นใครแกะ bundle ก็ปลอม ticket เข้า login ได้ · **ทำแล้ว 2026-11-04**) **ผลพลอยได้:** ไม่มีเงื่อนไขค้างกับทีม e-Court อีกต่อไป — ทางไหนที่เขาทำได้ตามบริบทหน้านั้น ๆ ก็ใช้ได้ (แนะนำ A เมื่อ mint real-time ได้, B เมื่อสร้างลิงก์ล่วงหน้า) · ลิงก์ทั้งสองแบบ @@ -69,7 +69,7 @@ credential อายุสั้น ฝังอีเมล/แชท/เก็ | D10 | เครื่องมือทดสอบ | `scripts/make-login-link.mjs` mint token ออฟไลน์ (node:crypto HS256 จาก `DOCVER_TOKEN_SECRET`) แล้วพิมพ์ลิงก์เต็ม — pattern เดียวกับ `make-ticket-docx.mjs` | | D11 | fid | route ใช้ fid จาก credential เป็น `fileId` ของ N8 **เสมอ** (ค่าที่เซ็นแล้ว มาจาก token/ticket ไม่ใช่ param) — ลิงก์ไม่ต้องส่ง `fileId` และ param นี้ถ้าส่งมาก็ถูกแทนที่ กัน mismatch ระหว่างค่าเซ็นกับค่าปลอมแปลงได้ | | D12 | สองทางเข้าพร้อมกัน | route แยกตาม param: มี `t` = ทาง JWT · ไม่มี `t` แต่มี `ticket_data`+`HmacSha256` ครบ = ทาง ticket · **มีทั้งสองชุด = `sso=invalid`** (fail closed กันกำกวม) · ทั้งสองทางจบที่ cookie + redirect เดียวกัน · ทาง ticket ไม่ใช้ `tid` (template ต้นทาง — ไม่เกี่ยวกับ login) | -| D13 | เงื่อนไขบังคับของทาง ticket | **ตัด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก add-in ก่อนเปิดทาง ticket** — secret ตัวเดียวกันจะกลายเป็นจุดอ่อนของ login โดยตรง (แกะ bundle = ปลอม ticket เข้าได้) · add-in เหลือ parse + exp เป็น UX gate, การตรวจ "ไฟล์ถูกแก้" เกิดตอน server ตอบ 401 (แทนที่ D3 ของ PLAN-ADDIN-AUTH.md) · โบนัส: ทางเข้า ticket ที่ `/token` ของ add-in ก็แข็งขึ้นตาม | +| D13 | เงื่อนไขบังคับของทาง ticket | **ตัด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก add-in ก่อนเปิดทาง ticket** — secret ตัวเดียวกันจะกลายเป็นจุดอ่อนของ login โดยตรง (แกะ bundle = ปลอม ticket เข้าได้) · add-in เหลือ parse + exp เป็น UX gate, การตรวจ "ไฟล์ถูกแก้" เกิดตอน server ตอบ 401 (แทนที่ D3 ของ PLAN-ADDIN-AUTH.md) · โบนัส: ทางเข้า ticket ที่ `/token` ของ add-in ก็แข็งขึ้นตาม · **✅ ทำแล้ว 2026-11-04** (พร้อมปรับ contract ทาง ticket ของ `/token` เป็น 400/401/403 + TTL 7 วัน — ดู PLAN-ADDIN-AUTH.md หัวไฟล์) | | D14 | หน้าแรก `/note` ใหม่ (2026-10-29) | เปลี่ยนจากรายการไฟล์แบบการ์ด เป็น**หน้าเลือกเอกสารแบบ dropdown 2 ระดับ** คล้าย `app/tmpl/page.tsx` (กลุ่ม → ไฟล์) + ปุ่ม "เปิดเอกสาร" เข้า annotate — ถอดรายการการ์ด N9 + ฟอร์มอัปโหลด N4 + การ์ดจำลองลิงก์ N8 ออกจากหน้า · N8 `?fileUrl=` ยังทำงานที่หน้านี้ **เฉพาะเมื่อมี session ที่ verify แล้ว** (ไม่มี = redirect `/note?sso=required` โดยไม่แตะ DB — ดู §1.1) · รายละเอียด §1.1 | | D15 | บังคับ session (2026-10-29) | `/note` **public** — middleware ยกเว้น `note` เพิ่ม (คนไม่มี cookie เห็นหน้าเลือกเอกสารได้) ส่วนหน้าอื่น (annotate/summary/groups) ยังบังคับ cookie — ไม่มี = redirect `/note?sso=required` (ไม่มี `next` แล้ว: ไม่มี login ให้วนกลับ ผู้ใช้ต้องกลับ e-Court คลิกลิงก์ใหม่) · action `logout` คงไว้ ปลายทางเปลี่ยนเป็น `/note` | | D16 | เซ็น session cookie (2026-10-29 รอบสอง — code review ข้อ 3) | `oacCase` เดิม**ไม่มีลายเซ็น** (`parseOacCase` อ่าน query string เปล่า — ใครก็ปลอม `u_userid` เป็นคนอื่นได้ token/ticket ทั้งแผนจะไม่เพิ่มความปลอดภัยจริง) → เก็บรูป `oacCase` เดิมไว้ + **cookie ที่สอง `oacCaseSig`** = `.` เซ็นครอบ **payload เต็มของ `oacCase` + `.` + exp** (แก้ exp เฉย ๆ ไม่ได้) ด้วย secret ใหม่ `NOTE_SESSION_SECRET` — **แยกจาก `DOCVER_TOKEN_SECRET`** (ไม่แตะของกลุ่ม documents: session ของแอป note ไม่ผูก secret กลุ่ม docver หมุนพร้อมกันไม่ได้ โดยเฉพาะตอน pre-production ที่ต้อง rotate DOCVER อยู่ด้วย) · ไม่ตั้ง/ว่าง = **fail closed** (verify ไม่ผ่านทุกกรณี = ไม่มี session, SSO route ตอบ `sso=error` — ไม่มี default ที่รู้กันหมด) · ตรวจ**ทุกจุดที่อ่าน identity**: middleware (async + Web Crypto — edge runtime ไม่มี node:crypto) + `getSessionUser()` (เป็น async + verify หน้า caller `await`) · cookie ทั้งสอง httpOnly/sameSite lax/path `/`/maxAge 8h + **`secure` เมื่อ production** · exp ฝังในของที่เซ็น = อายุจริงวัดฝั่ง server (maxAge เป็นแค่ cleanup ฝั่ง browser) | @@ -233,7 +233,7 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path, - `app/note/annotate/[id]/page.tsx` — `await getSessionUser()` ตาม signature ใหม่ - `app/actions/auth.ts` — action `logout` เคลียร์**ทั้งสอง cookie** (`oacCase` + `oacCaseSig`) + ปลายทาง redirect เปลี่ยนจากหน้า login เป็น `/note` - `utils/user.ts` — เพิ่ม `OAC_SIG_COOKIE` + `SESSION_MAX_AGE` (§4) -- **add-in (D13):** `wordaddin/services/authService.ts` ถอดการเรียก `verifyTicketHmacWeb` (เหลือ parse + exp เป็น UX gate) · `wordaddin/services/ticketService.ts` ถอด `verifyTicketHmacWeb`/`hmacSha256HexWeb` · `wordaddin/config.ts` ถอด `ticketSecret` · `components/wordaddin/TaskpaneApp.tsx` ข้อความ no-access ปรับ (ไม่มีสาเหตุ HMAC ฝั่ง client — รวมเป็น "ticket ไม่ถูกต้อง" ตอน server 401) · `docs/PLAN-ADDIN-AUTH.md` (D3/D8 ถูกแทนที่) + `docs/WORDADDIN-DEV.md` +- **add-in (D13):** `wordaddin/services/authService.ts` ถอดการเรียก `verifyTicketHmacWeb` (เหลือ parse + exp เป็น UX gate) · `wordaddin/services/ticketService.ts` ถอด `verifyTicketHmacWeb`/`hmacSha256HexWeb` · `wordaddin/config.ts` ถอด `ticketSecret` · `components/wordaddin/TaskpaneApp.tsx` ข้อความ no-access ปรับ (ไม่มีสาเหตุ HMAC ฝั่ง client — รวมเป็น "ticket ไม่ถูกต้อง" ตอน server 401) · `docs/PLAN-ADDIN-AUTH.md` (D3/D8 ถูกแทนที่) + `docs/WORDADDIN-DEV.md` — **✅ ทำแล้ว 2026-11-04** - `lib/openapi.ts` — เพิ่ม path ใหม่ `POST /api/note/auth/token` (field ตาม §3.1) · รายการ `/api/documents/token` **คงเดิมไม่แต้** - `.env.example` — เช็คว่ามี `DOCVER_TOKEN_SECRET` + `NOTE_SESSION_SECRET` (ค่า dev อะไรก็ได้) — dev ไม่มีสองค่านี้ = เข้า annotate ไม่ได้ (ทางเข้า session เดียวคือลิงก์ SSO ส่วนหน้าแรก public) - `CLAUDE.md` — สถานะ auth ของแอป note (mock login ถอด, SSO 2 ทางเข้า, **ไม่มีหน้า login · `/note` public + หน้าเลือกเอกสารใหม่ · N4 ถอด**) + add-in ถอด client HMAC (D13) + invariant #4 ไม่เกี่ยว (route นี้ไม่แตะ DB ไม่ publish SSE) @@ -247,7 +247,7 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path, - `MOCK_USERS` ใน `utils/user.ts` + comment "ใช้ร่วมกันสองที่" ที่ตายตาม (`OAC_CASE_COOKIE`/`parseOacCase`/`buildOacCaseCookie`/`SESSION_MAX_AGE` คงไว้ทั้งหมด — auth route ใช้อยู่) - `components/UploadFileForm.tsx` + action `uploadFile` ใน `app/actions/files.ts` (N4 — ไม่มีทางเข้า UI อีกตาม D14) - `components/EcourtLinkForm.tsx` (ทางทดสอบ N8 เหลือสคริปต์ D10) -- `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ใน `.env.example` และทุกจุดอ้าง (D13 — secret ออกจาก bundle) +- ~~`NEXT_PUBLIC_ADDIN_TICKET_SECRET` ใน `.env.example` และทุกจุดอ้าง (D13 — secret ออกจาก bundle)~~ **✅ ถอดแล้ว 2026-11-04** ## 7. Env @@ -298,7 +298,7 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path, ## 10. ลำดับงาน -0. **add-in D13** — ถอด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก bundle (+ ปรับ tests/docs) → verify 9.3 (ฐ) +0. **add-in D13** — ~~ถอด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก bundle (+ ปรับ tests/docs)~~ **✅ ทำแล้ว 2026-11-04** (พร้อม refactor ใหญ่ของ PLAN-ADDIN-AUTH.md: contract 400/401/403 + TTL 7 วัน + fallback onReady) → verify 9.3 (ฐ) 1. `lib/docver-token.ts` (purpose + ttl) + tests → verify 9.1 บางส่วน 2. `lib/note-auth.ts` + tests (รวม `signOacSession`/`verifyOacSession` ของ D16) 3. `app/api/note/auth/token/route.ts` (mint) → verify 9.3 (ก)(จ) แล้ว `app/api/note/auth/e-court/route.ts` → verify 9.3 (ข–ง) diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index 252aa0f..399c81e 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -24,7 +24,8 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม |---|---|---| | `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 ต้องตั้งค่าจริงทั้งสองฝั่ง** | + +(~~`NEXT_PUBLIC_ADDIN_TICKET_SECRET`~~ — ถอดออก 2026-11-04 ตาม PLAN-SSO.md D13: add-in ไม่ตรวจ HMAC เองแล้ว ไม่มี secret ใน bundle ฝั่ง client) ## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word) @@ -87,7 +88,7 @@ Word for Mac ไม่มี Trust Center แบบ Windows — สแกน add ## ทดสอบ e-Court ticket (auth ทางเข้าเดียว) -ไฟล์ที่ 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` แยกข้อความตามสาเหตุ +ไฟล์ที่ 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` (แก้ 2026-11-04: pre-check ฟอร์แม็ต/exp ฝั่ง client → ยิง `/docedit/api/documents/token` **ทุกครั้งที่เปิดเอกสาร** ให้ server ตรวจลายเซ็นแล้วแยกตาม status 200/400/401/403 → เก็บ token ตาม fid · ต้อง online ตอนเปิดไฟล์เสมอ) · ไฟล์ที่ไม่มีคู่นี้ / ฟอร์แม็ตพัง / ลายเซ็นไม่ผ่าน (server 401) / ticket หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ ทดสอบเองใน Word โดยไม่ต้องรอ e-Court — สร้างไฟล์ทดสอบด้วยสคริปต์ (คำนวณ HMAC ตรงกับตัว stamp จริงของกลุ่ม template): @@ -100,13 +101,13 @@ node scripts/make-ticket-docx.mjs path/to/file.ticket.docx --list 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 นาทีแล้วเปิด**เอกสารที่ยังไม่เคยสร้าง version** → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำหลัง ticket หมดอายุ → ยังใช้ได้ตาม requirement ขั้น 7: add-in ยิงขอ token ใหม่แล้ว server ตรวจว่า fid นี้เคยมี version โดย uid เดียวกัน → ออกให้ต่อ (แก้ 2026-10-28 — ใช้ได้แม้ token เก่าหมดอายุหรือ localStorage `wordaddin-auth` โดนเคลียร์ เพราะไม่พึ่ง cache ในเครื่องอีกต่อไป · ข้อจำกัด: เอกสารที่เคยเปิดแต่ยังไม่เคยสร้าง version ไม่มีหลักฐานฝั่ง server ต้องขอไฟล์ใหม่จาก e-Court) +แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case (แก้ 2026-11-04 ตาม contract ใหม่): แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access ข้อความลายเซ็นไม่ผ่าน (server 401) · สร้างด้วย `--exp +60` รอหมดอายุแล้วเปิดใหม่ — **มี session (JWT อายุ 7 วันยังไม่หมด)** → ยังใช้ได้ (Network เห็น /token ตอบ 403 แต่ UI ไหลต่อด้วย JWT เดิม) · **เคลียร์ localStorage `wordaddin-auth` ก่อนเปิด** → no-access "ticket หมดอายุ" (ต้องขอไฟล์ใหม่จาก e-Court — เลิกตรวจ "เคยมี version" จาก DB ตาม requirement ขั้น 7 เดิมแล้ว) · หยุด dev server แล้วเปิด task pane (ticket ยังไม่หมด) → no-access "ขอ token ไม่สำเร็จ" (ยืนยันว่าต้อง online ตอนเปิดเอกสาร) หมายเหตุ: การขอ 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` ของสคริปต์ ## การรันกับ backend จริง (ปลด mock แล้ว 2026-10-27) -- **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) +- **Auth:** ไม่มี login — **ยกเลิก Keycloak ถาวร 2026-10-27** · auth ของ add-in คือ ticket ของ e-Court ผ่าน `signInWithTicket` (ticket_data + HmacSha256 ในไฟล์ — แผนเต็ม `docs/PLAN-ADDIN-AUTH.md` แก้ 2026-11-04): add-in **ไม่ตรวจ HMAC เอง** (secret เป็นของ server อย่างเดียว — PLAN-SSO.md D13) — pre-check ฟอร์แม็ต/exp ฝั่ง client แล้วยิงขอ access_token ที่ `/api/documents/token` **ทุกครั้งที่เปิดเอกสาร**: 200 = ได้ token ใหม่ (อายุ default 7 วัน) · 401 = ลายเซ็นไม่ถูก · 403 = ลายเซ็นถูกแต่ ticket หมดอายุ (มี JWT เดิมใน session ที่ยังไม่หมด = ใช้ต่อ) · โดน 401 กลาง flow = ลอง renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` (ticket ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court) - **Upload/download ของจริงเป็น default แล้ว:** `wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน `apiClient` (Bearer จาก ticket) — **ไม่ต้องตั้ง env อะไรเพิ่ม** เพราะ `apiClient.toUrl` ใช้ same-origin `withBasePath("/api/documents/…")` เมื่อ `NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง (API ของกลุ่ม docver อยู่แอปนี้เอง) · ตั้งค่า env นั้นเฉพาะวันที่ deploy add-in กับ API คนละ origin (ต้องรวม basePath เช่น `https://host/docedit` และเป็น HTTPS) - **เอกสารถูกระบุด้วย fid ใน token** — พารามิเตอร์ `documentId` (GUID ที่ stamp ในไฟล์) ของ uploadVersion/getVersions/getDownloadLink เหลือไว้ตาม signature เดิม ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims ของ token — ส่งใน form = 400) - **สิ่งที่ต้องรันฝั่ง server ก่อนใช้จริง:** MinIO (`docker compose up -d minio` — bucket `docver` app สร้างเอง) + env `DOCVER_API_KEY`/`DOCVER_TOKEN_SECRET`/`MINIO_*` ตาม `.env.example` ของกลุ่ม docver (docs/PLAN-DOCVER.md §6) · ทดสอบวงเต็ม: สร้างไฟล์ด้วย `make-ticket-docx.mjs` → เปิดใน Word → Save & Version → version ขึ้น MinIO จริง → กดดาวน์โหลด/PDF เปิด presigned URL ในเบราว์เซอร์ (โฮสต์ MinIO ต้องเข้าถึงได้จากเครื่องผู้ใช้) diff --git a/docs/addin-ui-flow.md b/docs/addin-ui-flow.md index 520bdfb..2162442 100644 --- a/docs/addin-ui-flow.md +++ b/docs/addin-ui-flow.md @@ -12,10 +12,10 @@ 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 ทางเข้าเดียวคือ **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`) +**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ **ticket ของ e-Court**: `detect()` อ่าน custom properties `ticket_data` + `HmacSha256` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInWithTicket()` — pre-check ฟอร์แม็ต/exp ฝั่ง client (UX gate) → ยิง `/api/documents/token` **ทุกครั้งที่เปิดเอกสาร** ให้ server ตรวจลายเซ็น (ไม่มี secret ฝั่ง client ตาม PLAN-SSO.md D13 — แก้ 2026-11-04) → แยกทางตาม status 200/400/401/403 → เก็บ access_token ใน localStorage ตาม fid (flow เต็ม: `docs/requirement/addin-workflow.md` + แผน `docs/PLAN-ADDIN-AUTH.md`) ``` -checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access +checking ──(ไม่มี ticket / ฟอร์แม็ตพัง / ลายเซ็นไม่ผ่าน(401) / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access │ ├─(ticket ผ่าน + ไม่ใช่ tracked doc)──→ untracked └─(ticket ผ่าน + tracked doc)────────→ tracking @@ -25,7 +25,7 @@ checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หม - `untracked` → (Start Versioning สำเร็จ) → `tracking` - ทุก phase → (ไม่มี ticket ตอน detect ใหม่ / 401 หลังพยายาม renew จาก ticket แล้ว = ticket หมดอายุไม่มีทางต่ออายุ) → `no-access` -- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) +- ปุ่ม "ลองใหม่" มีจริงแค่ 3 จุด: `bootError` ใน `checking`, history error ใน `VersionHistoryPanel` และ (เพิ่ม 2026-11-04) no-access เคส "อ่านข้อมูลเอกสารไม่สำเร็จ" — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) State ที่ UI ถือนอกเหนือจาก phase: @@ -36,7 +36,7 @@ State ที่ UI ถือนอกเหนือจาก phase: | `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) | | `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) | | `versions` + `historyLoading` + `historyError` | version history ของ `documentId` | -| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / HMAC ไม่ตรง = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) | +| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / ฟอร์แม็ตพัง / ลายเซ็นไม่ผ่าน = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) | | `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version | | `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน | | `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) | @@ -64,12 +64,17 @@ State ที่ UI ถือนอกเหนือจาก phase: ### `no-access` - ข้อความ: "เอกสารนี้ยังใช้งาน add-in ไม่ได้" -- คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ): +- คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ — ข้อความฉบับ 2026-11-04): - ไม่มี ticket ในไฟล์: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น" - - ticket รูปผิด / HMAC ไม่ตรง: "ตรวจสอบ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย" - - ticket หมดอายุ (ตอนเปิด หรือโดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" + - ฟอร์แม็ตพัง (parse ฝั่ง client หรือ server ตอบ 400): "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court" + - ลายเซ็นไม่ผ่าน (server 401): "ตรวจสอบลายเซ็น (HMAC) ของ ticket กับระบบไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court" + - ticket หมดอายุ (pre-check / server 403 และไม่มี JWT ที่ยังใช้ได้ใน session / โดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" - ขอ token ไม่สำเร็จ (server ลง/network): "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง" -- **ไม่มีปุ่มใด ๆ** — ไม่มี login ให้กด (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court แล้วเปิดใหม่เท่านั้น + - อ่านไฟล์ไม่ได้ (Word.run พัง เช่นเปิดใน browser ธรรมดา): "อ่านข้อมูลเอกสารไม่สำเร็จ — ลองกด \"ลองใหม่\" หรือปิดแล้วเปิด task pane อีกครั้ง" +- **ปุ่ม "ลองใหม่" (เพิ่ม 2026-11-04) แสดงเฉพาะเคสอ่านไฟล์ไม่ได้** (`noAccessRetryable`) — กดแล้วรัน + `detect()` ใหม่ (ช่วยเคส office.js มาช้า/Word สะดุดชั่วคราว) · เคสอื่นไม่มีปุ่ม — ไม่มี login ให้กด + (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court + แล้วเปิดใหม่ หรือรอเน็ตกลับมาเท่านั้น ### `untracked` @@ -167,7 +172,7 @@ Dialog เดียวใช้ 2 flow (ปรับข้อความด้ ### Detect (FR-1) — ตอนเปิด task pane / กดลองใหม่ 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` แล้วไปต่อ +2. ticket auth (`signInWithTicket` — PLAN-ADDIN-AUTH.md §5 ฉบับ 2026-11-04): อ่านไฟล์ไม่ได้ / ไม่มี ticket / ฟอร์แม็ตพัง (client หรือ server 400) / ลายเซ็นไม่ผ่าน (server 401) / ticket หมดอายุ (pre-check หรือ server 403 โดยไม่มี JWT ที่ยังใช้ได้ใน 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 — ไฟล์ร่างอยู่กลุ่มนี้) @@ -255,7 +260,7 @@ response: `{ url: string, expiresAt: string }` — UI เปิด `url` ใน ### Auth — e-Court ticket (ไม่มี login) -ยกเลิก 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) +ยกเลิก 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` — แก้ 2026-11-04): **client ไม่ตรวจ HMAC เอง** (ถอด secret ออกจาก bundle ตาม PLAN-SSO.md D13) — pre-check ฟอร์แม็ต + exp (หมดอายุและไม่มี JWT ของ fid ใน session = จบเลย) → **ยิง `POST /api/documents/token` ทุกครั้งที่เปิดเอกสาร** แบบ body `{ticket_data, HmacSha256}` ให้ server ตรวจลายเซ็นแล้วแยกตาม contract: 200 = ได้ token ใหม่ (user ใหม่ = เคลียร์ session เก่าทั้งก้อน) · 400 = ฟอร์แม็ตพัง · 401 = ลายเซ็นไม่ถูก (ไฟล์ถูกแก้) · 403 = ลายเซ็นถูกแต่ ticket หมดอายุ (มี JWT ของ fid ใน session ที่ยังไม่หมด = ใช้ต่อ) → เก็บ token ตาม fid ("คีย์คือ fid") อายุ 7 วัน (default ใหม่ของ `DOCVER_TOKEN_TTL_SECONDS`) · token หมดอายุกลางใช้งาน (โดน 401) = ลองขอใหม่จาก ticket 1 ครั้ง (ticket หมดแล้ว server ตอบ 403) ยังไม่ผ่าน = `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 เป็น ticket auth จริงครบ flow (`wordaddin/services/authService.ts` + `ticketService.ts` + ทางเข้า ticket ของ `/api/documents/token`) และ **API ทั้งสามข้างบนเป็นของจริงแล้ว** (`wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer — ปลด mock 2026-10-27; เหลือ mock เฉพาะโหมด `?addindemo`) · รายละเอียดการรันกับ backend: `docs/WORDADDIN-DEV.md` · สร้างไฟล์ทดสอบพร้อม ticket: `node scripts/make-ticket-docx.mjs` diff --git a/lib/addin-ticket.ts b/lib/addin-ticket.ts index 8ca876b..f0b9e57 100644 --- a/lib/addin-ticket.ts +++ b/lib/addin-ticket.ts @@ -33,10 +33,9 @@ export function addinTicketSecret(): string { } /** - * ตรวจ ticket แบบไม่สน exp (HMAC → parse) — ใช้เมื่อ route จะตัดสิน "ticket หมดอายุ - * แต่เอกสารนี้เคยทำงานโดย uid เดียวกัน" เองผ่าน DB (requirement ขั้น 7: กลับมาทำ - * เอกสารเดิมผ่านได้แม้ ticket หมดอายุ) — ตัวตนของผู้ถือยังยึด HMAC ของ e-Court - * เหมือนเดิม แค่ไม่ใช้อายุ ticket เป็นเกณฑ์ตัดสิทธิ์ ณ จุดนี้ + * ตรวจ ticket แบบไม่สน exp (HMAC → parse) — route เป็นคนตัดสิน exp เองภายหลัง + * (ลายเซ็นถูกแต่หมดอายุ = 403, contract 2026-11-04) — ตัวตนของผู้ถือยึด HMAC + * ของ e-Court เหมือนเดิม แค่ไม่ใช้อายุ ticket เป็นเกณฑ์ตัดสิทธิ์ ณ จุดนี้ */ export function verifyAddinTicketHmac( ticketData: string, @@ -89,7 +88,7 @@ export type TicketTokenBodyResult = /** * parse body ของ POST /api/documents/token ทางเข้า ticket - * - `missing` = ไม่มี ticket มาเลย (route ตอบ 401 — คำเดียวกับตอน verify ไม่ผ่าน) + * - `missing` = ไม่มี ticket มาเลย (route ตอบ 400 — ฟอร์แม็ตพัง, contract 2026-11-04) * - `invalid` = รูปผิด (route ตอบ 400 ตาม convention กัน typo เงียบ ๆ) */ export function parseTicketTokenBody(raw: unknown): TicketTokenBodyResult { diff --git a/lib/docver-store.ts b/lib/docver-store.ts index 11083cd..4069f51 100644 --- a/lib/docver-store.ts +++ b/lib/docver-store.ts @@ -252,22 +252,6 @@ export async function findLatestVersionForDoc( return row?.version ?? null; } -/** - * เอกสาร (docId) นี้เคยมี version ที่สร้างโดย userId นี้หรือยัง — ใช้เป็นหลักฐาน - * "เคยทำงานกับเอกสารนี้" ตอนยอมรับ ticket ที่หมดอายุ (requirement ขั้น 7: - * กลับมาทำเอกสารเดิมผ่านได้แม้ ticket หมดอายุ — ตัวตนยังยึด HMAC ของ e-Court - * ทางเข้า /api/documents/token ของ add-in) · อ่านอย่างเดียว ไม่แตะ MinIO - */ -export async function hasVersionByUser(docId: string, userId: string): Promise { - const [row] = await db - .select({ one: sql`1` }) - .from(documentVersions) - .innerJoin(documents, eq(documentVersions.documentId, documents.id)) - .where(and(eq(documents.docId, docId), eq(documentVersions.userId, userId))) - .limit(1); - return row !== undefined; -} - export type InsertVersionInput = { /** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */ versionId: string; diff --git a/lib/openapi.ts b/lib/openapi.ts index 50b8cfe..2c27077 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -694,16 +694,18 @@ export const openApiSpec = { description: "ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " + "token ฝัง claims `{userid, username, fid, template}` อายุตาม env " + - "`DOCVER_TOKEN_TTL_SECONDS` (default 3600 วินาที)\n\n" + + "`DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = 7 วัน — token อายุยาวแทน" + + "ระบบ refresh/renew ของ add-in)\n\n" + "**ทางเข้า 2 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body ได้ทั้งคู่):\n\n" + "1. **ระบบภายนอก (server-to-server)** — แนบ header `x-api-key` (ค่าจาก env " + "`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest`\n" + "2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " + "ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " + "ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " + - '(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง · ' + - "**ticket หมดอายุ** ยังออกให้ต่อเมื่อเอกสารนี้ (`fid`) เคยมี version ที่สร้างโดย `uid` เดียวกัน " + - "(ผู้ที่เคยทำงานกับเอกสารเดิมกลับมาใช้ต่อได้แม้ ticket หมดอายุ — ไม่เคยทำงาน = 401)\n\n" + + '(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง ' + + "(contract 2026-11-04: ฟอร์แม็ตพัง/ไม่ครบ = 400 · ลายเซ็นไม่ถูก = 401 · " + + "**ลายเซ็นถูกแต่ ticket หมดอายุ = 403** — add-in ที่มี access_token เดิมของ `fid` " + + "นี้ใน session ยังใช้ต่อได้ ไม่มี = ขอไฟล์ใหม่จาก e-Court)\n\n" + "ได้ token แล้วแนบเป็น `Authorization: Bearer ` กับทุก request ถัดไป", security: [{ ApiKeyAuth: [] }], requestBody: { @@ -753,7 +755,17 @@ export const openApiSpec = { 401: { description: "ทางเข้า x-api-key: ไม่ได้ส่ง header หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY) · " + - "ทางเข้า ticket: ticket ไม่ถูกต้อง/หมดอายุ หรือไม่ได้ส่งมา (ข้อความเดียวกันทุกสาเหตุ)", + "ทางเข้า ticket: ลายเซ็น HMAC ไม่ถูก (ข้อความเดียวกันทุกสาเหตุ)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + 403: { + description: + "เฉพาะทางเข้า ticket: ลายเซ็นถูกแต่ ticket หมดอายุ — ใช้ access_token เดิมของ " + + "fid นี้ใน session ต่อ หรือขอไฟล์ใหม่จาก e-Court (contract 2026-11-04)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, @@ -945,7 +957,7 @@ export const openApiSpec = { "- `type=pdf` → คืนไบต์ pdf ตรง ๆ (version ล่าสุดไม่มีสำเนา PDF = 404)\n" + "- `type=docx` → server เติม `exp` ใหม่ (now + `TICKET_SEC` default 300 วิ) + " + "คำนวณ `HmacSha256` (secret ตาม chain `ADDIN_TICKET_SECRET` → `SECRET` → " + - "`\"super_secret\"` ให้ตรงฝั่ง verify ของ add-in) และ `Ed25519` (เฉพาะเมื่อ " + + "`\"super_secret\"` ให้ตรงฝั่ง verify ของ server — ทาง ticket ของ /token) และ `Ed25519` (เฉพาะเมื่อ " + "`PRIVATE_KEY_HEX` ตั้ง) แล้วเขียน custom properties ลง**สำเนาในหน่วยความจำ** " + "ก่อนคืนไฟล์ — object ใน MinIO ไม่ถูกแต่ (non-destructive) และเขียนทับ stamp " + "เดิมของ template ได้\n" + @@ -1127,13 +1139,13 @@ export const openApiSpec = { }, TokenResponse: { type: "object", - description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)", + description: "access_token ของกลุ่ม documents (JWT HS256)", properties: { access_token: { type: "string", description: "JWT พร้อมใช้แนบเป็น Bearer" }, token_type: { type: "string", example: "Bearer" }, expires_in: { type: "integer", - description: "อายุวินาที (env DOCVER_TOKEN_TTL_SECONDS, default 3600)", + description: "อายุวินาที (env DOCVER_TOKEN_TTL_SECONDS, default 604800 = 7 วัน)", }, }, }, diff --git a/tests/app/api/documents/token.test.ts b/tests/app/api/documents/token.test.ts new file mode 100644 index 0000000..803a271 --- /dev/null +++ b/tests/app/api/documents/token.test.ts @@ -0,0 +1,215 @@ +// POST /api/documents/token — contract ทางเข้า ticket ฉบับ 2026-11-04 + regression ทางเข้า x-api-key +// route pure ล้วนหลังถอด hasVersionByUser (ไม่ import @/db/minio อีกต่อไป) จึง unit test +// ได้โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า route +// ไม่แตะ DB (เลิก "ตรวจเคยมี version" ของ requirement ขั้น 7 ฉบับ 2026-10-28) +import { createHmac } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { POST } from "@/app/api/documents/token/route"; + +/** secret ตาม chain ของ addinTicketSecret — ลบ env ทั้งสองตัวใน setup เพื่อใช้ค่า default */ +const SECRET_DEFAULT = "super_secret"; +const TOKEN_SECRET = "unit-test-token-secret"; +const API_KEY = "unit-test-api-key"; + +const SAVED_ENV: Record = {}; + +function mintTicket( + exp: number, + overrides?: { fid?: string; uid?: string; uname?: string; tid?: string }, +): { ticketData: string; ticketHmac: string } { + const ticket = { + exp, + fid: overrides?.fid ?? "F1", + ...(overrides?.tid !== undefined ? { tid: overrides.tid } : { tid: "T12" }), + uid: overrides?.uid ?? "oom", + uname: overrides?.uname ?? "สรวิชญ์", + }; + const ticketData = JSON.stringify(ticket); + const ticketHmac = createHmac("sha256", SECRET_DEFAULT).update(ticketData, "utf8").digest("hex"); + return { ticketData, ticketHmac }; +} + +function futureExp(): number { + return Math.floor(Date.now() / 1000) + 3_600; +} + +function makeRequest(body: BodyInit, headers?: Record): Request { + return new Request("http://localhost/docedit/api/documents/token", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body, + }); +} + +function ticketBody(ticketData: string, ticketHmac: string): string { + return JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }); +} + +/** decode JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ assert claims/exp เท่านั้น */ +function jwtPayload(token: string): Record { + const payload = token.split(".")[1]; + if (!payload) throw new Error("token ไม่มี payload"); + return JSON.parse( + Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"), + ) as Record; +} + +beforeEach(() => { + for (const key of [ + "ADDIN_TICKET_SECRET", + "SECRET", + "DOCVER_API_KEY", + "DOCVER_TOKEN_SECRET", + "DOCVER_TOKEN_TTL_SECONDS", + ]) { + SAVED_ENV[key] = process.env[key]; + } + process.env.DOCVER_TOKEN_SECRET = TOKEN_SECRET; + delete process.env.ADDIN_TICKET_SECRET; // ใช้ default "super_secret" ของ addinTicketSecret + delete process.env.SECRET; + delete process.env.DOCVER_TOKEN_TTL_SECONDS; +}); + +afterEach(() => { + for (const [key, value] of Object.entries(SAVED_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +describe("POST /api/documents/token — ทางเข้า ticket (contract 2026-11-04)", () => { + it("ticket ถูกทั้งลายเซ็น+อายุ → 200 + JWT claims จาก ticket + expires_in 604800 (default 7 วัน)", async () => { + const { ticketData, ticketHmac } = mintTicket(futureExp(), { fid: "F9", uid: "u1", uname: "นายทดสอบ" }); + const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); + + expect(response.status).toBe(200); + const data = (await response.json()) as { access_token: string; token_type: string; expires_in: number }; + expect(data.token_type).toBe("Bearer"); + expect(data.expires_in).toBe(604_800); + + const payload = jwtPayload(data.access_token); + expect(payload.userid).toBe("u1"); + expect(payload.username).toBe("นายทดสอบ"); + expect(payload.fid).toBe("F9"); + expect(payload.template).toBe("T12"); + expect(payload.purpose).toBe("docver"); + expect((payload.exp as number) - (payload.iat as number)).toBe(604_800); + }); + + it("body ไม่ใช่ JSON → 400", async () => { + const response = await POST(makeRequest("not-json{")); + expect(response.status).toBe(400); + }); + + it("ไม่ส่ง ticket มาเลย / ค่าว่าง → 400 (ย้ายจาก 401 ตาม contract ฟอร์แม็ตพัง)", async () => { + for (const body of ["{}", JSON.stringify({ ticket_data: "" , HmacSha256: "ab".repeat(32) })]) { + const response = await POST(makeRequest(body)); + expect(response.status, body).toBe(400); + } + }); + + it("field นอก allowlist / ผิดชนิด / ยาวเกิน cap → 400", async () => { + for (const body of [ + JSON.stringify({ ticket_data: "x", HmacSha256: "y", oops: 1 }), + JSON.stringify({ ticket_data: 42, HmacSha256: "y" }), + JSON.stringify({ ticket_data: "x".repeat(2049), HmacSha256: "y" }), + ]) { + const response = await POST(makeRequest(body)); + expect(response.status, body).toBe(400); + } + }); + + it("ลายเซ็นผิด (แก้ ticket_data / digest เพี้ยน / secret ของ server ต่าง) → 401 ข้อความเดียวกันทุกเคส", async () => { + const { ticketData, ticketHmac } = mintTicket(futureExp()); + const tampered = ticketData.replace("F1", "F2"); // แก้ 1 ตัว — HMAC ไม่ตรง + + const cases: { label: string; body: string; secretEnv?: [string, string] }[] = [ + { label: "แก้ ticket_data", body: ticketBody(tampered, ticketHmac) }, + { label: "digest ไม่ใช่ hex", body: ticketBody(ticketData, "zz".repeat(32)) }, + { + label: "secret ฝั่ง server ต่าง", + body: ticketBody(ticketData, ticketHmac), + secretEnv: ["ADDIN_TICKET_SECRET", "different-secret"], + }, + ]; + const messages = new Set(); + for (const { label, body, secretEnv } of cases) { + if (secretEnv) process.env[secretEnv[0]] = secretEnv[1]; + const response = await POST(makeRequest(body)); + expect(response.status, label).toBe(401); + messages.add((await response.json()).error); + if (secretEnv) delete process.env[secretEnv[0]]; + } + expect(messages.size).toBe(1); // ข้อความเดียว กัน oracle + expect(Array.from(messages)[0]).toBe("ticket ไม่ถูกต้อง"); + }); + + it("ลายเซ็นถูกแต่ exp อดีต → 403 \"ticket ของ e-Court หมดอายุ\" (ไม่ตรวจ DB — route ไม่มี DB)", async () => { + const { ticketData, ticketHmac } = mintTicket(Math.floor(Date.now() / 1000) - 60); + const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); + + expect(response.status).toBe(403); + expect((await response.json()).error).toBe("ticket ของ e-Court หมดอายุ"); + }); + + it("HMAC ถูกแต่ ticket_data ผิดกติกา (field หลุด allowlist) → 401", async () => { + const invalid = JSON.stringify({ exp: futureExp(), fid: "f", uid: "u", uname: "n", oops: 1 }); + const hmac = createHmac("sha256", SECRET_DEFAULT).update(invalid, "utf8").digest("hex"); + const response = await POST(makeRequest(ticketBody(invalid, hmac))); + expect(response.status).toBe(401); + }); + + it("DOCVER_TOKEN_SECRET ไม่ตั้ง → 500 (ความผิด config ฝั่ง server ไม่ใช่ของผู้เรียก)", async () => { + delete process.env.DOCVER_TOKEN_SECRET; + const { ticketData, ticketHmac } = mintTicket(futureExp()); + const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); + expect(response.status).toBe(500); + }); + + it("DOCVER_TOKEN_TTL_SECONDS=120 → expires_in 120 (env override default)", async () => { + process.env.DOCVER_TOKEN_TTL_SECONDS = "120"; + const { ticketData, ticketHmac } = mintTicket(futureExp()); + const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); + expect(response.status).toBe(200); + expect(((await response.json()) as { expires_in: number }).expires_in).toBe(120); + }); +}); + +describe("POST /api/documents/token — ทางเข้า x-api-key (regression ไม่เปลี่ยน)", () => { + it("key ถูก + body claims → 200 ได้ token อายุ default ใหม่ 604800", async () => { + process.env.DOCVER_API_KEY = API_KEY; + const response = await POST( + makeRequest(JSON.stringify({ userid: "u1", username: "นายทดสอบ", fid: "F1" }), { + "x-api-key": API_KEY, + }), + ); + expect(response.status).toBe(200); + const data = (await response.json()) as { access_token: string; expires_in: number }; + expect(data.expires_in).toBe(604_800); + const payload = jwtPayload(data.access_token); + expect(payload.userid).toBe("u1"); + expect(payload.fid).toBe("F1"); + expect(payload.purpose).toBe("docver"); + }); + + it("key ผิด → 401", async () => { + process.env.DOCVER_API_KEY = API_KEY; + const response = await POST( + makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), { + "x-api-key": "wrong-key", + }), + ); + expect(response.status).toBe(401); + }); + + it("มี header x-api-key แต่ env ไม่ตั้ง → 401 fail closed", async () => { + delete process.env.DOCVER_API_KEY; + const response = await POST( + makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), { + "x-api-key": API_KEY, + }), + ); + expect(response.status).toBe(401); + }); +}); diff --git a/tests/lib/addin-ticket.test.ts b/tests/lib/addin-ticket.test.ts index d5003ff..9986e89 100644 --- a/tests/lib/addin-ticket.test.ts +++ b/tests/lib/addin-ticket.test.ts @@ -75,8 +75,7 @@ describe("verifyAddinTicket", () => { }); // verifyAddinTicketHmac = ตัวเดียวกันแต่ไม่ตรวจ exp — route ใช้ตรวจ HMAC ก่อน -// แล้วตัดสิน "ticket หมดอายุ + เอกสารนี้เคยทำงานโดย uid เดียวกัน" จาก DB เอง -// (requirement ขั้น 7 — แก้บั๊ก 2026-10-28) +// แล้วตัดสิน exp เองภายหลัง (ลายเซ็นถูกแต่หมดอายุ = 403 — contract 2026-11-04) describe("verifyAddinTicketHmac", () => { it("exp อดีต + HMAC ถูก = ผ่าน (ได้ ticket ครบ) — ต่างจาก verifyAddinTicket ที่ไม่ผ่าน", () => { const expired = JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }); @@ -122,7 +121,7 @@ describe("parseTicketTokenBody", () => { } }); - it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 401 กัน oracle)", () => { + it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 400 — ฟอร์แม็ตพัง, contract 2026-11-04)", () => { 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); diff --git a/tests/utils/docver-ticket.test.ts b/tests/utils/docver-ticket.test.ts index 7d09612..3d1bb78 100644 --- a/tests/utils/docver-ticket.test.ts +++ b/tests/utils/docver-ticket.test.ts @@ -5,7 +5,6 @@ import { normalizeHexDigest, parseDocverTicket, parseTicketDataParam, - timingSafeEqualHex, } from "@/utils/docver-ticket"; // PLAN-ADDIN-AUTH.md §4 — pure ล้วนไม่แตะ env/DB อะไรเลย @@ -116,15 +115,6 @@ describe("normalizeHexDigest", () => { }); }); -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); - }); -}); - // parseTicketDataParam — query param `ticket_data` ของ GET /api/documents/latest: // ticket ก่อนเติม exp (server เติมตอน stamp) แต่บังคับ fid/uid/uname เพราะ // parseDocverTicket บังคับทั้งสามตอน add-in redeem — ขาด = ไฟล์ที่คืนไปเปิดไม่ได้ diff --git a/tests/wordaddin/ticket-auth-flow.test.ts b/tests/wordaddin/ticket-auth-flow.test.ts index 0ab6938..5cea20c 100644 --- a/tests/wordaddin/ticket-auth-flow.test.ts +++ b/tests/wordaddin/ticket-auth-flow.test.ts @@ -1,11 +1,11 @@ // @vitest-environment jsdom -// ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5) — คุมพฤติกรรม service layer: -// happy-path ขอ token ครั้งเดียวต่อ fid, เปิดเอกสารเดิมซ้ำใช้ token เดิมไม่ยิง /token ซ้ำ, -// memory fallback ของ session, ทุก fail path ต้องคืน result ไม่ใช่ throw และ -// ticket หมดอายุ + เอกสารที่เคยทำงาน (requirement ขั้น 7 — บั๊ก 2026-10-28: -// client ยิงขอ token เสมอ server เป็นคนตัดสินเรื่อง "เคยทำงาน" จาก DB) -// (jsdom ไม่มี crypto.subtle — ฉีด webcrypto ของ node แทน secure context ของ webview จริง) -import { createHmac, webcrypto } from "node:crypto"; +// ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5 — contract 2026-11-04) — คุมพฤติกรรม +// service layer: pre-check ฝั่ง client (missing/ฟอร์แม็ต/หมดอายุ+ไม่มี session ไม่ยิง API), +// ยิง /api/documents/token ทุกครั้งที่เปิดเอกสารแล้วแยกทางตาม status (200/400/401/403), +// memory fallback ของ session, ทุก fail path ต้องคืน result ไม่ใช่ throw +// (ไม่มีการตรวจ HMAC ฝั่ง client อีกต่อไป — PLAN-SSO.md D13: secret เป็นของ server +// อย่างเดียว "ไฟล์ถูกแก้" ตรวจจาก 400/401 ของ server) +import { createHmac } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as authService from "@/wordaddin/services/authService"; @@ -18,110 +18,25 @@ function trapErrors() { window.addEventListener("unhandledrejection", (event) => errors.push(event.reason)); } -function makeTicket(expSec?: number): { ticketData: string; ticketHmac: string } { +const FID = "DBG_FID_001"; + +function makeTicket(overrides?: { exp?: number; fid?: string; uid?: string; uname?: string }): { + ticketData: string; + ticketHmac: string; +} { const ticket = { - exp: expSec ?? Math.floor(Date.now() / 1000) + 300, - fid: "DBG_FID_001", + exp: overrides?.exp ?? Math.floor(Date.now() / 1000) + 300, + fid: overrides?.fid ?? FID, tid: "T12", - uid: "oom", - uname: "สรวิชญ์", + uid: overrides?.uid ?? "oom", + uname: overrides?.uname ?? "สรวิชญ์", }; const ticketData = JSON.stringify(ticket); + // HMAC ยัง mint จริงตามตัว stamp (server ใน test อื่นจะเทียบค่านี้ — ฝั่ง client ไม่แตะแล้ว) 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("signInWithTicket (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([]); - }); -}); - -// ─── ticket หมดอายุ + เอกสารที่เคยทำงาน (requirement ขั้น 7 — บั๊ก 2026-10-28) ── -// เกณฑ์: uid ตรงกัน + เคยสร้าง token ของ fid เดิม → ไม่ต้องสน expire ของ ticket -// ฝั่ง client ยิงขอ token เสมอแล้วให้ server ตัดสิน (200 = เคยทำงาน / 401 = ไม่เคย) -const FID = "DBG_FID_001"; - function tokenResponse(token: string): Response { return new Response( JSON.stringify({ @@ -133,95 +48,323 @@ function tokenResponse(token: string): Response { ); } -function seedSession(token: string, tokenExpSec: number): void { +function errorResponse(status: number): Response { + return new Response(JSON.stringify({ error: "stub" }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function seedSession(token: string, tokenExpSec: number, userId = "oom"): void { ticketService.saveSession({ - userId: "oom", - userName: "สรวิชญ์", + userId, + userName: "ชื่อเก่า", tokens: { [FID]: { token, exp: tokenExpSec } }, }); } -describe("ticket หมดอายุ + เอกสารที่เคยทำงาน", () => { - it("session เดิม + token ยังไม่หมด → ผ่านเลย ไม่ยิง /token", async () => { +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("signInWithTicket — pre-check ฝั่ง client (เตะโดยไม่ยิง API)", () => { + it("ไม่มี ticket / ยาวเกิน 255 → reason missing", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - seedSession("cached-token", Math.floor(Date.now() / 1000) + 1800); + const { ticketData, ticketHmac } = makeTicket(); - const { ticketData, ticketHmac } = makeTicket(1); // ticket หมดอายุตั้งแต่ 1970 - const result = await authService.signInWithTicket({ ticketData, ticketHmac }); - - expect(result.ok, JSON.stringify(result)).toBe(true); + const bad = [ + { ticketData: undefined, ticketHmac: undefined }, + { ticketData: "", ticketHmac }, + { ticketData: "x".repeat(256), ticketHmac }, + { ticketData, ticketHmac: "x".repeat(256) }, + ] 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(result.reason).toBe("missing"); + } expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); - it("session เดิม + token หมดด้วย → ยิงขอใหม่ (server ตัดสิน) ได้ token ใหม่", async () => { + it("parse ฟอร์แม็ตไม่ผ่าน → reason tampered ไม่ยิง fetch", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); - const fetchMock = vi.fn(async () => tokenResponse("token-fresh")); + const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - seedSession("token-stale", Math.floor(Date.now() / 1000) - 60); - const { ticketData, ticketHmac } = makeTicket(1); - const result = await authService.signInWithTicket({ ticketData, ticketHmac }); - - expect(result.ok, JSON.stringify(result)).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(1); - const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); - expect(stored.tokens[FID].token).toBe("token-fresh"); + const result = await authService.signInWithTicket({ ticketData: "not-json", ticketHmac: "aa".repeat(32) }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("tampered"); + expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); - it("ไม่มี session (localStorage หาย) + server 401 (ยังไม่เคยทำงาน) → expired ไม่ใช่ crash", async () => { + it("ticket หมดอายุ + ไม่มี session → reason expired ไม่ยิง fetch", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); - vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 }))); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); - const { ticketData, ticketHmac } = makeTicket(1); + const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("expired"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(errors).toEqual([]); + }); + it("ticket หมดอายุ + session เป็น uid คนอื่น → reason expired ไม่ยิง fetch", async () => { + trapErrors(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + seedSession("other-token", Math.floor(Date.now() / 1000) + 1800, "someone-else"); + + const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("expired"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(errors).toEqual([]); + }); + + it("ticket หมดอายุ + uid ตรงแต่ไม่มี entry ของ fid นี้ → reason expired ไม่ยิง fetch", async () => { + trapErrors(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + // session ของ oom แต่เก็บ token ของ fid อื่น + ticketService.saveSession({ + userId: "oom", + userName: "สรวิชญ์", + tokens: { OTHER_FID: { token: "other", exp: Math.floor(Date.now() / 1000) + 1800 } }, + }); + + const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("expired"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(errors).toEqual([]); + }); +}); + +describe("signInWithTicket — ยิง /token ทุกครั้งที่เปิดเอกสาร", () => { + it("happy-path: ไม่มี session + 200 → ok และสร้าง session ใหม่ครบ userId/userName/tokens[fid]", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => tokenResponse("token-1")); + vi.stubGlobal("fetch", fetchMock); + + const { ticketData, ticketHmac } = makeTicket(); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.userId).toBe("oom"); + expect(stored.userName).toBe("สรวิชญ์"); + expect(stored.tokens[FID].token).toBe("token-1"); + expect(errors).toEqual([]); + }); + + it("เปิดเอกสารเดิมซ้ำ → ยิง /token อีกครั้ง (ตรวจลายเซ็นที่ server ทุกครั้ง) แล้วแทน entry ด้วย token ใหม่", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => tokenResponse("token-2")); + vi.stubGlobal("fetch", fetchMock); + + const { ticketData, ticketHmac } = makeTicket(); + await authService.signInWithTicket({ ticketData, ticketHmac }); + await authService.signInWithTicket({ ticketData, ticketHmac }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.tokens[FID].token).toBe("token-2"); + expect(errors).toEqual([]); + }); + + it("user ใหม่ (session uid ต่าง) + 200 → เคลียร์ session เก่าทั้งก้อนแล้วสร้างใหม่", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => tokenResponse("token-new-user")); + vi.stubGlobal("fetch", fetchMock); + seedSession("old-user-token", Math.floor(Date.now() / 1000) + 1800, "someone-else"); + + const { ticketData, ticketHmac } = makeTicket(); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok, JSON.stringify(result)).toBe(true); + + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.userId).toBe("oom"); + expect(stored.tokens[FID].token).toBe("token-new-user"); + expect(JSON.stringify(stored)).not.toContain("old-user-token"); // ของเดิมถูกเคลียร์ทิ้งจริง + expect(errors).toEqual([]); + }); + + it("คนเดิมแต่ uname เปลี่ยน → session.userName อัปเดตตามไฟล์ล่าสุด", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => tokenResponse("token-keep")); + vi.stubGlobal("fetch", fetchMock); + + await authService.signInWithTicket(makeTicket({ uname: "ชื่อเดิม" })); + await authService.signInWithTicket(makeTicket({ uname: "ชื่อใหม่" })); + + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.userName).toBe("ชื่อใหม่"); + expect(errors).toEqual([]); + }); +}); + +describe("signInWithTicket — แยกทางตาม status ของ /token (contract 2026-11-04)", () => { + it("400 (ฟอร์แม็ตพัง) → reason tampered", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => errorResponse(400)); + vi.stubGlobal("fetch", fetchMock); + + const result = await authService.signInWithTicket(makeTicket()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("tampered"); + expect(errors).toEqual([]); + }); + + it("401 (ลายเซ็นไม่ถูก) → reason tampered", async () => { + trapErrors(); + vi.stubGlobal("fetch", vi.fn(async () => errorResponse(401))); + + const result = await authService.signInWithTicket(makeTicket()); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.reason).toBe("expired"); - expect(typeof result.message).toBe("string"); + expect(result.reason).toBe("tampered"); + expect(result.message).toContain("ลายเซ็น"); } expect(errors).toEqual([]); }); - it("ไม่มี session + เคยทำงาน (server 200) → สร้าง session ใหม่ได้แม้ ticket หมดอายุ", async () => { + it("403 (ลายเซ็นถูกแต่หมดอายุ) + มี JWT ของ fid ใน session ที่ยังไม่หมด → ใช้ entry เดิมต่อ ไม่แทนที่", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); - const fetchMock = vi.fn(async () => tokenResponse("token-rebuilt")); + const fetchMock = vi.fn(async () => errorResponse(403)); vi.stubGlobal("fetch", fetchMock); + seedSession("cached-token", Math.floor(Date.now() / 1000) + 1800); - const { ticketData, ticketHmac } = makeTicket(1); + const { ticketData, ticketHmac } = makeTicket({ exp: 1, uname: "ชื่อใหม่" }); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); - expect(result.ok, JSON.stringify(result)).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); - expect(stored.userId).toBe("oom"); - expect(stored.tokens[FID].token).toBe("token-rebuilt"); + expect(stored.tokens[FID].token).toBe("cached-token"); // ไม่ถูกแทนที่ + expect(stored.userName).toBe("ชื่อใหม่"); // refresh ชื่อตามไฟล์ล่าสุด expect(errors).toEqual([]); }); - it("renewTokenFromTicket กับ ticket หมดอายุ + server 200 → ยังยิงขอ (server ตัดสิน) คืน true", async () => { + it("403 + entry ของ fid หมดอายุแล้ว → reason expired", async () => { + trapErrors(); + const fetchMock = vi.fn(async () => errorResponse(403)); + vi.stubGlobal("fetch", fetchMock); + seedSession("token-stale", Math.floor(Date.now() / 1000) - 60); + + const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); + const result = await authService.signInWithTicket({ ticketData, ticketHmac }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("expired"); + expect(fetchMock).toHaveBeenCalledTimes(1); // pre-check ปล่อยผ่าน (มี entry) แล้ว server ตอบ 403 + expect(errors).toEqual([]); + }); + + it("500 / network / 200 แต่ body เสีย → reason token-request-failed (ไม่ใช่ความผิดของไฟล์)", async () => { + trapErrors(); + const cases: { label: string; mock: () => unknown }[] = [ + { label: "500", mock: () => vi.fn(async () => errorResponse(500)) }, + { label: "network", mock: () => vi.fn(async () => Promise.reject(new Error("offline"))) }, + { + label: "malformed", + mock: () => + vi.fn(async () => new Response(JSON.stringify({ nope: true }), { status: 200, headers: { "content-type": "application/json" } })), + }, + ]; + for (const { label, mock } of cases) { + window.localStorage.clear(); + ticketService.clearSession(); + vi.stubGlobal("fetch", mock()); + + const result = await authService.signInWithTicket(makeTicket()); + expect(result.ok, label).toBe(false); + if (!result.ok) expect(result.reason, label).toBe("token-request-failed"); + expect(errors, label).toEqual([]); + } + }); +}); + +describe("signInWithTicket — resilience", () => { + it("localStorage โดน block (webview/private mode) → ผ่านด้วย memory fallback ไม่ throw", async () => { + trapErrors(); + const blocked = () => { + throw new DOMException("blocked", "SecurityError"); + }; + vi.spyOn(Storage.prototype, "getItem").mockImplementation(blocked); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(blocked); + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(blocked); + vi.stubGlobal("fetch", vi.fn(async () => tokenResponse("token-memory"))); + + const result = await authService.signInWithTicket(makeTicket()); + expect(result.ok, JSON.stringify(result)).toBe(true); + // token ใช้ได้ภายใน task pane instance นี้ผ่าน memory fallback + expect(await authService.getAccessToken()).toBe("token-memory"); + expect(errors).toEqual([]); + }); +}); + +describe("renewTokenFromTicket", () => { + it("200 → true และ session ได้ token ใหม่", async () => { trapErrors(); - vi.stubGlobal("crypto", webcrypto); const fetchMock = vi.fn(async () => tokenResponse("token-renewed")); vi.stubGlobal("fetch", fetchMock); - // sign-in ครั้งแรกด้วย ticket หมดอายุผ่านทาง server 200 → currentTicket เป็นตัวหมดอายุ - const { ticketData, ticketHmac } = makeTicket(1); - const signIn = await authService.signInWithTicket({ ticketData, ticketHmac }); + const signIn = await authService.signInWithTicket(makeTicket()); expect(signIn.ok, JSON.stringify(signIn)).toBe(true); fetchMock.mockClear(); const renewed = await authService.renewTokenFromTicket(); expect(renewed).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); + const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); + expect(stored.tokens[FID].token).toBe("token-renewed"); + expect(errors).toEqual([]); + }); + + it("ticket หมดอายุ (server ตอบ 403) / 401 / network → false ไม่ throw", async () => { + trapErrors(); + const cases: { label: string; mock: () => unknown }[] = [ + { label: "403", mock: () => vi.fn(async () => errorResponse(403)) }, + { label: "401", mock: () => vi.fn(async () => errorResponse(401)) }, + { label: "network", mock: () => vi.fn(async () => Promise.reject(new Error("offline"))) }, + ]; + for (const { label, mock } of cases) { + window.localStorage.clear(); + ticketService.clearSession(); + authService.signInForDemo(); + vi.stubGlobal("fetch", vi.fn(async () => tokenResponse("seed-token"))); + const signIn = await authService.signInWithTicket(makeTicket()); + expect(signIn.ok, label).toBe(true); + vi.stubGlobal("fetch", mock()); + + const renewed = await authService.renewTokenFromTicket(); + expect(renewed, label).toBe(false); + expect(errors, label).toEqual([]); + } + }); + + it("ยังไม่ได้ sign in (ไม่มี currentTicket) → false ไม่ยิง fetch", async () => { + trapErrors(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + // authService.signInForDemo() ใน afterEach/reset ตั้ง currentTicket = null อยู่แล้ว + + expect(await authService.renewTokenFromTicket()).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); }); diff --git a/tests/wordaddin/versioningService.test.ts b/tests/wordaddin/versioningService.test.ts index a61ce94..4c146e8 100644 --- a/tests/wordaddin/versioningService.test.ts +++ b/tests/wordaddin/versioningService.test.ts @@ -5,7 +5,7 @@ // templateId — identity มาจาก Bearer หมด · และห้ามส่ง fields — server แกะจากไฟล์เอง // 2026-10-30), getVersions แปลง DTO, getDownloadLink ใส่ ?format= // - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย) -import { createHmac, webcrypto } from "node:crypto"; +import { createHmac } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as authService from "@/wordaddin/services/authService"; @@ -20,7 +20,6 @@ const TOKEN = "header.payload.sig"; // ค่าหยง ๆ ก็ได้ /** sign-in จริงผ่าน signInWithTicket (fetch ตอบ token 1 ครั้ง) — real mode + มี Bearer ใน session */ async function signInReal(): Promise { - vi.stubGlobal("crypto", webcrypto); // jsdom ไม่มี crypto.subtle const ticket = { exp: Math.floor(Date.now() / 1000) + 300, fid: "REAL_FID_001", diff --git a/utils/docver-ticket.ts b/utils/docver-ticket.ts index 696f96f..dcb1c43 100644 --- a/utils/docver-ticket.ts +++ b/utils/docver-ticket.ts @@ -1,7 +1,9 @@ // 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) +// และฝั่ง client ของ add-in (wordaddin/services/authService.ts — ใช้แค่ +// parse + exp เป็น UX gate การตรวจ HMAC เป็นของ server อย่างเดียวตาม +// PLAN-SSO.md D13, 2026-11-04) // // กติกา (docs/requirement/addin-workflow.md + lib/lib-utils.ts generateHmacSha256 // ของตัว stamp ฝั่ง template): @@ -10,8 +12,7 @@ // - HmacSha256 เป็น hex lowercase 64 ตัว (การเทียบ normalize เป็น lowercase // ให้ก่อน — ฝั่ง stamp ตัวพิมพ์ใหญ่ก็ผ่านได้) // - ห้าม import อะไรที่เป็น platform-specific (node:crypto/crypto.subtle) — -// module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word โดยส่ง -// ตัวคำนวณ HMAC เข้ามาทาง parameter แทน +// module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word /** ticket ที่แกะจาก ticket_data — ชื่อ field ตามที่ e-Court กำหนด (requirement) */ export interface DocverTicket { @@ -216,25 +217,9 @@ export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.no /** * normalize ค่า digest ที่ได้รับ (property `HmacSha256` / body field) — * trim + lowercase แล้วตรวจว่าเป็น hex ครบ 64 ตัว คืน null เมื่อรูปไม่ใช่ + * (ใช้ฝั่ง server ตอนเทียบ HMAC — lib/addin-ticket.ts) */ 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 646079e..6c8e663 100644 --- a/wordaddin/config.ts +++ b/wordaddin/config.ts @@ -11,10 +11,6 @@ export const config = { 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", + // (ไม่มี secret ฝั่ง client อีกต่อไป — การตรวจ HMAC ของ ticket เป็นของ server อย่างเดียว + // ตาม PLAN-SSO.md D13, ถอด NEXT_PUBLIC_ADDIN_TICKET_SECRET ออก 2026-11-04) }; diff --git a/wordaddin/services/apiClient.ts b/wordaddin/services/apiClient.ts index 0b4476e..9640483 100644 --- a/wordaddin/services/apiClient.ts +++ b/wordaddin/services/apiClient.ts @@ -64,9 +64,9 @@ async function doFetch( /** * request พร้อม Bearer — โดน 401 → ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน - * (renewTokenFromTicket — ticket ยังไม่หมดอายุเท่านั้น) แล้ว retry 1 ครั้ง - * ยัง 401 อีก / renew ไม่ได้ = AuthRequiredError (UI พาไป no-access ให้ขอไฟล์ - * ใหม่จาก e-Court) · Response ที่ได้คืนไปให้ caller เช็ค ok เอง + * (renewTokenFromTicket — server เป็นคนตัดสิน: 200 = ออกให้ใหม่ · 403 = ticket + * หมดอายุ → false) แล้ว 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(); diff --git a/wordaddin/services/authService.ts b/wordaddin/services/authService.ts index 0c22b6a..9ce5fed 100644 --- a/wordaddin/services/authService.ts +++ b/wordaddin/services/authService.ts @@ -1,21 +1,21 @@ /** * Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม - * docs/requirement/addin-workflow.md) + * docs/requirement/addin-workflow.md — ปรับ flow 2026-11-04) * * ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data` * + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ - * HMAC → session ใน localStorage ตาม uid/fid → ขอ access_token จาก - * /api/documents/token เก็บตาม fid ("คีย์คือ fid") · exp ของ ticket ไม่ใช่ - * เกณฑ์ตัดสิทธิ์ฝั่ง client — server ออก token ให้แม้ ticket หมดอายุเมื่อ - * เอกสารนี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7) + * ฟอร์แม็ต/exp เป็น UX gate ฝั่ง client (ไม่มี secret ใน bundle อีกต่อไปตาม + * PLAN-SSO.md D13 — การตรวจ HMAC เป็นของ server อย่างเดียว ยิง /api/documents/token + * ทุกครั้งที่เปิดเอกสาร) → แยกทางตาม contract: 200 = ได้ JWT ใหม่ / 400 ฟอร์แม็ตพัง / + * 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ (มี JWT ของ fid ใน session + * ที่ยังไม่หมด = ใช้ต่อ ไม่มี = ขอไฟล์ใหม่จาก e-Court) * - * token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่ - * ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew - * ตอน token หมดอายุกลาง session + * token เก็บใน localStorage ตาม fid ("คีย์คือ fid") — อายุ 7 วัน แทนระบบ refresh + * ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew ตอนโดน 401 กลาง session */ import { config } from "../config"; -import { isTicketExpired, parseDocverTicket } from "@/utils/docver-ticket"; +import { isTicketExpired, parseDocverTicket, type DocverTicket } from "@/utils/docver-ticket"; import * as ticketService from "./ticketService"; /** สาเหตุที่เข้าไม่ได้ — UI แยกข้อความตาม reason (TaskpaneApp) */ @@ -46,13 +46,16 @@ export function isDemoMode(): boolean { } /** - * e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7): - * 1. ตรวจมี ticket_data + HmacSha256 ครบ - * 2. parse + ตรวจ HMAC ฝั่ง client (UX gate) - * 3. session ใน localStorage ตาม uid/fid — token ยังใช้ได้ = ผ่านเลย · ต้องขอ - * token ใหม่ = ยิงขอเสมอแล้วให้ **server เป็นคนตัดสิน** (ticket หมดอายุแต่ - * เอกสารนี้เคยมี version โดย uid เดียวกัน = ออกให้ต่อ — requirement ขั้น 7 - * กลับมาทำเอกสารเดิมได้แม้ ticket หมดอายุ แม้ session หายไปจากเครื่อง) + * e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (flow 2026-11-04): + * 1. ตรวจมี ticket_data + HmacSha256 ครบ (client) — ไม่ครบ = เตะเลย + * 2. parse ฟอร์แม็ต (client UX gate) — พัง = เตะเลย + * 3. pre-check: ticket หมดอายุ และ ไม่มี JWT ของ fid นี้ใน session (uid ตรงกัน) + * = เตะเลย ไม่ยิง API (มีใน session = มีโอกาสเป็นไฟล์เก่าที่เคยแก้ → ไปให้ + * server ตัดสินที่ขั้นถัดไป) + * 4. ที่เหลือยิง /api/documents/token **ทุกครั้ง** ให้ server ตรวจลายเซ็น (HMAC) + * แล้วแยกทางตาม contract: 200 ได้ JWT ใหม่ (user ต่าง = เคลียร์ session ทั้งก้อน + * สร้างใหม่) · 403 ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT ของ fid ที่ยังไม่หมด + * ใน session ก็ใช้ต่อ · 400/401 ฟอร์แม็ต/ลายเซ็นพัง = เตะ (ไฟล์ถูกแก้) */ export async function signInWithTicket(input: { ticketData?: string; @@ -74,14 +77,7 @@ export async function signInWithTicket(input: { return { ok: false, reason: "tampered", - message: "ตรวจสอบ ticket ไม่ผ่าน (รูปแบบไม่ถูกต้อง) — ไฟล์ถูกแก้ไขหรือเสียหาย", - }; - } - if (!(await ticketService.verifyTicketHmacWeb(ticketData, ticketHmac, config.ticketSecret))) { - return { - ok: false, - reason: "tampered", - message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไข", + message: "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court", }; } const ticket = parsed.ticket; @@ -91,56 +87,68 @@ export async function signInWithTicket(input: { message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", }; + // snapshot session ก่อนยิง — "มีใน session" = ยูสเซอร์ + ไฟล์ (uid+fid) ตรงกับ ticket const session = ticketService.loadSession(); - if (session === null || session.userId !== ticket.uid) { - // ไม่มี session / user ใหม่ — ขอ token ใหม่เสมอ (แม้ ticket หมดอายุ: server - // ออกให้ต่อเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน ซึ่งช่วยเคส localStorage หาย - // ของคนที่กลับมาทำเอกสารเดิมด้วย) แล้วเริ่ม session ใหม่ทั้งก้อน - let entry: ticketService.SessionTokenEntry; - try { - entry = await ticketService.requestDocverToken(ticketData, ticketHmac); - } catch { - return isTicketExpired(ticket.exp) - ? expiredMessage - : { - ok: false, - reason: "token-request-failed", - message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", - }; - } - ticketService.clearSession(); - 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 เสมอแล้วให้ server ตัดสิน (ticket ยังไม่หมด = - // ออกให้เสมอ · หมดแล้ว = ออกเฉพาะเอกสารนี้เคยทำงานโดย uid เดียวกัน) - try { - entry = await ticketService.requestDocverToken(ticketData, ticketHmac); - session.tokens[ticket.fid] = entry; - } catch { - // ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access - } - } - if (entry === null) { - // เอกสารใหม่ของคนเดิม — ขอ token เก็บเพิ่ม (server ตัดสินเรื่อง ticket หมดอายุ) - try { - session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac); - } catch { - return isTicketExpired(ticket.exp) - ? expiredMessage - : { - ok: false, - reason: "token-request-failed", - message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", - }; - } - } - ticketService.saveSession(session); + const inSession = + session !== null && session.userId === ticket.uid && session.tokens[ticket.fid] !== undefined; + + // pre-check: หมดอายุ + ไม่มีใน session = เตะโดยไม่ยิง API (ลดการเรียกใช้ network) + if (isTicketExpired(ticket.exp) && !inSession) { + return expiredMessage; } + // ยิงทุกครั้งที่เปิดเอกสาร — signature ถูกตรวจที่ server เท่านั้น (ไม่มี secret ฝั่ง client) + const result = await ticketService.requestDocverToken(ticketData, ticketHmac); + if (!result.ok) { + if (result.kind === "http" && result.status === 400) { + return { + ok: false, + reason: "tampered", + message: "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court", + }; + } + if (result.kind === "http" && result.status === 401) { + return { + ok: false, + reason: "tampered", + message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket กับระบบไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court", + }; + } + if (result.kind === "http" && result.status === 403) { + // ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT ของ fid (uid ตรง) ที่ยังไม่หมด = ใช้ต่อ + if (session !== null && session.userId === ticket.uid) { + const entry = session.tokens[ticket.fid]; + if (entry !== undefined && !ticketService.isTokenExpired(entry)) { + session.userName = ticket.uname; // refresh ชื่อตามไฟล์ล่าสุด + ticketService.saveSession(session); + return finishSignIn(ticket, ticketData, ticketHmac); + } + } + return expiredMessage; + } + // network/malformed/5xx และอื่น ๆ — ไม่ใช่ความผิดของไฟล์ ลองใหม่ได้เมื่อเน็ตกลับมา + return { + ok: false, + reason: "token-request-failed", + message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", + }; + } + + // 200 — user ใหม่ = เคลียร์ session เก่าทั้งก้อน (requirement ขั้น 6) สร้างใหม่ · + // คนเดิม = update userName (ชื่อเปลี่ยนได้) + เก็บ token ใหม่ตาม fid + if (session === null || session.userId !== ticket.uid) { + ticketService.clearSession(); + ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: result.entry } }); + } else { + session.userName = ticket.uname; + session.tokens[ticket.fid] = result.entry; + ticketService.saveSession(session); + } + return finishSignIn(ticket, ticketData, ticketHmac); +} + +/** ท้ายร่วมของทุกทางที่ผ่าน — ตั้ง module state ของ taskpane instance นี้ */ +function finishSignIn(ticket: DocverTicket, ticketData: string, ticketHmac: string): SignInResult { currentFid = ticket.fid; currentTicket = { data: ticketData, hmac: ticketHmac }; username = ticket.uname; @@ -151,26 +159,22 @@ export async function signInWithTicket(input: { /** * ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน — เรียกจาก apiClient ตอนโดน 401 * ก่อน throw AuthRequiredError 1 รอบ · ไม่ตัดสิน exp ของ ticket ฝั่งนี้ (server - * ออกให้ต่อได้แม้ ticket หมดอายุเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน — - * requirement ขั้น 7) 401 กลับมา = false ตามเดิม + * เป็นคนตอบ: 200 = ออกให้ใหม่ · 403 = ticket หมดอายุ → false) * คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่) */ export async function renewTokenFromTicket(): Promise { if (currentTicket === null || currentFid === null) return false; const parsed = parseDocverTicket(currentTicket.data); if (!parsed.ok) 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; - } + const result = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac); + if (!result.ok) return false; + const session = + ticketService.loadSession() ?? + { userId: parsed.ticket.uid, userName: parsed.ticket.uname, tokens: {} }; + session.userName = parsed.ticket.uname; + session.tokens[parsed.ticket.fid] = result.entry; + ticketService.saveSession(session); + return true; } /** Bearer ของ apiClient — token ของ fid ปัจจุบัน (stale ได้ — apiClient เป็นคนตัดสิน 401) */ diff --git a/wordaddin/services/ticketService.ts b/wordaddin/services/ticketService.ts index 744c764..349a721 100644 --- a/wordaddin/services/ticketService.ts +++ b/wordaddin/services/ticketService.ts @@ -1,17 +1,18 @@ /** * Ticket ของ add-in — ของมือถือ (primitives) ให้ authService ประกอบเป็น flow * (PLAN-ADDIN-AUTH.md §5): - * - verifyTicketHmacWeb: ตรวจ HMAC ฝั่ง client ด้วย Web Crypto (UX gate — ตัดเคส - * ไฟล์ถูกแก้ก่อนยิง network ส่วนความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น) * - session store: localStorage key เดียว `wordaddin-auth` — userId/userName + * map token ต่อ fid · webview block localStorage = fallback memory ต่อ session * (pattern เดียวกับ versioningService) * - requestDocverToken: POST same-origin /api/documents/token แบบ body ticket — - * ไม่ผูกกับ config.apiBaseUrl (endpoint อยู่แอปนี้ ใช้ได้ทั้งช่วง mock และจริง) + * ไม่ผูกกับ config.apiBaseUrl (endpoint อยู่แอปนี้ ใช้ได้ทั้งช่วง mock และจริง) · + * คืน discriminated result แยก network/http status/malformed ให้ caller + * ตัดสินตาม contract 400/401/403 (2026-11-04) + * - (ไม่มีการตรวจ HMAC ฝั่ง client อีกต่อไป — secret เป็นของ server อย่างเดียว + * ตาม PLAN-SSO.md D13, ถอด verifyTicketHmacWeb ออก 2026-11-04) */ import { withBasePath } from "@/utils/base-path"; -import { normalizeHexDigest } from "@/utils/docver-ticket"; /** key เดียวของ session — v ไว้ migrate รูปในอนาคต */ const SESSION_STORAGE_KEY = "wordaddin-auth"; @@ -91,38 +92,6 @@ 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) // --------------------------------------------------------------------------- @@ -147,30 +116,50 @@ function jwtExp(token: string): number | undefined { } } +/** ผลของ requestDocverToken — แยกสาเหตุให้ caller ตัดสินตาม contract ไม่โยน exception */ +export type RequestDocverTokenResult = + | { ok: true; entry: SessionTokenEntry } + /** fetch reject — ไม่ถึง server (offline/dev server ตาย) */ + | { ok: false; kind: "network" } + /** ได้ response แต่ไม่ใช่ 200 — 400 ฟอร์แม็ตพัง / 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ */ + | { ok: false; kind: "http"; status: number } + /** ตอบ 200 แต่ body ใช้ไม่ได้ (ไม่ใช่ JSON / ไม่มี access_token) */ + | { ok: false; kind: "malformed" }; + /** - * ขอ access_token ด้วย ticket — โยน Error เมื่อ status ไม่สำเร็จ - * (401 = ticket ไม่ผ่านการตรวจฝั่ง server — ให้ caller ไป no-access ตาม flow) + * ขอ access_token ด้วย ticket — คืน discriminated result ไม่โยน Error: + * caller (authService) แยกทางตาม contract ของทางเข้า ticket ของ + * /api/documents/token (2026-11-04: 200 ผ่าน / 400 ฟอร์แม็ต / 401 ลายเซ็น / + * 403 ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT เดิมของ fid ใน session ใช้ต่อได้) */ -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 }), - }); +export async function requestDocverToken( + ticketData: string, + ticketHmac: string, +): Promise { + let response: Response; + try { + response = await fetch(withBasePath("/api/documents/token"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }), + }); + } catch { + return { ok: false, kind: "network" }; + } if (!response.ok) { - throw new Error(`ขอ token ไม่สำเร็จ (HTTP ${response.status})`); + return { ok: false, kind: "http", status: response.status }; } let data: TokenResponse; try { data = (await response.json()) as TokenResponse; } catch { - throw new Error("ขอ token ไม่สำเร็จ (response ไม่ใช่ JSON)"); + return { ok: false, kind: "malformed" }; } if (typeof data.access_token !== "string" || data.access_token === "") { - throw new Error("ขอ token ไม่สำเร็จ (ไม่มี access_token)"); + return { ok: false, kind: "malformed" }; } 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 }; + return { ok: true, entry: { token: data.access_token, exp } }; }