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
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:
parent
c57a75e36a
commit
5121970f31
26 changed files with 625 additions and 523 deletions
295
tests/app/api/auth/token.test.ts
Normal file
295
tests/app/api/auth/token.test.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// 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/auth/token/route";
|
||||
import { POST as documentsTokenPOST } from "@/app/api/documents/token/route";
|
||||
|
||||
/** secret ตาม chain ของ addinTicketSecret — ลบ env ทั้งสองตัวใน setup เพื่อใช้ค่า default */
|
||||
const SECRET_DEFAULT = "super_secret";
|
||||
const TOKEN_SECRET = "unit-test-token-secret";
|
||||
const API_KEY = "unit-test-api-key";
|
||||
|
||||
const SAVED_ENV: Record<string, string | undefined> = {};
|
||||
|
||||
function mintTicket(
|
||||
exp: number,
|
||||
overrides?: { fid?: string; uid?: string; uname?: string; tid?: string },
|
||||
): { ticketData: string; ticketHmac: string } {
|
||||
const ticket = {
|
||||
exp,
|
||||
fid: overrides?.fid ?? "F1",
|
||||
...(overrides?.tid !== undefined ? { tid: overrides.tid } : { tid: "T12" }),
|
||||
uid: overrides?.uid ?? "oom",
|
||||
uname: overrides?.uname ?? "สรวิชญ์",
|
||||
};
|
||||
const ticketData = JSON.stringify(ticket);
|
||||
const ticketHmac = createHmac("sha256", SECRET_DEFAULT).update(ticketData, "utf8").digest("hex");
|
||||
return { ticketData, ticketHmac };
|
||||
}
|
||||
|
||||
function futureExp(): number {
|
||||
return Math.floor(Date.now() / 1000) + 3_600;
|
||||
}
|
||||
|
||||
function makeRequest(body: BodyInit, headers?: Record<string, string>): Request {
|
||||
return new Request("http://localhost/docedit/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
function ticketBody(ticketData: string, ticketHmac: string): string {
|
||||
return JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac });
|
||||
}
|
||||
|
||||
/** decode JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ assert claims/exp เท่านั้น */
|
||||
function jwtPayload(token: string): Record<string, unknown> {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) throw new Error("token ไม่มี payload");
|
||||
return JSON.parse(
|
||||
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of [
|
||||
"ADDIN_TICKET_SECRET",
|
||||
"SECRET",
|
||||
"DOCVER_API_KEY",
|
||||
"DOCVER_TOKEN_SECRET",
|
||||
"DOCVER_TOKEN_TTL_SECONDS",
|
||||
]) {
|
||||
SAVED_ENV[key] = process.env[key];
|
||||
}
|
||||
process.env.DOCVER_TOKEN_SECRET = TOKEN_SECRET;
|
||||
delete process.env.ADDIN_TICKET_SECRET; // ใช้ default "super_secret" ของ addinTicketSecret
|
||||
delete process.env.SECRET;
|
||||
delete process.env.DOCVER_TOKEN_TTL_SECONDS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(SAVED_ENV)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
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)));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = (await response.json()) as { access_token: string; token_type: string; expires_in: number };
|
||||
expect(data.token_type).toBe("Bearer");
|
||||
expect(data.expires_in).toBe(604_800);
|
||||
|
||||
const payload = jwtPayload(data.access_token);
|
||||
expect(payload.userid).toBe("u1");
|
||||
expect(payload.username).toBe("นายทดสอบ");
|
||||
expect(payload.fid).toBe("F9");
|
||||
expect(payload.template).toBe("T12");
|
||||
expect(payload.purpose).toBe("docver");
|
||||
expect((payload.exp as number) - (payload.iat as number)).toBe(604_800);
|
||||
});
|
||||
|
||||
it("body ไม่ใช่ JSON → 400", async () => {
|
||||
const response = await POST(makeRequest("not-json{"));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("ไม่ส่ง ticket มาเลย / ค่าว่าง → 400 (ย้ายจาก 401 ตาม contract ฟอร์แม็ตพัง)", async () => {
|
||||
for (const body of ["{}", JSON.stringify({ ticket_data: "" , HmacSha256: "ab".repeat(32) })]) {
|
||||
const response = await POST(makeRequest(body));
|
||||
expect(response.status, body).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("field นอก allowlist / ผิดชนิด / ยาวเกิน cap → 400", async () => {
|
||||
for (const body of [
|
||||
JSON.stringify({ ticket_data: "x", HmacSha256: "y", oops: 1 }),
|
||||
JSON.stringify({ ticket_data: 42, HmacSha256: "y" }),
|
||||
JSON.stringify({ ticket_data: "x".repeat(2049), HmacSha256: "y" }),
|
||||
]) {
|
||||
const response = await POST(makeRequest(body));
|
||||
expect(response.status, body).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("ลายเซ็นผิด (แก้ ticket_data / digest เพี้ยน / secret ของ server ต่าง) → 401 ข้อความเดียวกันทุกเคส", async () => {
|
||||
const { ticketData, ticketHmac } = mintTicket(futureExp());
|
||||
const tampered = ticketData.replace("F1", "F2"); // แก้ 1 ตัว — HMAC ไม่ตรง
|
||||
|
||||
const cases: { label: string; body: string; secretEnv?: [string, string] }[] = [
|
||||
{ label: "แก้ ticket_data", body: ticketBody(tampered, ticketHmac) },
|
||||
{ label: "digest ไม่ใช่ hex", body: ticketBody(ticketData, "zz".repeat(32)) },
|
||||
{
|
||||
label: "secret ฝั่ง server ต่าง",
|
||||
body: ticketBody(ticketData, ticketHmac),
|
||||
secretEnv: ["ADDIN_TICKET_SECRET", "different-secret"],
|
||||
},
|
||||
];
|
||||
const messages = new Set<string>();
|
||||
for (const { label, body, secretEnv } of cases) {
|
||||
if (secretEnv) process.env[secretEnv[0]] = secretEnv[1];
|
||||
const response = await POST(makeRequest(body));
|
||||
expect(response.status, label).toBe(401);
|
||||
messages.add((await response.json()).error);
|
||||
if (secretEnv) delete process.env[secretEnv[0]];
|
||||
}
|
||||
expect(messages.size).toBe(1); // ข้อความเดียว กัน oracle
|
||||
expect(Array.from(messages)[0]).toBe("ticket ไม่ถูกต้อง");
|
||||
});
|
||||
|
||||
it("ลายเซ็นถูกแต่ exp อดีต → 403 \"ticket ของ e-Court หมดอายุ\" (ไม่ตรวจ DB — route ไม่มี DB)", async () => {
|
||||
const { ticketData, ticketHmac } = mintTicket(Math.floor(Date.now() / 1000) - 60);
|
||||
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).error).toBe("ticket ของ e-Court หมดอายุ");
|
||||
});
|
||||
|
||||
it("HMAC ถูกแต่ ticket_data ผิดกติกา (field หลุด allowlist) → 401", async () => {
|
||||
const invalid = JSON.stringify({ exp: futureExp(), fid: "f", uid: "u", uname: "n", oops: 1 });
|
||||
const hmac = createHmac("sha256", SECRET_DEFAULT).update(invalid, "utf8").digest("hex");
|
||||
const response = await POST(makeRequest(ticketBody(invalid, hmac)));
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("DOCVER_TOKEN_SECRET ไม่ตั้ง → 500 (ความผิด config ฝั่ง server ไม่ใช่ของผู้เรียก)", async () => {
|
||||
delete process.env.DOCVER_TOKEN_SECRET;
|
||||
const { ticketData, ticketHmac } = mintTicket(futureExp());
|
||||
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("DOCVER_TOKEN_TTL_SECONDS=120 → expires_in 120 (env override default)", async () => {
|
||||
process.env.DOCVER_TOKEN_TTL_SECONDS = "120";
|
||||
const { ticketData, ticketHmac } = mintTicket(futureExp());
|
||||
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||||
expect(response.status).toBe(200);
|
||||
expect(((await response.json()) as { expires_in: number }).expires_in).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
makeRequest(JSON.stringify({ userid: "u1", username: "นายทดสอบ", fid: "F1" }), {
|
||||
"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(604_800);
|
||||
const payload = jwtPayload(data.access_token);
|
||||
expect(payload.userid).toBe("u1");
|
||||
expect(payload.fid).toBe("F1");
|
||||
expect(payload.purpose).toBe("docver");
|
||||
});
|
||||
|
||||
it("key ผิด → 401", async () => {
|
||||
process.env.DOCVER_API_KEY = API_KEY;
|
||||
const response = await POST(
|
||||
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), {
|
||||
"x-api-key": "wrong-key",
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("มี header x-api-key แต่ env ไม่ตั้ง → 401 fail closed", async () => {
|
||||
delete process.env.DOCVER_API_KEY;
|
||||
const response = await POST(
|
||||
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), {
|
||||
"x-api-key": API_KEY,
|
||||
}),
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue