eCourt-note/lib/addin-ticket.ts

125 lines
6.4 KiB
TypeScript
Raw Normal View History

// PLAN-ADDIN-AUTH.md §3 — ตรวจ ticket ของ add-in ฝั่ง server (ทางเข้าที่สองของ
// POST /api/documents/token) — secret อยู่ฝั่ง server อย่างเดียว เพราะทางเข้านี้
// ผู้เรียกคือ add-in ใน webview ของ Word ซึ่งแนบ x-api-key (server-to-server
// ของ e-Court) มาไม่ได้ — ตัวพิสูจน์ตัวตนคือ ticket_data + HmacSha256 ที่ e-Court
// stamp มากับไฟล์ (คำนวณ HMAC ตรงกับ lib/lib-utils.ts generateHmacSha256
// ของกลุ่ม template: hex lowercase บน string ดิบ)
//
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรัน
// ได้โดยไม่ต้องตั้ง env อะไรเลย (pattern เดียวกับ lib/docver-token.ts)
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import {
isTicketExpired,
normalizeHexDigest,
parseDocverTicket,
type DocverTicket,
} from "@/utils/docver-ticket";
import type { DocverTokenClaims } from "@/lib/docver-token";
/**
* secret ของ ticket — อ่านตอนเรียก ไม่ใช่ตอน module load
* ลำดับ: ADDIN_TICKET_SECRET (ของกลุ่มนี้) → SECRET (env เดียวกับตัว stamp
* ของกลุ่ม template — deployment เดียวกันมักตั้งค่านี้อยู่แล้ว) → "super_secret"
* (ค่าตาม requirement — dev ใช้ได้ทันที แต่ production ต้องตั้งค่าจริงเพราะ
* ค่าเดิมรู้กันหมด = ใครก็ปลอม ticket ได้) · ห้าม log ค่าดิบ
*/
export function addinTicketSecret(): string {
return (
process.env.ADDIN_TICKET_SECRET?.trim() ||
process.env.SECRET?.trim() ||
"super_secret"
);
}
/**
* ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา
* (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle —
* add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว)
*/
export function verifyAddinTicket(
ticketData: string,
hmacHex: string,
secret: string,
nowSec: number = Math.floor(Date.now() / 1000),
): { ok: true; ticket: DocverTicket } | { ok: false } {
const digest = normalizeHexDigest(hmacHex);
if (digest === null) return { ok: false };
// เทียบแบบ timing-safe ตาม pattern verifyDocverToken — hash ทั้งสองฝั่ง
// ด้วย SHA-256 ก่อนเพื่อให้ digest ยาวเท่ากันเสมอ (timingSafeEqual ไม่ throw)
const expected = createHmac("sha256", secret).update(ticketData, "utf8").digest();
const expectedHash = createHash("sha256").update(expected).digest();
const providedHash = createHash("sha256").update(Buffer.from(digest, "hex")).digest();
if (!timingSafeEqual(providedHash, expectedHash)) return { ok: false };
const parsed = parseDocverTicket(ticketData);
if (!parsed.ok) return { ok: false };
if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false };
return { ok: true, ticket: parsed.ticket };
}
/** แปลง ticket → claims ของ access_token (uid→userid, uname→username, tid→template) */
export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims {
return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid };
}
/** body ของทางเข้า ticket — allowlist 2 field เป๊ะ (ชื่อเดียวกับ custom properties) */
const TICKET_BODY_FIELDS = ["ticket_data", "HmacSha256"] as const;
export type TicketTokenBodyResult =
| { ok: true; ticketData: string; hmacHex: string }
| { ok: false; kind: "missing" | "invalid"; error: string };
/**
* parse body ของ POST /api/documents/token ทางเข้า ticket
* - `missing` = ไม่มี ticket มาเลย (route ตอบ 401 — คำเดียวกับตอน verify ไม่ผ่าน)
* - `invalid` = รูปผิด (route ตอบ 400 ตาม convention กัน typo เงียบ ๆ)
*/
export function parseTicketTokenBody(raw: unknown): TicketTokenBodyResult {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, kind: "invalid", error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(TICKET_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
kind: "invalid",
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TICKET_BODY_FIELDS.join(", ")})`,
};
}
}
const read = (name: (typeof TICKET_BODY_FIELDS)[number]): string | null => {
const value = record[name];
if (value === undefined || value === null || value === "") return null; // ไม่ส่ง = missing
if (typeof value !== "string") return ""; // ส่งผิดชนิด = invalid
return value;
};
const ticketData = read("ticket_data");
if (ticketData === null) {
return { ok: false, kind: "missing", error: "ต้องระบุ ticket_data (string)" };
}
if (ticketData === "") {
return { ok: false, kind: "invalid", error: "ticket_data ต้องเป็น string" };
}
if (ticketData.length > 2048) {
return { ok: false, kind: "invalid", error: "ticket_data ยาวเกินไป" };
}
const hmacHex = read("HmacSha256");
if (hmacHex === null) {
return { ok: false, kind: "missing", error: "ต้องระบุ HmacSha256 (string)" };
}
if (hmacHex === "") {
return { ok: false, kind: "invalid", error: "HmacSha256 ต้องเป็น string" };
}
if (hmacHex.length > 128) {
return { ok: false, kind: "invalid", error: "HmacSha256 ยาวเกินไป" };
}
return { ok: true, ticketData, hmacHex };
}