# 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/ ``` - **การเขียนทุกครั้งผ่าน 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` + `` 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 เก่า) = ยอมผ่าน · กันเคสเว็บอื่นฝัง `` ดัน 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`** = `.` เซ็นครอบ **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: content-type: application/json {"userid":"","username":"","fid":"","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=&typeId=<กลุ่ม ทางเลือก>&t= ``` ทาง B (ลิงก์ที่สร้างล่วงหน้า/แบบ offline — stamp ด้วยตัวเดียวกับ .docx, format ตาม PLAN-ADDIN-AUTH.md §2): ``` {BASE}/note?fileUrl=…&typeId=…&ticket_data=&HmacSha256= ``` กติกา: - ทาง 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/ → AnnotateView (client gate) → GET /api/note/files//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=` → 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