eCourt-note/docs/PLAN-SSO.md
Your Name 5121970f31
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Failing after 30s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Failing after 1s
refactor(auth): consolidate auth under /api/auth/* endpoints
- ยุบ route auth ที่เคย copy กันจน drift (flow N11 หายจาก /api/auth/session)
  เหลือโค้ด inline สองเส้นเดียว: POST /api/auth/token (ทางเข้า 3: x-api-key
  docver / x-api-key purpose:"login" mint ลิงก์ note / ticket ของ add-in)
  + POST /api/auth/session (แลก credential + register N8 + N11 fid-only)
- POST /api/documents/token คงไว้เป็น alias re-export (กลุ่ม docver/e-Court
  เดิม — contract เดิมเป๊ะ, มี test toBe กัน copy แยกชุดกลับมา)
- ถอดเส้นฝั่ง note ทั้งสอบ: /api/note/auth/token + /api/note/auth/session
  (e-Court ต้องย้ายมาทางเข้ารวม — แจ้งทีม e-Court ก่อน deploy)
- utils/auth-store.ts: primitives กลางฝั่ง client (jwtExp/isExpired/
  localStorage store + memory fallback) — lib/note-session ใช้ร่วม
  (expMs decode จาก JWT payload = นาฬิกา server) และวางไว้ให้ wordaddin
  ย้ายมาใช้รอบหน้าได้ (รอบนี้ไม่แต้ wordaddin ตามข้อตกลง)
- นโยบาย token หมดอายุกลาง session: PDFViewer reportActionError จับ
  auth-error ของ Server Action ทุกจุด → ล้าง session แล้วพาไป
  /note?sso=expired (จบที่ gate เดียวกับ bootstrap 401)
- openapi: ทางเข้า purpose:"login" + schema LoginTokenRequest + ลบ path
  เส้นที่ถอด + test กันเส้นตายหลุดค้างใน spec
- เอกสาร sync: PLAN-SSO §0.6, CLAUDE.md, PLAN-STAGED, .env.example, /note/dev
- tests: ย้าย/เพิ่มชุด auth (50 เคสกลุ่ม auth ผ่าน, ทั้งชุด 444 ผ่าน) +
  e2e ครบทั้ง API (mint/alias/ticket/404 เส้นเก่า/N11/D4) และ browser

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-09 14:30:29 +07:00

336 lines
69 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PLAN-SSO — Login จากลิงก์ e-Court ด้วย login-link token
> วันที่ตกลงแผน: 2026-10-28 · ปรับแผน 2026-10-29 (ถอดหน้า login ทั้งหน้า + หน้าแรก `/note` เป็นหน้าเลือกเอกสาร — D14/D15 · แยกเส้น mint login token ออกจากกลุ่ม docver — D1) · ปรับแผน 2026-10-29 รอบสอง (เซ็น session cookie HMAC ตาม code review ข้อ "oacCase ไม่มีลายเซ็น" — D16) · ปรับแผน 2026-10-30 (แยกหน้าแรกออกเป็นสองหน้า: `/note` = landing เรียบ ส่วน D14 เครื่องมือทั้งหมด + คู่มือ SSO ย้ายไป **`/note/dev`** — public เหมือนเดิม) · **ปรับแผน 2026-10-30 รอบสอง (ใหญ่): เลิก cookie ทั้งระบบ เปลี่ยน session เป็น access_token แบบ add-in — ดู §0.5 ซึ่งแทนที่ D2/D3/D15/D16 และส่วนที่เหลือของแผนที่พูดถึง cookie** · สถานะ: **implement แล้ว (ฉบับ token) ยกเว้น D13** (ผู้ใช้สั่งไม่แตะของ add-in — การถอด client HMAC + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` เลื่อนเป็นงานแยก: ทาง ticket (B) จึงยังแบกความเสี่ยง secret ร่วมอยู่ใน bundle ของ add-in ตามที่ D13 อธิบาย ส่วนตัว verify ทาง B ฝั่ง server (lib/addin-ticket.ts) ใช้ได้ตามปกติ) · ต่อยอด: `docs/PLAN-ECOURT.md` (N8) + `docs/PLAN-DOCVER.md` §auth
## 0. บริบท/เป้าหมาย
แอป note (annotate) ปัจจุบัน login ด้วย mock (`MOCK_USERS` + `Demo1234`) ตั้ง cookie `oacCase` —
แผนนี้เพิ่ม**ทางเข้าจากลิงก์ e-Court 2 รูปแบบพร้อมกัน** (D12): **(A) JWT** — e-Court mint ผ่าน
ทางเข้า `x-api-key` ของ **`POST /api/note/auth/token` — เส้น mint ใหม่เอกสิทธิ์ของแผนนี้ แยกจาก
`POST /api/documents/token` ของกลุ่ม docver (contract docver ไม่ถูกแต้ ใช้ lib เดียวกัน)** ฝังเป็น param `t=` · **(B) ticket**
— `ticket_data` + `HmacSha256` ชุดเดียวกับที่ stamp ลง .docx ของ add-in สำหรับลิงก์ที่ e-Court
สร้างแบบ offline ไม่ต้องยิง API ตอน render → route ของเรา verify → set cookie → ไหลเข้า N8 flow
เดิม (registerExternalFile → viewer) · **mock login ถอดออกทั้งเส้น** (MOCK_USERS + Demo1234) —
ลิงก์ e-Court เป็นทางเข้าเดียว ส่วน dev/ทดสอบ mint ลิงก์เองผ่านสคริปต์ (D10) · **ไม่มีหน้า
login เหลืออยู่อีกต่อไป** — `/note/login` ถอดทั้งหน้า ปลายทาง error ของ SSO กับ redirect ของ
middleware รวมเป็น**หน้าแรก `/note` ที่เปลี่ยนเป็นหน้าเลือกเอกสารแบบ dropdown 2 ระดับ**
(คล้าย `app/tmpl/page.tsx`) และเป็น public (ปรับแผน 2026-10-29 — D14/D15)
เหตุผลที่ **แนะนำให้ e-Court ใช้ทาง JWT (A) เป็นหลัก** และถือ ticket (B) เป็นทางรอง (2026-10-28):
- secret ตัวเซ็น JWT (`DOCVER_TOKEN_SECRET`) เป็น **server-only โดยโครงสร้าง** — e-Court โดนแฮก
ก็ปลอม identity ใส่เราไม่ได้ (ต่างจาก HMAC secret ร่วมของ ticket: เซิร์ฟเวอร์คู่กรณีใดโดนแฮก
ก็ปลอม ticket ได้)
- ฝั่ง e-Court **ไม่มี crypto contract ใหม่** — ทาง A ใช้ x-api-key ตัวเดิม (`DOCVER_API_KEY`)
ยิงเส้น mint ใหม่ ส่วนทาง B ใช้ตัว stamp เดียวกับ .docx ของ add-in
- ticket (B) เก็บไว้เพราะ mint ได้**แบบ offline** — ครอบคลุมเคสที่ e-Court สร้างลิงก์ล่วงหน้า/
ไม่ยิง API ตอน render · แลกมาด้วยเงื่อนไขบังคับ **D13** (ตัด secret ออกจาก bundle ของ add-in
ก่อน — ไม่งั้นใครแกะ bundle ก็ปลอม ticket เข้า login ได้ · **ทำแล้ว 2026-11-04**)
**ผลพลอยได้:** ไม่มีเงื่อนไขค้างกับทีม e-Court อีกต่อไป — ทางไหนที่เขาทำได้ตามบริบทหน้านั้น ๆ
ก็ใช้ได้ (แนะนำ A เมื่อ mint real-time ได้, B เมื่อสร้างลิงก์ล่วงหน้า) · ลิงก์ทั้งสองแบบ
credential อายุสั้น ฝังอีเมล/แชท/เก็บลง DB ไม่ได้อยู่ดี
## 0.5 ฉบับ token แบบ add-in (2026-10-30 รอบสอง — คำขอผู้ใช้ "note ใช้แบบ add-in")
**เลิก cookie ทั้งระบบ** (cookie คู่ `oacCase`/`oacCaseSig` + `NOTE_SESSION_SECRET` + middleware + link proof ที่เคยทำรอบเดียวกันวันนั้น — ถอดหมด) session ของแอป note เปลี่ยนเป็น **access_token ใน localStorage แบบ `wordaddin/services/authService`**:
```
ลิงก์ e-Court ชี้ตรงมา /note?fileUrl=&typeId=&t=… (หรือ ticket_data+HmacSha256)
→ NoteHome (client bootstrap หน้าแรก) POST /api/auth/session (§0.6 — เดิม /api/note/auth/session)
→ server verify credential (ทาง A JWT purpose=login / ทาง B ticket + fail-closed
เมื่อ secret เป็นค่า default "super_secret")
→ ลงทะเบียนเอกสาร N8 แบบ trusted เลย (fid จาก credential — ไม่ต้องมี link proof)
→ คืน {access_token (purpose=login อายุ 8 ชม. = NOTE_SESSION_TTL), userid, username, fileId?}
→ client เก็บใน localStorage "note-auth" + ตัด credential ออกจาก URL ทันที
→ มี fileId = router.replace /note/annotate/<rowId>
```
- **การเขียนทุกครั้งผ่าน Server Action ที่รับ `token` เป็น param แรก** — identity แกะฝั่ง server ด้วย `lib/note-token.ts` (`noteUserFromToken` → `verifyDocverLoginToken` + env `DOCVER_TOKEN_SECRET` ตัวเดียวกับ login-token ของลิงก์) — แทนที่จะเชื่อ `userId` จาก client ตรง ๆ แบบเดิม (ปิดรูปเดิมด้วย)
- **หน้า protected เป็น shell public ฝั่ง server + client gate**: `/note/annotate/[id]` → `AnnotateView` ตรวจ token → ดึงข้อมูลเริ่มต้นจาก `GET /api/note/files/[id]/bootstrap` (Bearer) → PDFViewer (prop `token`) · ไม่มี/หมดอายุ = หน้า gate "กลับ e-Court กดลิงก์ใหม่" (แทน `sso=required` ของ middleware ที่ถอด)
- **middleware.ts / lib/session.ts / utils/user.ts / app/actions/auth.ts / GET /api/note/auth/e-court / app/note/annotate/page.tsx (entry ?fileUrl=) — ถอดทั้งไฟล์** · logout = ปุ่ม client ล้าง localStorage
- ทางเปิดไฟล์แบบไม่มี credential (samples หน้า /note/dev) ย้ายไป `GET /api/note/files/open?fileUrl=/samples/…` (ลิงก์ "A Link Get(new)" แบบ /tmpl — path ในเครื่องเท่านั้น, `registerExternalFile` trusted:false แถว `url:…`)
- **คง status quo เส้น content ที่ browser เปิดแบบไม่ผ่าน fetch()** (ตามคำตอบผู้ใช้ 2026-10-30): SSE (EventSource) / `/api/note/pdf` + `<img>` N3 / export PDF — ไม่บังคับ Bearer (เดิมก็ไม่มี auth) · หน้าสรุป/หน้ากลุ่มยังเป็น server component จึงกลายเป็น public ไปด้วย — ยอมรับชั่วคราวตามมาตรฐานเดียวกัน (จะเปลี่ยนเป็น client+API เมื่อเรียกข้อเกิน)
- **contract กับ e-Court เปลี่ยนเฉพาะปลายทางลิงก์**: `{BASE}/note?…` แทน `{BASE}/api/note/auth/e-court?…` (mint ทาง A และ ticket ทาง B ไม่เปลี่ยน) — ส่วนที่เหลือของแผน (§1 D-rows, §3.1, §6, §8) ให้อ่านผ่านเลนส์ของ §0.5 นี้
## 0.6 ฉบับทางเข้ารวม `/api/auth/*` (2026-11-09 — คำขอผู้ใช้ "note กับ add-in ใช้ auth เดียวกัน")
**ยุบ route คู่ที่เคย copy กันให้เป็นทางเข้าเดียว** (จุดกระตุ้น: `/api/auth/session` ที่ copy มา 2026-11-05 ตัด flow N11 ของลิงก์ fid-only ทิ้ง — drift จริง) · **รอบสอง (วันเดียวกัน ตามคำขอผู้ใช้ "ทำให้มีแค่ /api/auth/token + /api/auth/session" + "ของที่เกี่ยวกับ add-in ไม่ต้องลบ")**: **ถอดเส้นฝั่ง note ทั้งสอง (`/api/note/auth/token`, `/api/note/auth/session`) ออกถาวร** — โค้ดกลับมา inline ใน route ของ `/api/auth/*` แบบเดิม (ไม่มี lib handler แยก) · **`/api/documents/token` ของกลุ่ม docver คงไว้** เป็น alias re-export ของ `/api/auth/token` (function เดียวกันเป๊ะ — ของ add-in/e-Court เดิมไม่ถอด) · e-Court ที่ยังยิงเส้น note เก่าต้องย้าย:
- **`POST /api/auth/token` = ทางเข้า token เดียวของทั้งระบบ** (โค้ดเต็มใน route): x-api-key + body เดิม = docver-token 7 วัน (alias `/api/documents/token` — contract เดิมเป๊ะ) · x-api-key + **`purpose:"login"`** (+`ttl_seconds` 60–600) = login-token อายุสั้น (แทนเส้น mint เดิมของ §3.1) · ticket ของ add-in เหมือนเดิม
- **`POST /api/auth/session` = ทางเข้า session เดียวของแอป note** (โค้ดเต็มใน route ฉบับครบ flow N11) · `NoteHome` ยิงเส้นนี้
- **TTL ยังแยกตาม purpose** (ตามข้อตกลงกับผู้ใช้ "แยกไว้ก่อน"): login ลิงก์ 60–600 วิ · session 8 ชม. · docver 7 วัน — secret เดียวกัน `DOCVER_TOKEN_SECRET` แยกสิทธิ์ด้วย `purpose` (D4) · response shape `{access_token, token_type, expires_in}` ตรง OAuth2 อยู่แล้ว วันหนึ่งเพิ่ม grant `refresh_token` ได้โดยไม่ break (จดใน §11)
- **โค้ด client กลาง**: `utils/auth-store.ts` (pure — localStorage store + memory fallback + `jwtExp`/`isExpired`) — ฝั่ง note ใช้ผ่าน `lib/note-session.ts` (exp ตอนนี้ decode จาก JWT payload = นาฬิกา server) · **วางไว้ที่ `utils/` เพื่อให้ wordaddin import ได้ตามกติกา client-only zone** — add-in ย้าย `ticketService` มาใช้รอบหน้า
- **นโยบาย token หมดอายุ (คำถามผู้ใช้): เอาออกจาก session เลย** — token ที่ตายแล้วไม่มีทางฟื้น (browser ไม่มี ticket ให้ renew แบบ add-in; renew ของ add-in ก็ไปทาง ticket ไม่ใช่ token เก่า): `readNoteSession` ล้างทิ้งตอนอ่าน (เดิม) + **กลาง session: Server Action ล้มด้วย auth (message sentinel ของ `requireUser` — helper `isNoteAuthExpiredError` + `reportActionError` ใน PDFViewer) = `clearNoteSession` + พาไป `/note?sso=expired`** จบที่แบนเนอร์เดียวกับตอน bootstrap 401 ไม่ปล่อย error toast ค้างให้กดอะไรไม่ได้อีก
- กัน drift กลับมา: `/api/documents/token` เป็น re-export ของ `/api/auth/token` (function เดียวกันเป๊ะ — มี test `toBe` ยึดไว้ใน `tests/app/api/auth/token.test.ts` กัน copy แยกชุดอีก)
## 1. ข้อตกลงที่ตัดสินใจแล้ว (2026-10-28)
| # | เรื่อง | ข้อตกลง |
|---|---|---|
| D1 | ตัวพา identity | 2 รูปแบบ (D12): **(A)** JWT จากทางเข้า `x-api-key` ของ **`POST /api/note/auth/token` — เส้น mint ใหม่ของแผนนี้ แยกจาก `/api/documents/token` ของกลุ่ม docver** (คำขอผู้ใช้ 2026-10-29 — ไม่แต้ contract docver ใช้ lib `docver-token` เดิม) ฝังเป็น param เดียว `t=` · **(B)** `ticket_data`+`HmacSha256` (ชุดเดียวกับตัว stamp .docx ของ add-in) |
| D2 | จุดลงจอ | **route handler `GET /api/note/auth/e-court`** — set cookie ได้ตามกติกา Next (page render ทำไม่ได้) + middleware ยกเว้น `api/note` อยู่แล้ว (route นี้เองไม่ต้องแต้ middleware — ชุดที่แต้ตาม D15 เป็นเรื่องหน้าแรก public คนละเรื่อง) |
| D3 | session | แปลงเป็น cookie `oacCase` รูปเดิมผ่าน `buildOacCaseCookie(userid, username)` (identity ไหลใน cookie เดิม: `userid`→u_userid, `username`→u_userlogin) **+ คู่ `oacCaseSig` ตาม D16 เสมอ** — cookie เดิมลอยตัวไม่มีลายเซ็น = ใครก็ปลอม `u_userid` ข้าม SSO ได้ทั้งหมด · `lib/session.ts` ต้องแต้แล้ว (verify signature — ดู D16) |
| D4 | แยกสิทธิ์ token | เพิ่ม claim `purpose: "docver" \| "login"` เซ็นด้วย secret เดียวกัน (`DOCVER_TOKEN_SECRET`) — **mint login token ได้จาก `POST /api/note/auth/token` เท่านั้น** ส่วน `/api/documents/token` ไม่รับ/ไม่ออก `purpose` อีก (ส่ง field มา = 400 ตาม allowlist เดิม) · `requireDocverBearer` รับเฉพาะ `docver` (undefined = docver เพื่อ token เดิมในมือ user ตอน roll ไม่ตาย), landing route รับเฉพาะ `login` — กัน token ใน URL ถูกจับไปใช้กับ API versioning (scope escalation) · **อัปเดต 2026-11-09 (§0.6): mint login token ทาง `POST /api/auth/token` ด้วย `purpose:"login"` ใน body ได้เพิ่ม (ค่าอื่น = 400) — เส้น mint เดิม `/api/note/auth/token` ถอดแล้วในรอบสอง** |
| D5 | TTL ของ login token | body `ttl_seconds` ทางเลือกช่วง 60–600 default 300 (ของเส้น mint ใหม่ — เป็น login-token เอกสิทธิ์ ไม่ต้องส่ง `purpose` ใน body) — credential ใน URL ต้องอายุสั้น · ทางเข้า docver TTL ตาม env เดิมไม่เปลี่ยน |
| D6 | อายุ session | cookie 8 ชม. เหมือน mock login — token สั้นเฉพาะตอน "ขอเข้า" (แบบ SSO code flow) · ไม่มี revoke (เท่า status quo ปัจจุบัน) |
| D7 | mock login | **ถอดออกทั้งเส้น** (MOCK_USERS + Demo1234 + LoginForm + action `login`) — ลิงก์ e-Court คือทางเข้าเดียวของ production · **หน้า `/note/login` ถอดทั้งหน้า ไม่เหลือหน้าข้อมูล** (ปรับแผน 2026-10-29 — ปลายทางข้อความต่าง ๆ ย้ายไปแบนเนอร์ของหน้าแรก D14) · dev/ทดสอบ mint ลิงก์ด้วยสคริปต์ D10 |
| D8 | token ไม่ผ่าน | ไม่ตอบ 401 เปล่า (user คลิกลิงก์มาเอง) — **302 ไป `/note?sso=expired\|invalid\|error`** หน้าแรกแสดงแบนเนอร์ตามสาเหตุ (บอกผู้ใช้กลับ e-Court กดเปิดใหม่) — ค่า `sso=required` ตัวที่สี่มาจาก middleware ตอนเด้งคนไม่มี session กลับหน้าแรก (D15) |
| D9 | login-CSRF ผ่าน img/embed | route เช็ค `Sec-Fetch-Dest: document` — header ขาด (browser เก่า) = ยอมผ่าน · กันเคสเว็บอื่นฝัง `<img src=…?t=…>` ดัน browser ของเหยื่อ login เป็นเจ้าของ token |
| 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 ก็แข็งขึ้นตาม · **✅ ทำแล้ว 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`** = `<exp epoch วินาที>.<HMAC-SHA256 hex>` เซ็นครอบ **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) |
### 1.1 หน้าแรก `/note` ใหม่ (D14)
> **ปรับแผน 2026-10-30 (คำขอผู้ใช้ "ออกแบบ /note ใหม่")** — เนื้อหาทั้งหมดของ §1.1 นี้ย้ายไปหน้า **`/note/dev` "เครื่องมือทดสอบ"** (dropdown เปิดเอกสารตัวอย่าง + คู่มือทางเข้า SSO 2 ทาง + ฟอร์ม mint ลิงก์ + สคริปต์) ซึ่ง **public เหมือนหน้าแรก** (matcher เพิ่มยกเว้น `note/dev` — ต้อง mint ลิงก์ได้ตอนยังไม่มี session) ส่วน `/note` เหลือ **landing เรียบ**: ชื่อระบบ + การ์ด "เข้าใช้งานผ่านลิงก์ของ e-Court" + ลิงก์ไป `/note/dev` · **สัญญาลิงก์ไม่เปลี่ยน**: แบนเนอร์ `?sso=` 4 ค่า + N8 `?fileUrl=` (เช็ค session ก่อนเสมอ) ยังอยู่ที่ `/note` ทั้งหมด — redirect ของ SSO route/middleware ไม่ต้องแต้
- **โครงหน้าเหมือน `app/tmpl/page.tsx`**: dropdown หลัก = กลุ่มเอกสาร → dropdown รอง = ไฟล์ในกลุ่ม (reuse `components/utils/DependentDropdown.tsx` — ปรับ class ให้เป็น Genesis: ตัวเดิมใช้ raw `gray-*` ซึ่งห้ามตาม DESIGN.md) + ปุ่ม "เปิดเอกสาร" · ไม่มีส่วนอื่นของหน้าเดิมเหลืออยู่
- **รายการเอกสาร fixed ในโค้ด 2 กลุ่ม × 2 ไฟล์** — จาก PDF ตัวอย่างที่มีอยู่จริงของแอป note (`public/note/samples/`):
```ts
const SAMPLE_DOCS: DropdownData = {
"คำสั่งศาล — คดี 01012-672427": [
"01012-672427-1f-690609-0000840223.pdf",
"01012-672427-4f-690609-0000840227.pdf",
],
"คำสั่งศาล — ตัวอย่าง": [
"court_command_11_123 1.pdf",
"court_command_1_2547 1.pdf",
],
};
```
- **ปุ่ม "เปิดเอกสาร"** → `/note/annotate?fileUrl=/samples/<ชื่อไฟล์>` (entry ที่สองของ N8 ที่มีอยู่แล้ว `app/note/annotate/page.tsx` — dedupe ผ่าน external_id เอง) — หน้า `/note` **ไม่ต้อง query DB เลย**
- **N8 `?fileUrl=` ทำงานก่อน render dropdown — เฉพาะเมื่อมี session ที่ผ่าน `await getSessionUser()` (verify ตาม D16)**: SSO route redirect มา `/note?fileUrl=…&fileId=…&typeId=…` พร้อม cookie คู่ใหม่เสมอ → registerExternalFile → redirect viewer · **มี `?fileUrl=` แต่ไม่มี session = redirect `/note?sso=required` ทิ้ง param ทั้งหมด — ห้าม register ก่อนตรวจ** (หน้านี้ public ตาม D15 แต่ N8 เขียน DB: ถ้าไม่เช็ค ใครก็เปิด `/note?fileUrl=…&fileId=<ของคนอื่น>` ได้แม้ถูกเด้งตอนเข้า viewer — สร้าง row ขยะ + refresh `pdf_url`/`typeId` ของเอกสารที่มีอยู่ให้ชี้ไป PDF ที่ตัวเองเลือก · ปิด gap ตามคำถามรอบทดสอบ 2026-10-29) · เข้าเองโดยไม่มี query = เห็น dropdown
- **แบนเนอร์ `?sso=` (4 ค่า — D8/D15)** แสดงบนหน้านี้ · AppNav แสดงชื่อผู้ใช้เมื่อมี session ไม่มี = hint "เข้าผ่านลิงก์ e-Court" แทน
- **ส่วนคู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (คำขอผู้ใช้ 2026-10-29 รอบสอง ให้เหมือน `app/tmpl/page.tsx`): การ์ดทาง A (JWT — curl mint → ประกอบลิงก์ `t=` → คลิก → session → viewer) + การ์ดทาง B (ticket → ประกอบลิงก์ `ticket_data`+`HmacSha256` → คลิก → session) + กล่อง dev สั่ง `scripts/make-login-link.mjs` — ตัวอย่างเป็น "รูปแบบ + ขั้นตอน" เท่านั้น **ห้าม mint จริงจากหน้า** (หน้า public — เอา `x-api-key` ลงหน้า = ใครก็ mint เป็นทุกคน)
- **ฟอร์ม "สร้างลิงก์ทดสอบ" บนหน้าแรก** (คำขอผู้ใช้ 2026-10-29 รอบสาม "ใช้ได้ใน note" — dev-only): `POST /api/note/auth/dev-link` mint ฝั่ง server (secret ไม่ผ่านหน้าเบราว์เซอร์) คืน URL เต็มให้กด "เปิดลิงก์" — เปิดด้วย env `NOTE_SSO_DEV_MINT=1` **และ `NODE_ENV !== "production"` เท่านั้น** (ปิด = route 404 + ฟอร์มหายจากหน้า · dev server บน LAN เปิดค้าง = คนในวงแชร์ mint ได้ จึงเปิดเฉพาะตอนทดสอบ) · identity/ttl ใช้เกณฑ์เดียวกับเส้น mint จริง (`parseDevLinkBody` ตัด field ส่วนเสริมแล้ว delegate ให้ `parseLoginTokenBody`) · โหมด jwt ใช้ `DOCVER_TOKEN_SECRET` โหมด ticket ใช้ `addinTicketSecret()` และ ticket_data เรียง key ตามตัว stamp จริง
- **ถอดตามหน้าเดิม**: `UploadFileForm` + action `uploadFile` (N4 หมดทางเข้า UI — เอกสารเข้าระบบทางลิงก์ e-Court กับ samples เท่านั้น) + `EcourtLinkForm` (ทางทดสอบ N8 เหลือสคริปต์ D10 อย่างเดียว) · หน้า `/note/groups/[typeId]` **คงไว้ตามเดิม** (ยังเข้าได้จาก deep link/URL — แค่ลิงก์ "เอกสารในกลุ่ม" จากหน้าแรกหายไป)
## 2. Contract ให้ทีม e-Court
**ขั้น 1 (ทาง A) — mint token (server-to-server ของ backend e-Court):**
```
POST {BASE}/api/auth/token
x-api-key: <DOCVER_API_KEY ที่ใช้อยู่>
content-type: application/json
{"userid":"<u_userid>","username":"<u_userlogin>","fid":"<fileId ของ e-Court>","ttl_seconds":300,"purpose":"login"}
→ 200 {"access_token":"…","token_type":"Bearer","expires_in":300}
```
(ทางเข้ารวม §0.6 — `purpose:"login"` บอกว่าต้องการ login-token · **เส้น mint เดิม `POST /api/note/auth/token` ถอดแล้ว (2026-11-09 รอบสอง) — e-Court ที่ยังยิง URL เดิมต้องย้ายมาเส้นนี้** · ไม่ส่ง `purpose` ที่ `/api/auth/token` = docver-token ของกลุ่ม documents — อย่าสับสนสองอย่างนี้ใน codebase เดียวกัน)
**ขั้น 2 — ประกอบลิงก์ให้ผู้ใช้คลิก (เลือกทางใดทางหนึ่ง — ห้ามส่งสองชุดพร้อมกัน):**
ทาง A (แนะนำ — เมื่อ mint แบบ real-time ได้):
```
{BASE}/note?fileUrl=<https ของไฟล์ — urlencode>&typeId=<กลุ่ม ทางเลือก>&t=<access_token>
```
ทาง B (ลิงก์ที่สร้างล่วงหน้า/แบบ offline — stamp ด้วยตัวเดียวกับ .docx, format ตาม PLAN-ADDIN-AUTH.md §2):
```
{BASE}/note?fileUrl=…&typeId=…&ticket_data=<JSON urlencode>&HmacSha256=<hex 64 ตัว>
```
กติกา:
- ทาง A: mint ตอน render หน้า/ปุ่มให้ user ปัจจุบันเท่านั้น — **ห้ามเก็บ token ลง DB / ฝังอีเมล /
แชท** (อายุ 5 นาที เก็บไว้ก็ตาย) · ทาง B: exp ของ ticket แนะนำ ≤ 600 วิเช่นกัน (credential
นอนอยู่ใน URL — ยิ่งสั้นยิ่งดี)
- `userid`/`username` (A) หรือ `uid`/`uname` (B) = ค่าเดียวกับ `u_userid`/`u_userlogin` เดิม
(สิทธิ์เจ้าของ annotation ของแอป note ผูกกับ userid — ส่งผิดคน = ข้อมูลผูกคนผิด)
- `fileUrl` ต้องผ่าน allowlist เดิมของ N8 (โฮสต์ `admincourt.go.th` + `EXTERNAL_FILE_HOSTS`)
- token ทาง A ใช้ได้กับ**การเข้าแอป note เท่านั้น** (purpose=login) — ยิง API กลุ่ม docver ไม่ได้
## 3. ฝั่ง server — endpoint ที่เปลี่ยน/เพิ่ม
### 3.1 `POST /api/note/auth/token` (mint login token — **ถอดแล้ว 2026-11-09 รอบสอง**: ทางเข้ารวมอยู่ที่ `POST /api/auth/token` แบบ `purpose:"login"` ตาม §0.6 · เนื้อหาข้างล่างเป็น contract ที่ย้ายไปเส้นรวมทั้งหมด)
| เคส | พฤติกรรม |
|---|---|
| `x-api-key` ถูกต้อง + body `{userid, username, fid, ttl_seconds?}` (allowlist 4 field เท่านั้น) | ออก JWT claims `{userid, username, fid, template:null, purpose:"login"}` TTL ตาม `ttl_seconds` (60–600 default 300) → `{"access_token","token_type":"Bearer","expires_in"}` |
| `ttl_seconds` นอกช่วง / field ที่ไม่รู้จัก (รวมส่ง `purpose` มา) | 400 |
| `x-api-key` ไม่ใส่/ผิด | 401 ข้อความเดียวกันทั้งสองกรณี (กัน enumerate — pattern `requireDocverApiKey`) |
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 |
- reuse จาก lib เดิมทั้งหมด: `requireDocverApiKey` (เทียบ `DOCVER_API_KEY`) + `issueDocverToken` (ส่ง purpose="login" + ttl เข้าไป) — **ห้าม log ค่า key/body ดิบ**
- **`POST /api/documents/token` ของกลุ่ม docver ไม่ถูกแต้จากแผนนี้แล้ว** — contract เดิมคงเดิมทั้งหมด (ส่ง `purpose`/`ttl_seconds` มา = 400 ตาม allowlist เดิม) เหลือแค่ระดับ lib: `issueDocverToken` default แนบ `purpose:"docver"` ให้ token ใหม่โดย route docver ไม่ต้องแต้ และ `requireDocverBearer` (ทุก endpoint กลุ่ม docver) reject `purpose === "login"` — 401 ข้อความเดิม (กันเอา login token ไปยิง API versioning — D4)
### 3.2 `POST /api/auth/session` (ฉบับ token 2026-10-30 — แทน GET e-court ที่ถอดแล้ว · **เส้นเดิม `/api/note/auth/session` ถอดแล้ว 2026-11-09 รอบสอง** — ทางเข้า session เดียว)
body: `{t} | {ticket_data, HmacSha256}` + optional `{fileUrl, typeId}` (allowlist 5 field — `parseSessionExchangeBody`)
| เคส | พฤติกรรม |
|---|---|
| `t` ถูกต้อง (signature + exp + purpose=login) | 200 `{access_token, token_type:"Bearer", expires_in: 28800, userid, username, …}` — token คือ login-token ที่ `issueDocverToken` ออกใหม่อายุ `NOTE_SESSION_TTL` (8 ชม.) · มี `fileUrl` = registerExternalFile **trusted** (fileId จาก fid ที่เซ็น) แล้วเพิ่ม `fileId` ใน response (ล้ม = ยัง 200 พร้อม `fileError` ให้หน้าแรกแสดง — session ได้ก่อนเหมือนสมัย cookie) |
| **ไม่มี `fileUrl` (ลิงก์ fid-only — N11 เอกสาร staging 2026-11-04, `docs/PLAN-STAGED.md` D5)** | เช็คตั้งแต่แลก token: select row `ecourt:<fid>` → เจอ + `pdf_url` เป็น sentinel staged + **`hasStagedDoc` = true** → คืน `fileId` (ใช้ `hasStagedDoc` ไม่ใช่ `getStagedDoc` — ไม่ยืด TTL/ถือ Buffer, การอ่านจริงเกิดที่ viewer) · เจอ + staged + ไม่มี bytes (restart/TTL/evict) → `fileError: "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"` ตั้งแต่ต้น (ไม่ปล่อยเข้า viewer ไปเจอ 410 เอง) · เจอ + `pdf_url` เป็น URL/path ปกติ → คืน `fileId` (เอกสาร N8 ที่รู้จักแล้ว) · ไม่เจอ → `fileError: "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่"` · ผู้ใช้ได้ token ตามปกติทุกกรณี · ทางเข้า history/bookmark (ไม่ผ่าน exchange) ยังเจอ 410 gate ที่ viewer |
| `ticket_data`+`HmacSha256` ครบ (ไม่มี `t`) และ `verifyAddinTicket` ผ่าน | เหมือนแถวบน — uid/uname/fid จาก ticket (`tid` ไม่ใช้) |
| credential ผิด/หมดอายุ | 401 `{error, reason: expired\|invalid}` — client เปลี่ยนเป็นแบนเนอร์ตามเหตุ |
| ทาง ticket แต่ `addinTicketSecret()` resolve ได้ default `"super_secret"` **(เฉพาะ production — dev ยอมรับ default ให้ทดสอบได้)** | log ฝั่ง server + 500 — เส้นนี้ mint session ทั้งแอป ค่า default รู้กันทั้ง repo ใครก็ปลอม ticket ได้ (กลุ่ม docver/add-in ยังใช้ default ตาม requirement — คนละประตู, review 2026-10-30 รอบสอง) |
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | log ฝั่ง server + 500 (server พัง ไม่ใช่ความผิด user) |
| body ผิด / สอง credential พร้อมกัน (D12) | 400 `{error}` |
- audit: log **เฉพาะ** `userid` (strip อักขระควบคุม) + เวลาตอนสำเร็จ — ห้าม log token/body ดิบ · `Cache-Control: no-store`
## 4. โมดูล — `lib/note-auth.ts` (pure ใช้ร่วม client ได้) + `lib/note-token.ts` (server)
```ts
// lib/note-auth.ts — pure ไม่ import @/db/node:crypto (client component ใช้ร่วมได้)
export function parseSsoLinkParams(url: URL): SsoLinkParams;
// ใช้ที่ NoteHome ตอน bootstrap + test — allowlist 5 param + cap + สอง credential พร้อมกัน = ok:false (D12)
export function parseSessionExchangeBody(raw: unknown): SessionExchangeBody; // body ของ §3.2 — เกณฑ์เดียวกัน
export function parseLoginTokenBody(raw: unknown): LoginTokenBody; // §3.1 (allowlist 4 field + ห้าม & = + % ใน identity)
export function devMintEnabled(): boolean; // NOTE_SSO_DEV_MINT=1 + ไม่ใช่ production
export function parseDevLinkBody(raw: unknown): DevLinkBody;
export function ssoBanner(reason: string | undefined): SsoBanner | null; // ข้อความแบนเนอร์ 4 ค่า (D8)
// lib/note-token.ts — server (import lib/docver-token ซึ่งใช้ node:crypto — ห้าม client แตะ)
export const NOTE_SESSION_TTL = 8 * 60 * 60; // อายุ session token วินาที (8 ชม.)
export function noteUserFromToken(token: string | null | undefined): { userid: string; username: string } | null;
// verifyDocverLoginToken + env DOCVER_TOKEN_SECRET — invalid/expired/secret ไม่ตั้ง = null (fail closed)
export function noteUserFromRequest(request: Request): … | null; // อ่าน Authorization: Bearer ของ API route
// lib/note-session.ts — client (localStorage "note-auth": {token, userid, username, expMs})
readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path, init) // แนบ Bearer อัตโนมัติ
```
## 5. Flow รวม (ฉบับ token)
```
ผู้ใช้คลิกลิงก์ใน e-Court → browser ไป /note?fileUrl=&typeId=&t=… (หรือ ticket)
→ NoteHome (client bootstrap) POST /api/auth/session {credential + fileUrl + typeId} (§0.6 เส้นสั้น)
→ server: verify (A: verifyDocverLoginToken / B: verifyAddinTicket + fail-closed default secret)
→ registerExternalFile(fileUrl, fid, typeId, {trusted:true}) → fileId
→ 200 {access_token (8 ชม.), userid, username, fileId?}
→ client: saveNoteSession (localStorage "note-auth" — expMs จาก jwtExp ของ JWT payload) + strip credential ออกจาก URL
→ มี fileId = /note/annotate/<rowId> → AnnotateView (client gate)
→ GET /api/note/files/<id>/bootstrap (Bearer) → PDFViewer (prop token)
→ ทุก Server Action รับ token เป็น param แรก — identity แกะฝั่ง server เสมอ
```
- ผู้ใช้มี session token เก่าอยู่ในเครื่อง → **ลิงก์ชนะ** (bootstrap เขียนทับใหม่เสมอ — e-Court
เป็นผู้ยืนยันตัวตนปัจจุบัน) · token หมดอายุกลางวัน = **§0.6: PDFViewer จับ auth-error
ของ action (`isNoteAuthExpiredError` ผ่าน `reportActionError`) → ล้าง session + พาไป
`/note?sso=expired`** → กลับ e-Court กดลิงก์ใหม่ (ไม่มี renew — เว็บไม่มี ticket
ฝังไฟล์แบบ add-in)
- ไม่มี credential ใน URL → `/note` = landing public (แบนเนอร์ `?sso=` ของลิงก์เก่ายังรองรับ)
- samples หน้า `/note/dev` → `GET /api/note/files/open?fileUrl=/samples/…` (ลิงก์แบบ /tmpl, trusted:false — แถว `url:…` เท่านั้น)
- **ลิงก์ fid-only ของเอกสาร staging (N11 2026-11-04):** e-Court POST เนื้อ base64 ที่
`/api/note/files/stage` ก่อน → mint login token ด้วย `fid` เดิม → ลิงก์มีแค่ `t=`
(ไม่มี `fileUrl`) → bootstrap แลก token พร้อมเช็ค staging ตามตาราง §3.2 → เข้า
viewer ตามปกติ (bytes ไหลทาง `/api/note/pdf` — 410 gate เมื่อพ้นช่วงจัดเตรียม)
## 6. ไฟล์ที่สร้าง/แต้/ถอด
**สร้าง**
- **(§0.6 2026-11-09 — รอบสอง)** `app/api/auth/token/route.ts` + `app/api/auth/session/route.ts` โค้ดเต็ม inline (เส้นฝั่ง note `/api/note/auth/token` + `/api/note/auth/session` **ถอดทั้งคู่**; lib handler ที่ใช้ชั่วระหว่างรอบแรกถอดตาม) · `app/api/documents/token/route.ts` = alias re-export ของ `/api/auth/token` (กลุ่ม docver คงไว้ตามคำขอผู้ใช้) · `utils/auth-store.ts` — primitives กลางฝั่ง client (localStorage + memory fallback + `jwtExp`/`isExpired`) + `tests/utils/auth-store.test.ts` + `tests/app/api/auth/token.test.ts` + `tests/api/auth-session-staged.test.ts`
- `app/api/note/auth/token/route.ts` — §3.1 mint login token (runtime nodejs, force-dynamic) — **ถอดแล้ว 2026-11-09 §0.6 รอบสอง (ทางเข้ารวมที่ `/api/auth/token`)**
- `app/api/note/auth/e-court/route.ts` — §3.2 (runtime nodejs, force-dynamic)
- `lib/note-auth.ts` — §4 (รวม `signOacSession`/`verifyOacSession` ของ D16) + `tests/lib/note-auth.test.ts` (รวมเคส: คู่ถูกต้องผ่าน · แก้ payload 1 ตัวอักษร/แก้ exp เฉย ๆ ไม่ผ่าน · exp อดีตไม่ผ่าน · ไม่มี secret = false)
- `scripts/make-login-link.mjs` — `node scripts/make-login-link.mjs --uid oom --uname สรวิชญ์ --fid F123 --fileUrl https://… --typeId T12 [--ttl 300] [--mode jwt|ticket] [--secret <ตาม mode: DOCVER_TOKEN_SECRET | ADDIN_TICKET_SECRET>]` → พิมพ์ลิงก์เต็ม (jwt = node:crypto HS256 ตรงตาม `lib/docver-token.ts` · ticket = HMAC ตรงตาม `lib/addin-ticket.ts` โดย exp มาจาก `--ttl` default 300)
- `tests/lib/docver-token.test.ts` — เพิ่มเคส purpose/ttl ของ `issueDocverToken` (default "docver" + ส่ง "login") + `requireDocverBearer`-reject-login (เคสเดิมไม่พัง)
- `app/note/dev/page.tsx` — **(ปรับแผน 2026-10-30)** หน้า "เครื่องมือทดสอบ": ย้ายเนื้อหา D14 ทั้งหมด (dropdown เปิดเอกสารตัวอย่าง + คู่มือทางเข้า SSO 2 ทาง + ฟอร์ม mint + คู่มือสคริปต์) ออกจากหน้าแรก — public เหมือนเดิม (matcher เพิ่ม `note/dev` — ต้อง mint ลิงก์ได้ตอนยังไม่มี session)
**แต้**
- `lib/docver-token.ts` — `DocverTokenClaims.purpose?` + `issueDocverToken` รับ purpose/ttl ทางเข้า param (default `purpose:"docver"` — route docver ไม่ต้องแต้), `requireDocverBearer` ปฏิเสธ login-token · `parseTokenRequestBody` **ไม่แต้** (route docver ไม่รับ `purpose`/`ttl_seconds`)
- `app/api/documents/token/route.ts` — **แต้จุดเดียว: ลบค่า hex ที่ comment ค้าง (บรรทัด ~78)** (hygiene ก่อน rotate secret — contract ไม่แต้)
- `app/note/page.tsx` — **rework เป็นหน้าเลือกเอกสาร D14** (dropdown 2 ระดับ + ปุ่ม "เปิดเอกสาร" + แบนเนอร์ `?sso=` + N8 `?fileUrl=` ก่อน render **โดยเช็ค `await getSessionUser()` ก่อน — มี `?fileUrl=` แต่ไม่มี session = redirect `/note?sso=required` ไม่แตะ registerExternalFile** — หน้านี้ public แล้ว `await getSessionUser()` เป็น optional เฉพาะส่วนแสดงชื่อ: มี = แสดงชื่อ ไม่มี = hint, **ไม่ redirect แบบไม่มีเงื่อนไขอีกต่อไป**) · **ปรับแผน 2026-10-30: rework อีกครั้งเป็น landing เรียบ — เนื้อหา D14 ทั้งหมดย้ายไป `app/note/dev/page.tsx` ส่วนแบนเนอร์ `?sso=` + N8 `?fileUrl=` + เช็ค session ก่อนแตะ DB คงอยู่ที่หน้านี้ทั้งหมด (สัญญาลิงก์ไม่เปลี่ยน)**
- `middleware.ts` — matcher ยกเว้น `note` เพิ่ม (หน้าแรก public) + redirect คนไม่มี cookie จาก `/note/login?next=…` เป็น `/note?sso=required` (ถอด `next` ทิ้ง) + **ตรวจ `oacCaseSig` ด้วย `verifyOacSession`** (middleware เป็น async — parse โครงสร้างเดิม + await verify คู่, ไม่ผ่าน = เหมือนไม่มี session) · **ปรับแผน 2026-10-30: matcher เพิ่มยกเว้น `note/dev`**
- `lib/session.ts` — `getSessionUser()` เป็น async + verify `oacCaseSig` (ไม่ผ่าน = `null`)
- `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` — **✅ ทำแล้ว 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)
**ถอด** (mock login ทั้งเส้น + หน้า login + UI เดิมของหน้าแรก — D7/D14)
- `app/note/login/page.tsx` — **ทั้งหน้า** (ไม่ rework เป็นหน้าข้อมูล — ปรับแผน 2026-10-29)
- `components/LoginForm.tsx` + การเรียก action `login` ของหน้า login
- `components/DotGrid.tsx` (กริดจุด decorative ของหน้า login — ไม่มีผู้ใช้ที่อื่น จึงตายตาม)
- action `login` + `MOCK_PASSWORD` ใน `app/actions/auth.ts` (**action `logout` คงไว้** — ล้าง cookie แล้วไป `/note`)
- `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)~~ **✅ ถอดแล้ว 2026-11-04**
## 7. Env
| env | default | หมายเหตุ |
|---|---|---|
| `DOCVER_API_KEY` / `DOCVER_TOKEN_SECRET` | — | ใช้ตัวที่มีอยู่ **ไม่มี env ใหม่ของกลุ่มนี้** (env ใหม่เดียวของแผน = `NOTE_SESSION_SECRET` ของ D16 ซึ่งเป็นของแอป note ไม่ผูกกับกลุ่ม docver) — `DOCVER_API_KEY` ยิงได้ทั้ง `/api/documents/token` (docver) และ `/api/note/auth/token` (mint login), `DOCVER_TOKEN_SECRET` เซ็น token ทั้งสองชนิด (แยกกันด้วย claim `purpose` — D4) · dev ต้องมี `DOCVER_TOKEN_SECRET` ใน `.env` ไม่งั้นเข้า annotate ไม่ได้ (mock login ไม่มีแล้ว ทางเข้า session เดียวคือลิงก์ — หน้าแรก `/note` public ตาม D15) · **ก่อน production: rotate `DOCVER_TOKEN_SECRET`** (ค่า hex หนึ่งชุดเคยถูก commit ไว้ใน comment ของ token route — ถือว่าหลุดแล้ว) |
| `NOTE_SESSION_SECRET` | — | **ถอดแล้ว 2026-10-30 (ฉบับ token)** — เคยเป็น HMAC ของ `oacCaseSig` รอบ cookie; ตอนนี้ session ใช้ `DOCVER_TOKEN_SECRET` ตัวเดียว (เซ็น login-token ของลิงก์ + เซ็น/ตรวจ session token ผ่าน `lib/note-token.ts`) |
| `ADDIN_TICKET_SECRET` (→ `SECRET` → `"super_secret"`) | ตาม chain เดิม | ใช้กับทาง ticket (B) — หลัง D13 ค่านี้เหลืออยู่แค่ e-Court (ตัว stamp) + server เรา ("ต้องตรงกันทั้งสาม" เหลือสองฝั่ง) · production ต้องเป็นค่าจริง (default รู้กันหมด = ปลอม ticket ได้) · ห้าม log ค่าดิบ · **2026-10-30: ทาง ticket ของ SSO ปฏิเสธค่า default `"super_secret"` เฉพาะ production — dev ใช้ default ทดสอบได้** |
## 8. Edge cases
- **มี cookie คนอื่นอยู่แล้ว** → ลิงก์ชนะเสมอ (เขียนทับ) — กติกาเดียว ไม่มี branch
- **`fileUrl` ไม่ผ่าน allowlist** → token ผ่านแต่ N8 จะตอบ error เดิมของหน้า `/note` (พฤติกรรมเดิม — ไม่ duplicate validation ที่ route)
- **browser เก่าไม่ส่ง `Sec-Fetch-Dest`** → ยอมผ่าน (ยอมรับ — login-CSRF เป็นความเสี่ยงรองใน intranet)
- **`t` ยาวผิดปกติ / param ปนมา** → allowlist ตัดทิ้ง/ปฏิเสธ (fail closed)
- **ผู้ใช้ refresh หน้า auth route ซ้ำ** (token ยังอยู่ใน history) → verify ใหม่ set cookie ใหม่ — idempotent · พ้น TTL แล้ว = เข้า `sso=expired` (มี session เดิมอยู่ก็ไม่เป็นไร — middleware ไม่ได้ดักคนมี cookie)
- **webview ไม่รับ cookie** — n/a (browser ปกติ)
- **cookie หมดอายุกลางงาน** → middleware redirect `/note?sso=required` → ผู้ใช้กลับ e-Court คลิกลิงก์ใหม่
- **dev/Playwright ที่เคย login ผ่านฟอร์ม mock** → เปลี่ยนเป็น mint ลิงก์ด้วยสคริปต์แล้วเปิด URL นั้นก่อนเข้าหน้าอื่น (แทน step "เลือก user + Demo1234" เดิมในทุกสคริปต์ทดสอบ)
- **ticket exp ยาวผิดปกติ (เช่น 1 ชม.)** — server ตรวจแค่ "ยังไม่หมด" ไม่บังคับ cap (ฝั่ง mint เป็น e-Court) — กติกา ≤600 วิ อยู่ที่ §2 · **มี credential สองชุดใน URL พร้อมกัน** = invalid ตาม D12
- **log** — route นี้และ token route ห้าม log query string/token/secret ดิบ (ตัวแปร env logger ปัจจุบัน = console — บังคับใน code review)
- **คนไม่มี session กด "เปิดเอกสาร" บนหน้าแรก** → middleware เด้งกลับ `/note?sso=required` พร้อมแบนเนอร์ "กรุณาเข้าสู่ระบบผ่านลิงก์ e-Court" (D15 — ปุ่มเดียวกันคนมี session ใช้ต่อเข้า viewer ได้ปกติ)
- **ไฟล์ samples หายจาก disk** (ยังไม่ `npm run seed-samples`) → กด "เปิดเอกสาร" แล้ว N8 ตอบ error เดิมที่หน้า annotate (ไม่ duplicate validation ที่หน้าแรก)
- **เปิด `/note?fileUrl=…&fileId=…&typeId=…` เองโดยไม่มี session** → redirect `/note?sso=required` **โดยไม่เขียน DB ใด ๆ** (ไม่มี row ใหม่ — `pdf_url`/`type_id` ของ fileId ที่มีอยู่ไม่ถูก refresh ด้วยมือแปลกปลอม) · entry ที่สอง `/note/annotate?fileUrl=` ปลอดภัยอยู่แล้วเพราะอยู่ใต้ middleware ที่ยังบังคับ session
- **`?sso=` ค่าอื่นที่ไม่รู้จัก** → ไม่แสดงแบนเนอร์ render หน้าปกติ (กัน URL ปลอมขู่)
- **cookie คู่ไม่ครบ** (มี `oacCase` ไม่มี `oacCaseSig` หรือกลับกัน — D16) = ไม่มี session ทุกกรณี — รวมเคส cookie เดิมที่ตกค้างจาก mock login ก่อน deploy: ถือว่าไม่มี session ทุกคน เข้าใหม่ผ่านลิงก์ e-Court ครั้งเดียว (เกิดครั้งเดียวตอน roll แผนนี้)
- **ปลอม/แก้ cookie** (D16) — เซ็นครอบ payload เต็ม + exp: แก้ `u_userid` แก้ `exp` หรือเซ็นเองโดยไม่รู้ secret = verify ไม่ผ่านทั้งหมด (จุดตรวจ: middleware + `getSessionUser` — ครบทุกจุดที่อ่าน identity อยู่แล้ว)
- **rotate `NOTE_SESSION_SECRET`** → session ทั้งหมดตายทันที (เท่า force logout ทั้งระบบ — ไม่มีข้อมูลเสียหาย ผู้ใช้กลับ e-Court กดลิงก์ใหม่)
- **exp ใน `oacCaseSig` หมดกลางงาน** → เหมือน cookie หมดอายุปัจจุบัน: middleware เด้ง `/note?sso=required` — exp กับ maxAge ตั้งพร้อมกัน 8 ชม. เท่ากัน ในทางปฏิบัติแทบไม่เจอต่าง (ต่างแค่กรณี browser ค้าง cookie ไว้/ถูกปรับ maxAge เอง ซึ่ง exp ฝั่ง server ชนะ)
## 9. Verify criteria
1. `npx vitest run` ผ่านทั้งชุด (เพิ่ม `tests/lib/note-auth.test.ts` + เคสใหม่ของ `docver-token` — เคสเดิมไม่พัง)
2. `npx tsc --noEmit` ผ่าน
3. curl regression:
- (ก) mint: `x-api-key` + `{userid,username,fid,ttl_seconds:300,purpose:"login"}` ที่ **`/api/auth/token`** → ได้ token ที่ decode แล้วมี `purpose:"login"` (§0.6 — เส้น mint เดิม `/api/note/auth/token` ถอดแล้ว)
- (ข) `GET /api/note/auth/e-court?fileUrl=…&t=<login token>` → 302 + `Set-Cookie oacCase` + Location เป็น `/note?…` **ไม่มี `t`** + `Cache-Control: no-store`
- (ค) รอให้ TTL หมด (ตั้ง 60 วิ) → 302 `/note?sso=expired`
- (ง) เอา token purpose=docver มาใช้ที่ route → `sso=invalid` · เอา login token ไป `GET /api/documents/versions` → 401
- (จ) `ttl_seconds` 601 ที่เส้น mint ใหม่ → 400 · **regression docver: mint `/api/documents/token` แบบเดิม (ไม่มี purpose) ยังผ่าน + ส่ง `purpose:"login"` ไปที่นั่น = 400**
- (ฉ) action `login` ไม่ export อีกต่อไป · `/note/login` **404** · แบนเนอร์ `sso=` ครบ 4 ค่าแสดงบน `/note` (expired/invalid/error จาก auth route · required จาก middleware)
- (ญ) ทาง ticket: mint ด้วย `make-login-link --mode ticket` → ผ่านเหมือน (ข) · แก้ HMAC 1 ตัวอักษร → `sso=invalid` · exp อดีต → `sso=expired` · ส่ง `t` และ ticket มาพร้อมกัน → `sso=invalid`
- (ฐ) regression add-in หลัง D13: ชุด test wordaddin ผ่าน + sideload ทดสอบใน Word จริง (ไฟล์ ticket ถูกต้อง → ใช้ได้เหมือนเดิม · แก้ ticket_data 1 ตัวอักษร → no-access "ticket ไม่ถูกต้องหรือหมดอายุ" จาก server)
- (ฎ) หน้าแรกใหม่ D14/D15 (ปรับตาม 2026-10-30): ไม่มี session เปิด `/note` = เห็น landing เรียบ และ `/note/dev` = เห็น dropdown 2 ระดับ (public ทั้งคู่ ไม่โดน redirect) · เลือกกลุ่ม → ไฟล์ แล้วกด "เปิดเอกสาร" บน `/note/dev` โดยไม่มี session = เด้งกลับ `/note?sso=required` มีแบนเนอร์ · มี session = ลง viewer ของไฟล์ที่เลือก · middleware ไม่จับ `/note` และ `/note/dev` แต่ยังจับ `/note/annotate/…` · **ไม่มี session เปิด `/note?fileUrl=…&fileId=<ที่มีอยู่แล้ว>` ตรง ๆ = redirect `sso=required` และ DB ไม่เปลี่ยน** (ไม่มี row ใหม่, `pdf_url`/`type_id` เดิม — เทียบก่อน/หลัง)
- (ฏ) D16 session signing: ผ่านลิงก์จริงแล้วตั้ง cookie เดียวแบบเดิม (`oacCase` ไม่มี `oacCaseSig` — คัดลอกค่าจาก DevTools ใส่เอง) → เข้า annotate ไม่ได้ redirect `sso=required` · มีคู่ครบแต่แก้ `u_userid` ใน `oacCase` 1 ตัวอักษร → ไม่ผ่านเช่นกัน · แก้เฉพาะ `exp` ใน `oacCaseSig` ยืดอายุออก → ไม่ผ่าน (exp ถูกเซ็น) · response ของ auth route มี `Set-Cookie` **สองตัว** + `Secure` เมื่อรัน production (ตรวจจาก response header) · `NOTE_SESSION_SECRET` ว่าง → auth route ตอบ `sso=error` และทุกหน้ามองเป็นไม่มี session · `getSessionUser` async แล้ว `npx tsc --noEmit` ผ่าน (call site ครบ)
4. ทดสอบเบราว์เซอร์ด้วย `scripts/make-login-link.mjs`: วางลิงก์ → ลง viewer ชื่อผู้ใช้ = `uname` · เปิดลิงก์ uid คนละคนต่อ → session สลับตามลิงก์ · ลิงก์หมดอายุ → กลับหน้าแรกพร้อมแบนเนอร์ "ลิงก์หมดอายุ — กลับ e-Court กดเปิดใหม่" · เข้า `/note` ตรง ๆ ไม่มี session → landing เรียบ (public ไม่มีแบนเนอร์) · ที่ `/note/dev` กดปุ่ม "เปิดเอกสาร" → แบนเนอร์ "กรุณาเข้าสู่ระบบผ่าน e-Court" (`sso=required`)
5. **(§0.6)** ทางเข้ารวม: mint `purpose:"login"` ที่ `/api/auth/token` → ลิงก์ `/note?t=` → NoteHome แลกที่ `/api/auth/session` (network tab) → เข้า viewer · alias `/api/documents/token` body เดิมยังผ่าน (กลุ่ม docver คงไว้) · **เส้น note เก่า `/api/note/auth/token` + `/api/note/auth/session` ตอบ 404** · `/api/auth/session` กับลิงก์ fid-only ของ staging → ได้ `fileId` (N11 ครบ) · **หมดอายุกลาง session**: แก้ `expMs` ใน localStorage เป็นอดีต → สร้าง annotation → ถูกพาไป `/note?sso=expired` + localStorage `note-auth` ถูกล้าง (ไม่ใช่ error toast เปล่า ๆ)
6. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
## 10. ลำดับงาน
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 — ภายหลังถอดตาม §0.6 รอบสอง) → verify 9.3 (ก)(จ) แล้ว `app/api/note/auth/e-court/route.ts` → verify 9.3 (ข–ง)
4. ถอด mock login ทั้งเส้น (`LoginForm`/action `login`/`MOCK_USERS` + **หน้า `/note/login` ทั้งหน้า**) + rework หน้าแรก `/note` ใหม่ (D14) + middleware (D15) + งาน session signing ของ D16 (`utils/user.ts`/`lib/session.ts`/logout/หน้า caller `await`) → verify 9.3 (ฉ)(ฎ)(ฏ) + 9.4
5. `scripts/make-login-link.mjs` → verify 9.4
6. `lib/openapi.ts` + `.env.example` + `CLAUDE.md` sync → verify 9.1–9.5
## 11. Out of scope
- **add-in ย้ายมาใช้โค้ดกลางของ §0.6** (รอบหน้า — ผู้ใช้กำหนด "รอบนี้เอาแค่ของ note มาใช้ก่อน"): `wordaddin/services/ticketService.ts` internals → `utils/auth-store.ts` + prune token หมดอายุออกจาก map ตอนโหลด + ลบ entry ของ fid หลัง renew จาก ticket ล้ม — wire contract/flow/UI ไม่เปลี่ยน (`utils/auth-store` ออกแบบรองรับอยู่แล้ว)
- **refresh token** — วันหนึ่งอยากให้ browser/add-in ต่ออายุโดยไม่กลับ e-Court: เพิ่ม grant ใหม่ที่ `/api/auth/token` (มาตรฐาน OAuth2 + rotation) — response shape ปัจจุบันรองรับอยู่แล้ว · ข้อควรชั่ง: refresh token ใน localStorage ของ browser = ยืด session โดยไม่มีผู้ใช้ยืนยัน ขัดเหตุผลความปลอดภัยของ TTL 8 ชม. เดิม
- ~~แก้ secret รั่ยใน bundle ของ add-in~~ — ย้ายเข้าแผนนี้เป็น **D13** แล้ว (ทาง ticket ทำให้ secret ตัวนี้กลายเป็นจุดอ่อนของ login โดยตรง จึงตัดออกก่อนเปิดทาง ticket)
- revoke/refresh token, logout ระดับ e-Court, roles/สิทธิ์จริง, ผูก session กับ device
- OAuth/OIDC เต็มรูปแบบ (e-Court เป็น IdP) — ไปทางนี้เมื่อมีหลายแอปของศาลต้อง SSO
- ย้ายไปลายเซ็น asymmetric (Ed25519) — upgrade path หากวันหนึ่งต้องการ verifier ถือแค่ public key