eCourt-note/docs/PLAN-SSO.md
Your Name d37bc2a3f0 feat(sso): switch note session from cookies to token
เปลี่ยน 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>
2026-10-30 18:05:36 +07:00

58 KiB
Raw Blame History

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/):

    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)

// 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. ลำดับงาน

  1. add-in D13 — ถอด client HMAC check + NEXT_PUBLIC_ADDIN_TICKET_SECRET ออกจาก bundle (+ ปรับ tests/docs) → verify 9.3 (ฐ)
  2. lib/docver-token.ts (purpose + ttl) + tests → verify 9.1 บางส่วน
  3. lib/note-auth.ts + tests (รวม signOacSession/verifyOacSession ของ D16)
  4. app/api/note/auth/token/route.ts (mint) → verify 9.3 (ก)(จ) แล้ว app/api/note/auth/e-court/route.ts → verify 9.3 (ข–ง)
  5. ถอด 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
  6. scripts/make-login-link.mjs → verify 9.4
  7. 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