เปลี่ยน session ของแอป note จาก cookie คู่ oacCase/oacCaseSig มาเป็น access_token แบบ add-in ตามคำขอผู้ใช้ (2026-10-30): - POST /api/note/auth/session แลก credential ของลิงก์ e-Court (JWT ทาง A / ticket ทาง B) เป็น access_token purpose=login อายุ 8 ชม. พร้อมลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server (fileId จาก fid) - token เก็บ localStorage "note-auth" (lib/note-session.ts) + Bearer ผ่าน noteAuthFetch · Server Actions ทุกตัวรับ token เป็น param แรก และแกะ identity เอง (lib/note-token.ts) — ไม่เชื่อ userId จาก client - หน้า protected เป็น shell + client gate (NoteHome bootstrap ที่ /note, AnnotateView + GET /api/note/files/[id]/bootstrap) - ถอด: middleware.ts, lib/session.ts, utils/user.ts, action logout, GET /api/note/auth/e-court, entry /note/annotate?fileUrl= - ทาง ticket ปฏิเสธ secret default "super_secret" เฉพาะ production - GET /api/note/files/open: ลิงก์เปิดไฟล์ตัวอย่างแบบ /tmpl (path ใน เครื่อง /samples เท่านั้น) - หน้า /note/dev: ลิงก์ A Link Get(new) ที่ mint credential แล้วยิง เข้า flow SSO จริง (JWT/ticket สลับได้) + section Flow + เรียบเรียง ภาษาให้อ่านง่าย · หน้า /note เป็นหน้าข้อมูลระบบ - แก้ตาม code review: redo closure เรียก action ด้วย signature เก่า, bootstrap timeout + strip credential ที่ผิดรูป, แยก no-session ออก จาก network error, ห้ามอักขระ & = + % ใน identity, ตรวจ purpose ก่อน exp ของ login-token, audit log strip อักขระควบคุม - เอกสาร: docs/PLAN-SSO.md §0.5 (ฉบับ token), CLAUDE.md, .env.example ทดสอบ: tsc ผ่าน · vitest ผ่านทั้งชุด · ทดสอบจริงผ่าน Playwright (SSO → viewer → เปลี่ยนสี annotation → undo → redo ครบวงจร) Co-Authored-By: Claude Code <noreply@anthropic.com>
163 lines
7.3 KiB
JavaScript
163 lines
7.3 KiB
JavaScript
#!/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 <id> default oom (claim userid / ticket uid)
|
|
// --uname <name> default "สรวิชญ์" (claim username / ticket uname)
|
|
// --fid <fileId> default DEMO_FID_001 (fileId ของ e-Court — D11)
|
|
// --fileUrl <url> path ในเครื่อง (/samples/…) หรือ https ของ allowlist N8 (optional)
|
|
// --typeId <id> กลุ่มเอกสาร N9 (optional)
|
|
// --ttl <seconds> default 300 (ทาง jwt = exp ของ token · ทาง ticket = exp ของ ticket)
|
|
// --mode jwt|ticket default jwt (ทาง A แนะนำ — ทาง B ใช้ตัว stamp เดียวกับ .docx)
|
|
// --secret <secret> ทาง jwt: DOCVER_TOKEN_SECRET · ทาง ticket: ADDIN_TICKET_SECRET
|
|
// → SECRET → "super_secret" (default ตามลำดับ อ่านจาก env/.env ก่อน)
|
|
// --base <url> 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 <id> --uname <name> --fid <fileId> --fileUrl <url> --typeId <id>
|
|
--ttl <seconds> --mode jwt|ticket --secret <secret> --base <url>`);
|
|
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")}`);
|