eCourt-note/scripts/make-login-link.mjs
Your Name d37bc2a3f0 feat(sso): switch note session from cookies to token
เปลี่ยน 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>
2026-10-30 18:05:36 +07:00

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")}`);