137 lines
6.5 KiB
TypeScript
137 lines
6.5 KiB
TypeScript
|
|
// PLAN-ADDIN-AUTH.md §4 — ticket ของ add-in (custom properties `ticket_data` +
|
|||
|
|
// `HmacSha256` ที่ e-Court ผ่านกลุ่ม template stamp ลงไฟล์) — ส่วน pure ที่
|
|||
|
|
// ใช้ร่วมกันทั้งฝั่ง server (lib/addin-ticket.ts — route /api/documents/token)
|
|||
|
|
// และฝั่ง client ของ add-in (wordaddin/services/ticketService.ts)
|
|||
|
|
//
|
|||
|
|
// กติกา (docs/requirement/addin-workflow.md + lib/lib-utils.ts generateHmacSha256
|
|||
|
|
// ของตัว stamp ฝั่ง template):
|
|||
|
|
// - ticket_data เป็น JSON string ตามที่ stamp ไว้เป๊ะ (key เรียงตามตัวอักษร
|
|||
|
|
// หลังเติม exp) — **HMAC ต้องคำนวณบน string ดิบนี้ ห้าม trim/normalize**
|
|||
|
|
// - HmacSha256 เป็น hex lowercase 64 ตัว (การเทียบ normalize เป็น lowercase
|
|||
|
|
// ให้ก่อน — ฝั่ง stamp ตัวพิมพ์ใหญ่ก็ผ่านได้)
|
|||
|
|
// - ห้าม import อะไรที่เป็น platform-specific (node:crypto/crypto.subtle) —
|
|||
|
|
// module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word โดยส่ง
|
|||
|
|
// ตัวคำนวณ HMAC เข้ามาทาง parameter แทน
|
|||
|
|
|
|||
|
|
/** ticket ที่แกะจาก ticket_data — ชื่อ field ตามที่ e-Court กำหนด (requirement) */
|
|||
|
|
export interface DocverTicket {
|
|||
|
|
/** หมดอายุเมื่อไร — unix epoch วินาที */
|
|||
|
|
exp: number;
|
|||
|
|
/** fileId ของ e-Court — เป็น docId ของทุก operation ของกลุ่ม docver ตรง ๆ */
|
|||
|
|
fid: string;
|
|||
|
|
/** template ต้นทาง — optional (ไม่มี = null) */
|
|||
|
|
tid: string | null;
|
|||
|
|
/** รหัสผู้ใช้ → claim `userid` */
|
|||
|
|
uid: string;
|
|||
|
|
/** ชื่อผู้ใช้ → claim `username` */
|
|||
|
|
uname: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** ความยาว cap ต่อ field — ตาม parseTokenRequestBody ของ /api/documents/token */
|
|||
|
|
const TICKET_LIMITS = {
|
|||
|
|
uid: 100,
|
|||
|
|
uname: 200,
|
|||
|
|
fid: 200,
|
|||
|
|
tid: 100,
|
|||
|
|
} as const;
|
|||
|
|
|
|||
|
|
/** allowlist ของ ticket_data — field อื่นปนมา = reject (กัน typo เงียบ ๆ ตาม convention ของ repo) */
|
|||
|
|
const TICKET_FIELDS = ["exp", "fid", "tid", "uid", "uname"] as const;
|
|||
|
|
|
|||
|
|
export type DocverTicketParseResult =
|
|||
|
|
| { ok: true; ticket: DocverTicket }
|
|||
|
|
| { ok: false; error: string };
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* แกะ + ตรวจ ticket จาก string ดิบของ property `ticket_data`
|
|||
|
|
* คืนค่าที่ trim แล้ว (uid/uname/fid/tid) — exp เป็น number ตรง ๆ
|
|||
|
|
*/
|
|||
|
|
export function parseDocverTicket(raw: string): DocverTicketParseResult {
|
|||
|
|
let parsed: unknown;
|
|||
|
|
try {
|
|||
|
|
parsed = JSON.parse(raw);
|
|||
|
|
} catch {
|
|||
|
|
return { ok: false, error: "ticket_data ไม่ใช่ JSON ที่ถูกต้อง" };
|
|||
|
|
}
|
|||
|
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|||
|
|
return { ok: false, error: "ticket_data ต้องเป็น JSON object" };
|
|||
|
|
}
|
|||
|
|
const record = parsed as Record<string, unknown>;
|
|||
|
|
for (const key of Object.keys(record)) {
|
|||
|
|
if (!(TICKET_FIELDS as readonly string[]).includes(key)) {
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
error: `ticket_data มี field ที่ไม่รู้จัก '${key}' (รับเฉพาะ ${TICKET_FIELDS.join(", ")})`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const exp = record.exp;
|
|||
|
|
if (typeof exp !== "number" || !Number.isSafeInteger(exp)) {
|
|||
|
|
return { ok: false, error: "ticket_data ต้องมี exp เป็นจำนวนเต็ม (unix วินาที)" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const field = (name: "uid" | "uname" | "fid"): string | null => {
|
|||
|
|
const value = record[name];
|
|||
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const trimmed = value.trim();
|
|||
|
|
return trimmed.length <= TICKET_LIMITS[name] ? trimmed : null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const uid = field("uid");
|
|||
|
|
if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" };
|
|||
|
|
const uname = field("uname");
|
|||
|
|
if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" };
|
|||
|
|
const fid = field("fid");
|
|||
|
|
if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" };
|
|||
|
|
|
|||
|
|
let tid: string | null = null;
|
|||
|
|
if (record.tid !== undefined) {
|
|||
|
|
if (typeof record.tid !== "string") {
|
|||
|
|
return { ok: false, error: "ticket_data ของ tid ต้องเป็น string" };
|
|||
|
|
}
|
|||
|
|
const trimmed = record.tid.trim();
|
|||
|
|
if (trimmed !== "") {
|
|||
|
|
if (trimmed.length > TICKET_LIMITS.tid) {
|
|||
|
|
return { ok: false, error: "ticket_data ของ tid ยาวเกิน 100 ตัวอักษร" };
|
|||
|
|
}
|
|||
|
|
tid = trimmed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { ok: true, ticket: { exp, fid, tid, uid, uname } };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** exp หมดอายุไหม — exp เท่ากับ now พอดีก็ถือว่าหมดแล้ว */
|
|||
|
|
export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean {
|
|||
|
|
return exp <= nowSec;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* normalize ค่า digest ที่ได้รับ (property `HmacSha256` / body field) —
|
|||
|
|
* trim + lowercase แล้วตรวจว่าเป็น hex ครบ 64 ตัว คืน null เมื่อรูปไม่ใช่
|
|||
|
|
*/
|
|||
|
|
export function normalizeHexDigest(value: string): string | null {
|
|||
|
|
const normalized = value.trim().toLowerCase();
|
|||
|
|
return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* เทียบ hex digest สองค่าแบบ constant-time (best-effort ของ pure JS —
|
|||
|
|
* ฝั่ง server จริงใช้ node:crypto timingSafeEqual ผ่าน lib/addin-ticket.ts
|
|||
|
|
* ตัวนี้ใช้กับ UX gate ฝั่ง client เป็นหลัก) — ค่าที่ส่งเข้าควร normalize แล้ว
|
|||
|
|
*/
|
|||
|
|
export function timingSafeEqualHex(a: string, b: string): boolean {
|
|||
|
|
if (a.length !== b.length) return false;
|
|||
|
|
let diff = 0;
|
|||
|
|
for (let i = 0; i < a.length; i++) {
|
|||
|
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|||
|
|
}
|
|||
|
|
return diff === 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** ตัวคำนวณ HMAC-SHA256 คืนค่าเป็น hex lowercase — สองฝั่งฉีดของตัวเอง (Web Crypto / node:crypto) */
|
|||
|
|
export type HmacSha256Hex = (message: string, secret: string) => Promise<string>;
|