125 lines
6.4 KiB
TypeScript
125 lines
6.4 KiB
TypeScript
|
|
// 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 };
|
||
|
|
}
|