เปลี่ยน session ของแอป note จาก cookie คู่ oacCase/oacCaseSig มาเป็น access_token แบบ add-in ตามคำขอผู้ใช้ (2026-10-30): - POST /api/note/auth/session แลก credential ของลิงก์ e-Court (JWT ทาง A / ticket ทาง B) เป็น access_token purpose=login อายุ 8 ชม. พร้อมลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server (fileId จาก fid) - token เก็บ localStorage "note-auth" (lib/note-session.ts) + Bearer ผ่าน noteAuthFetch · Server Actions ทุกตัวรับ token เป็น param แรก และแกะ identity เอง (lib/note-token.ts) — ไม่เชื่อ userId จาก client - หน้า protected เป็น shell + client gate (NoteHome bootstrap ที่ /note, AnnotateView + GET /api/note/files/[id]/bootstrap) - ถอด: middleware.ts, lib/session.ts, utils/user.ts, action logout, GET /api/note/auth/e-court, entry /note/annotate?fileUrl= - ทาง ticket ปฏิเสธ secret default "super_secret" เฉพาะ production - GET /api/note/files/open: ลิงก์เปิดไฟล์ตัวอย่างแบบ /tmpl (path ใน เครื่อง /samples เท่านั้น) - หน้า /note/dev: ลิงก์ A Link Get(new) ที่ mint credential แล้วยิง เข้า flow SSO จริง (JWT/ticket สลับได้) + section Flow + เรียบเรียง ภาษาให้อ่านง่าย · หน้า /note เป็นหน้าข้อมูลระบบ - แก้ตาม code review: redo closure เรียก action ด้วย signature เก่า, bootstrap timeout + strip credential ที่ผิดรูป, แยก no-session ออก จาก network error, ห้ามอักขระ & = + % ใน identity, ตรวจ purpose ก่อน exp ของ login-token, audit log strip อักขระควบคุม - เอกสาร: docs/PLAN-SSO.md §0.5 (ฉบับ token), CLAUDE.md, .env.example ทดสอบ: tsc ผ่าน · vitest ผ่านทั้งชุด · ทดสอบจริงผ่าน Playwright (SSO → viewer → เปลี่ยนสี annotation → undo → redo ครบวงจร) Co-Authored-By: Claude Code <noreply@anthropic.com>
58 KiB
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+ envDOCVER_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 (proptoken) · ไม่มี/หมดอายุ = หน้า 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 ในเครื่องเท่านั้น,registerExternalFiletrusted: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 รอง = ไฟล์ในกลุ่ม (reusecomponents/utils/DependentDropdown.tsx— ปรับ class ให้เป็น Genesis: ตัวเดิมใช้ rawgray-*ซึ่งห้ามตาม DESIGN.md) + ปุ่ม "เปิดเอกสาร" · ไม่มีส่วนอื่นของหน้าเดิมเหลืออยู่ -
รายการเอกสาร fixed ในโค้ด 2 กลุ่ม × 2 ไฟล์ — จาก PDF ตัวอย่างที่มีอยู่จริงของแอป note (
public/note/samples/):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 ขยะ + refreshpdf_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-linkmint ฝั่ง server (secret ไม่ผ่านหน้าเบราว์เซอร์) คืน URL เต็มให้กด "เปิดลิงก์" — เปิดด้วย envNOTE_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+ actionuploadFile(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:issueDocverTokendefault แนบpurpose:"docver"ให้ token ใหม่โดย route docver ไม่ต้องแต้ และrequireDocverBearer(ทุก endpoint กลุ่ม docver) rejectpurpose === "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)
// 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 มาจาก--ttldefault 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 (defaultpurpose:"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/devlib/session.ts—getSessionUser()เป็น async + verifyoacCaseSig(ไม่ผ่าน =null)app/note/annotate/[id]/page.tsx—await getSessionUser()ตาม signature ใหม่app/actions/auth.ts— actionlogoutเคลียร์ทั้งสอง cookie (oacCase+oacCaseSig) + ปลายทาง redirect เปลี่ยนจากหน้า login เป็น/noteutils/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 ·/notepublic + หน้าเลือกเอกสารใหม่ · 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+ การเรียก actionloginของหน้า logincomponents/DotGrid.tsx(กริดจุด decorative ของหน้า login — ไม่มีผู้ใช้ที่อื่น จึงตายตาม)- action
login+MOCK_PASSWORDในapp/actions/auth.ts(actionlogoutคงไว้ — ล้าง cookie แล้วไป/note) MOCK_USERSในutils/user.ts+ comment "ใช้ร่วมกันสองที่" ที่ตายตาม (OAC_CASE_COOKIE/parseOacCase/buildOacCaseCookie/SESSION_MAX_AGEคงไว้ทั้งหมด — auth route ใช้อยู่)components/UploadFileForm.tsx+ actionuploadFileใน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
npx vitest runผ่านทั้งชุด (เพิ่มtests/lib/note-auth.test.ts+ เคสใหม่ของdocver-token— เคสเดิมไม่พัง)npx tsc --noEmitผ่าน- 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_seconds601 ที่เส้น mint ใหม่ → 400 · regression docver: mint/api/documents/tokenแบบเดิม (ไม่มี purpose) ยังผ่าน + ส่งpurpose:"login"ไปที่นั่น = 400 - (ฉ) action
loginไม่ export อีกต่อไป ·/note/login404 · แบนเนอร์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=<ที่มีอยู่แล้ว>ตรง ๆ = redirectsso=requiredและ DB ไม่เปลี่ยน (ไม่มี row ใหม่,pdf_url/type_idเดิม — เทียบก่อน/หลัง) - (ฏ) D16 session signing: ผ่านลิงก์จริงแล้วตั้ง cookie เดียวแบบเดิม (
oacCaseไม่มีoacCaseSig— คัดลอกค่าจาก DevTools ใส่เอง) → เข้า annotate ไม่ได้ redirectsso=required· มีคู่ครบแต่แก้u_useridในoacCase1 ตัวอักษร → ไม่ผ่านเช่นกัน · แก้เฉพาะexpในoacCaseSigยืดอายุออก → ไม่ผ่าน (exp ถูกเซ็น) · response ของ auth route มีSet-Cookieสองตัว +Secureเมื่อรัน production (ตรวจจาก response header) ·NOTE_SESSION_SECRETว่าง → auth route ตอบsso=errorและทุกหน้ามองเป็นไม่มี session ·getSessionUserasync แล้วnpx tsc --noEmitผ่าน (call site ครบ)
- (ก) mint:
- ทดสอบเบราว์เซอร์ด้วย
scripts/make-login-link.mjs: วางลิงก์ → ลง viewer ชื่อผู้ใช้ =uname· เปิดลิงก์ uid คนละคนต่อ → session สลับตามลิงก์ · ลิงก์หมดอายุ → กลับหน้าแรกพร้อมแบนเนอร์ "ลิงก์หมดอายุ — กลับ e-Court กดเปิดใหม่" · เข้า/noteตรง ๆ ไม่มี session → landing เรียบ (public ไม่มีแบนเนอร์) · ที่/note/devกดปุ่ม "เปิดเอกสาร" → แบนเนอร์ "กรุณาเข้าสู่ระบบผ่าน e-Court" (sso=required) - รัน
plan-reviewer+code-reviewerตามกติกา.claude/
10. ลำดับงาน
- add-in D13 — ถอด client HMAC check +
NEXT_PUBLIC_ADDIN_TICKET_SECRETออกจาก bundle (+ ปรับ tests/docs) → verify 9.3 (ฐ) lib/docver-token.ts(purpose + ttl) + tests → verify 9.1 บางส่วนlib/note-auth.ts+ tests (รวมsignOacSession/verifyOacSessionของ D16)app/api/note/auth/token/route.ts(mint) → verify 9.3 (ก)(จ) แล้วapp/api/note/auth/e-court/route.ts→ verify 9.3 (ข–ง)- ถอด mock login ทั้งเส้น (
LoginForm/actionlogin/MOCK_USERS+ หน้า/note/loginทั้งหน้า) + rework หน้าแรก/noteใหม่ (D14) + middleware (D15) + งาน session signing ของ D16 (utils/user.ts/lib/session.ts/logout/หน้า callerawait) → verify 9.3 (ฉ)(ฎ)(ฏ) + 9.4 scripts/make-login-link.mjs→ verify 9.4lib/openapi.ts+.env.example+CLAUDE.mdsync → 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