Merge branch 'dev' into working
# Conflicts: # README.md
This commit is contained in:
commit
02add92e3c
57 changed files with 3211 additions and 1363 deletions
314
docs/PLAN-SSO.md
Normal file
314
docs/PLAN-SSO.md
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
# 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 ได้)
|
||||
|
||||
**ผลพลอยได้:** ไม่มีเงื่อนไขค้างกับทีม 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/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 นี้
|
||||
|
||||
## 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) |
|
||||
| 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 ก็แข็งขึ้นตาม |
|
||||
| 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/note/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}
|
||||
|
||||
→ 200 {"access_token":"…","token_type":"Bearer","expires_in":300}
|
||||
```
|
||||
|
||||
(เส้นนี้เป็น login-token เอกสิทธิ์ — ไม่ต้องส่ง `purpose` ใน body · แยกจาก `/api/documents/token` ของกลุ่ม docver ไม่ปนกัน)
|
||||
|
||||
**ขั้น 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 ทางเข้า x-api-key, ไม่ import `@/db`)
|
||||
|
||||
| เคส | พฤติกรรม |
|
||||
|---|---|
|
||||
| `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/note/auth/session` (ฉบับ token 2026-10-30 — แทน GET e-court ที่ถอดแล้ว)
|
||||
|
||||
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) |
|
||||
| `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/note/auth/session {credential + fileUrl + typeId}
|
||||
→ 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") + 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 หมดอายุกลางวัน = action โยน error/หน้า 401 → กลับ 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:…` เท่านั้น)
|
||||
|
||||
## 6. ไฟล์ที่สร้าง/แต้/ถอด
|
||||
|
||||
**สร้าง**
|
||||
|
||||
- `app/api/note/auth/token/route.ts` — §3.1 mint login token (runtime nodejs, force-dynamic)
|
||||
- `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`
|
||||
- `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)
|
||||
|
||||
## 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}` ที่ **`/api/note/auth/token`** → ได้ token ที่ decode แล้วมี `purpose:"login"` · ส่ง `purpose` มาด้วย = 400
|
||||
- (ข) `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. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
|
||||
|
||||
## 10. ลำดับงาน
|
||||
|
||||
0. **add-in D13** — ถอด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก bundle (+ ปรับ tests/docs) → 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 (ข–ง)
|
||||
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
|
||||
|
||||
- ~~แก้ 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue