Refactor authentication flow and ticket handling in add-in
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m23s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s

- Removed client-side HMAC verification; server now handles all HMAC checks.
- Updated ticket processing logic to improve UX and security.
- Adjusted API request handling to return discriminated results based on HTTP status.
- Enhanced error messages for better clarity on authentication failures.
- Added unit tests for the new token endpoint to ensure compliance with updated contract.
- Cleaned up unused code and comments related to previous HMAC handling.
This commit is contained in:
Suphonchai Phoonsawat 2026-11-04 21:18:19 +07:00
parent 2e951f99f4
commit c0833c00f7
24 changed files with 858 additions and 460 deletions

View file

@ -0,0 +1,215 @@
// 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)
import { createHmac } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { POST } 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/documents/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/documents/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/documents/token — ทางเข้า x-api-key (regression ไม่เปลี่ยน)", () => {
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);
});
});