#!/usr/bin/env node // PLAN-SSO.md §6 D10 — dev tool mint ลิงก์ SSO ของ e-Court ให้แอป note ออฟไลน์ // (แทน mock login ที่ถอดแล้ว) แล้วพิมพ์ลิงก์เต็มพร้อมวางใน browser — pattern // เดียวกับ scripts/make-ticket-docx.mjs // // ใช้: node scripts/make-login-link.mjs [options] // --uid default oom (claim userid / ticket uid) // --uname default "สรวิชญ์" (claim username / ticket uname) // --fid default DEMO_FID_001 (fileId ของ e-Court — D11) // --fileUrl path ในเครื่อง (/samples/…) หรือ https ของ allowlist N8 (optional) // --typeId กลุ่มเอกสาร N9 (optional) // --ttl default 300 (ทาง jwt = exp ของ token · ทาง ticket = exp ของ ticket) // --mode jwt|ticket default jwt (ทาง A แนะนำ — ทาง B ใช้ตัว stamp เดียวกับ .docx) // --secret ทาง jwt: DOCVER_TOKEN_SECRET · ทาง ticket: ADDIN_TICKET_SECRET // → SECRET → "super_secret" (default ตามลำดับ อ่านจาก env/.env ก่อน) // --base base URL ของแอป (default env DOCEDIT_BASE_URL หรือ // http://localhost:3000/docedit) // // รันใน repo เท่านั้น — ไม่ใช่ postinstall import { createHmac } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; /** อ่าน .env (KEY=VALUE บรรทัดละคู่ — พอสำหรับค่า dev ไม่ใช่ parser เต็ม) */ function loadDotEnv() { const file = path.join(process.cwd(), ".env"); if (!existsSync(file)) return {}; const values = {}; for (const line of readFileSync(file, "utf8").split(/\r?\n/)) { const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line); if (match === null) continue; let value = match[2]; if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } values[match[1]] = value; } return values; } function usage(code = 1) { console.log(`ใช้: node scripts/make-login-link.mjs [options] --uid --uname --fid --fileUrl --typeId --ttl --mode jwt|ticket --secret --base `); process.exit(code); } function parseArgs(argv) { const options = { uid: "oom", uname: "สรวิชญ์", fid: "DEMO_FID_001", fileUrl: null, typeId: null, ttl: 300, mode: "jwt", secret: null, base: null, }; for (let i = 0; i < argv.length; i++) { const next = () => { const value = argv[++i]; if (value === undefined) usage(); return value; }; switch (argv[i]) { case "--uid": options.uid = next(); break; case "--uname": options.uname = next(); break; case "--fid": options.fid = next(); break; case "--fileUrl": options.fileUrl = next(); break; case "--typeId": options.typeId = next(); break; case "--ttl": { const value = Number(next()); if (!Number.isInteger(value) || value <= 0) throw new Error("--ttl ต้องเป็นจำนวนวินาทีบวก"); options.ttl = value; break; } case "--mode": { const value = next(); if (value !== "jwt" && value !== "ticket") throw new Error("--mode ต้องเป็น jwt หรือ ticket"); options.mode = value; break; } case "--secret": options.secret = next(); break; case "--base": options.base = next(); break; case "--help": case "-h": usage(0); break; default: usage(); } } return options; } const envFile = loadDotEnv(); const env = (name) => process.env[name] ?? envFile[name] ?? ""; const options = parseArgs(process.argv.slice(2)); const base = (options.base || env("DOCEDIT_BASE_URL") || "http://localhost:3000/docedit").replace(/\/+$/, ""); const exp = Math.floor(Date.now() / 1000) + options.ttl; /** query ร่วมของทั้งสองทาง (เฉพาะ param ที่มีจริง) */ const sharedParams = new URLSearchParams(); if (options.fileUrl !== null) sharedParams.set("fileUrl", options.fileUrl); if (options.typeId !== null) sharedParams.set("typeId", options.typeId); let link; let secretUsed; if (options.mode === "jwt") { // ตรงตาม lib/docver-token.ts issueDocverToken(…, purpose:"login") secretUsed = options.secret ?? env("DOCVER_TOKEN_SECRET"); if (secretUsed === "") { console.error("✗ ไม่มี secret — ตั้ง DOCVER_TOKEN_SECRET ใน .env หรือส่ง --secret"); process.exit(1); } const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8").toString("base64url"); const payload = Buffer.from( JSON.stringify({ userid: options.uid, username: options.uname, fid: options.fid, template: null, purpose: "login", iat: Math.floor(Date.now() / 1000), exp, }), "utf8", ).toString("base64url"); const signature = createHmac("sha256", secretUsed) .update(`${header}.${payload}`) .digest("base64url"); const params = new URLSearchParams(sharedParams); params.set("t", `${header}.${payload}.${signature}`); // ปลายทาง = หน้าแรก /note ที่ client bootstrap แลก credential เป็น token // (ฉบับ token 2026-10-30 — ไม่มี route e-court ที่ set cookie ให้ชี้แล้ว) link = `${base}/note?${params.toString()}`; } else { // ตรงตามตัว stamp จริง (make-ticket-docx.mjs / lib/addin-ticket.ts): // ticket_data key เรียงตามตัวอักษรหลังเติม exp → HMAC hex lowercase บน string ดิบ secretUsed = options.secret ?? (env("ADDIN_TICKET_SECRET") || env("SECRET") || "super_secret"); const ticket = { exp, fid: options.fid, uid: options.uid, uname: options.uname }; const ticketData = JSON.stringify( Object.keys(ticket).sort().reduce((result, key) => { result[key] = ticket[key]; return result; }, {}), ); const hmac = createHmac("sha256", secretUsed).update(ticketData, "utf8").digest("hex"); const params = new URLSearchParams(sharedParams); params.set("ticket_data", ticketData); params.set("HmacSha256", hmac); link = `${base}/note?${params.toString()}`; } console.log(`✓ ลิงก์ SSO (${options.mode}) อายุ ${options.ttl} วินาที — วางใน browser:`); console.log(); console.log(link); console.log(); console.log(` userid/uid : ${options.uid} → หลังแลก token ระบบพาเข้าหน้าอ่านเอกสารของ fid ${options.fid}`); console.log(` secret : ${".".repeat(Math.max(secretUsed.length - 2, 0))}${secretUsed.slice(-2)} (ไม่พิมพ์เต็ม)`); console.log(` exp : ${new Date(exp * 1000).toLocaleString("th-TH")}`);