refactor(auth): consolidate auth under /api/auth/* endpoints
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Failing after 30s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Failing after 1s

- ยุบ route auth ที่เคย copy กันจน drift (flow N11 หายจาก /api/auth/session)
  เหลือโค้ด inline สองเส้นเดียว: POST /api/auth/token (ทางเข้า 3: x-api-key
  docver / x-api-key purpose:"login" mint ลิงก์ note / ticket ของ add-in)
  + POST /api/auth/session (แลก credential + register N8 + N11 fid-only)
- POST /api/documents/token คงไว้เป็น alias re-export (กลุ่ม docver/e-Court
  เดิม — contract เดิมเป๊ะ, มี test toBe กัน copy แยกชุดกลับมา)
- ถอดเส้นฝั่ง note ทั้งสอบ: /api/note/auth/token + /api/note/auth/session
  (e-Court ต้องย้ายมาทางเข้ารวม — แจ้งทีม e-Court ก่อน deploy)
- utils/auth-store.ts: primitives กลางฝั่ง client (jwtExp/isExpired/
  localStorage store + memory fallback) — lib/note-session ใช้ร่วม
  (expMs decode จาก JWT payload = นาฬิกา server) และวางไว้ให้ wordaddin
  ย้ายมาใช้รอบหน้าได้ (รอบนี้ไม่แต้ wordaddin ตามข้อตกลง)
- นโยบาย token หมดอายุกลาง session: PDFViewer reportActionError จับ
  auth-error ของ Server Action ทุกจุด → ล้าง session แล้วพาไป
  /note?sso=expired (จบที่ gate เดียวกับ bootstrap 401)
- openapi: ทางเข้า purpose:"login" + schema LoginTokenRequest + ลบ path
  เส้นที่ถอด + test กันเส้นตายหลุดค้างใน spec
- เอกสาร sync: PLAN-SSO §0.6, CLAUDE.md, PLAN-STAGED, .env.example, /note/dev
- tests: ย้าย/เพิ่มชุด auth (50 เคสกลุ่ม auth ผ่าน, ทั้งชุด 444 ผ่าน) +
  e2e ครบทั้ง API (mint/alias/ticket/404 เส้นเก่า/N11/D4) และ browser

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-11-09 14:30:29 +07:00
parent c57a75e36a
commit 5121970f31
26 changed files with 625 additions and 523 deletions

View file

@ -1,11 +1,15 @@
// POST /api/documents/token — contract ทางเข้า ticket ฉบับ 2026-11-04 + regression ทางเข้า x-api-key
// route pure ล้วนหลังถอด hasVersionByUser (ไม่ import @/db/minio อีกต่อไป) จึง unit test
// ได้โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า route
// ไม่แตะ DB (เลิก "ตรวจเคยมี version" ของ requirement ขั้น 7 ฉบับ 2026-10-28)
// POST /api/auth/token — ทางเข้า token หลักของระบบ (2026-11-09 รอบสอง: ถอด
// /api/note/auth/token — ทาง mint login-token รวมมาที่นี่ผ่าน purpose:"login"
// ส่วน /api/documents/token ของกลุ่ม docver **คงไว้** เป็น alias re-export ของ
// เส้นนี้) · contract ทางเข้า ticket ฉบับ 2026-11-04 + ทาง x-api-key แยกตาม
// body purpose · route pure ล้วน (ไม่ import @/db/minio) จึง unit test ได้โดย
// ไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า
// route ไม่แตะ DB
import { createHmac } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { POST } from "@/app/api/documents/token/route";
import { POST } from "@/app/api/auth/token/route";
import { POST as documentsTokenPOST } from "@/app/api/documents/token/route";
/** secret ตาม chain ของ addinTicketSecret — ลบ env ทั้งสองตัวใน setup เพื่อใช้ค่า default */
const SECRET_DEFAULT = "super_secret";
@ -35,7 +39,7 @@ function futureExp(): number {
}
function makeRequest(body: BodyInit, headers?: Record<string, string>): Request {
return new Request("http://localhost/docedit/api/documents/token", {
return new Request("http://localhost/docedit/api/auth/token", {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body,
@ -78,7 +82,7 @@ afterEach(() => {
}
});
describe("POST /api/documents/token — ทางเข้า ticket (contract 2026-11-04)", () => {
describe("POST /api/auth/token — ทางเข้า ticket (contract 2026-11-04)", () => {
it("ticket ถูกทั้งลายเซ็น+อายุ → 200 + JWT claims จาก ticket + expires_in 604800 (default 7 วัน)", async () => {
const { ticketData, ticketHmac } = mintTicket(futureExp(), { fid: "F9", uid: "u1", uname: "นายทดสอบ" });
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
@ -176,7 +180,7 @@ describe("POST /api/documents/token — ทางเข้า ticket (contract 2
});
});
describe("POST /api/documents/token — ทางเข้า x-api-key (regression ไม่เปลี่ยน)", () => {
describe("POST /api/auth/token — ทางเข้า x-api-key แบบ docver (contract เดิมของ /api/documents/token)", () => {
it("key ถูก + body claims → 200 ได้ token อายุ default ใหม่ 604800", async () => {
process.env.DOCVER_API_KEY = API_KEY;
const response = await POST(
@ -213,3 +217,79 @@ describe("POST /api/documents/token — ทางเข้า x-api-key (regress
expect(response.status).toBe(401);
});
});
describe("POST /api/auth/token — ทางเข้า purpose:'login' (mint login-token ลิงก์ SSO ของ note)", () => {
it("x-api-key + purpose:login + ttl_seconds → 200 login-token อายุตาม ttl (template:null)", async () => {
process.env.DOCVER_API_KEY = API_KEY;
const response = await POST(
makeRequest(JSON.stringify({ userid: "u1", username: "นายทดสอบ", fid: "F1", ttl_seconds: 120, purpose: "login" }), {
"x-api-key": API_KEY,
}),
);
expect(response.status).toBe(200);
const data = (await response.json()) as { access_token: string; expires_in: number };
expect(data.expires_in).toBe(120);
const payload = jwtPayload(data.access_token);
expect(payload.purpose).toBe("login");
expect(payload.template).toBeNull();
expect((payload.exp as number) - (payload.iat as number)).toBe(120);
});
it("purpose:login ไม่ส่ง ttl_seconds → default 300 (D5)", async () => {
process.env.DOCVER_API_KEY = API_KEY;
const response = await POST(
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1", purpose: "login" }), {
"x-api-key": API_KEY,
}),
);
expect(response.status).toBe(200);
expect(((await response.json()) as { expires_in: number }).expires_in).toBe(300);
});
it("purpose ค่าอื่น (docver/ตัวเลข) → 400 (จากทาง x-api-key ไม่ใช่ทาง ticket)", async () => {
process.env.DOCVER_API_KEY = API_KEY;
for (const purpose of ["docver", "admin", 42]) {
const response = await POST(
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1", purpose }), {
"x-api-key": API_KEY,
}),
);
expect(response.status, `purpose=${JSON.stringify(purpose)}`).toBe(400);
}
});
it("purpose:login + template / ttl นอกช่วง 60–600 → 400 (allowlist ของ parseLoginTokenBody)", async () => {
process.env.DOCVER_API_KEY = API_KEY;
for (const body of [
JSON.stringify({ userid: "u1", username: "n", fid: "F1", template: "T12", purpose: "login" }),
JSON.stringify({ userid: "u1", username: "n", fid: "F1", ttl_seconds: 601, purpose: "login" }),
JSON.stringify({ userid: "u1", username: "n", fid: "F1", ttl_seconds: 59, purpose: "login" }),
]) {
const response = await POST(
makeRequest(body, { "x-api-key": API_KEY }),
);
expect(response.status, body).toBe(400);
}
});
});
describe("alias /api/documents/token (กลุ่ม docver คงไว้ — กัน copy กลับมา drift อีก)", () => {
it("export function เดียวกันกับ /api/auth/token (re-export)", () => {
expect(documentsTokenPOST).toBe(POST);
});
it("body เดิมของ e-Court (ไม่มี purpose) ผ่านเส้นเก่ายังได้ docver-token ตาม contract เดิม", async () => {
process.env.DOCVER_API_KEY = API_KEY;
const response = await documentsTokenPOST(
new Request("http://localhost/docedit/api/documents/token", {
method: "POST",
headers: { "content-type": "application/json", "x-api-key": API_KEY },
body: JSON.stringify({ userid: "u1", username: "n", fid: "F1", template: "T12" }),
}),
);
expect(response.status).toBe(200);
const payload = jwtPayload(((await response.json()) as { access_token: string }).access_token);
expect(payload.purpose).toBe("docver");
expect(payload.template).toBe("T12");
});
});