eCourt-note/app/api/note/auth/dev-link/route.ts

135 lines
7.8 KiB
TypeScript
Raw Normal View History

feat(note): mock e-Court login form on dev page เพิ่มฟอร์ม "จำลอง e-Court — ขอ token ด้วย API key" บนหน้า /note/dev (components/MockEcourtForm): ใส่ DOCVER_API_KEY + userid/username + เลือกไฟล์ตัวอย่าง → ยิง POST /api/note/auth/token (ทางเข้าเดียวกับที่ e-Court ใช้จริง) แล้ว redirect เข้า /note?fileUrl=…&t=… ให้ NoteHome แลกเป็น session 8 ชม. ต่อ — ใช้ได้ทั้ง dev และ production และไม่เป็นประตูหลัง (ต้องถือ API key ที่ถูกต้อง) พร้อมกันนี้เปลี่ยนเกณฑ์เปิดหน้า dev เป็น env ตัวเดียว (คำขอผู้ใช้ 2026-11-02): - devMintEnabled: ตัดเงื่อนไข NODE_ENV≠production ออก เหลือ NOTE_SSO_DEV_MINT === "1" อย่างเดียว — ตั้งไว้บน production (เครื่องทดสอบ) ก็ใช้ได้ - หน้า /note/dev: 0/ไม่ตั้ง = 404 ทั้งหน้า (notFound) — เดิมเป็น public ตลอดเหลือแต่ข้อความแนะนำ - dev-link (GET/POST) เปิด/ปิดตามเกณฑ์เดียวกันโดยอัตโนมัติ - sync CLAUDE.md + ปรับ unit test ให้ตรงเกณฑ์ใหม่ Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-02 12:19:38 +07:00
// เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว (2026-11-02 — ใช้ได้ทุก environment
// รวม production ที่ตั้ง env ไว้, เดิม gate ด้วย NODE_ENV ด้วย) — mint ลิงก์ SSO
// จากหน้า /note/dev ให้ทดสอบ flow จริงจาก browser ได้โดยไม่ต้องรัน script:
// คำนวณ token/ticket ฝั่ง server ด้วย secret ของตัวเอง (ไม่มีค่าใดหลุดไปหน้า public) แล้วคืน URL เต็มเดียว
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
// พร้อมคลิก — ปิดอยู่ = 404 เหมือนไม่มีเส้นนี้ · ไม่ import @/db · ห้าม log ค่าดิบ
// · GET ?file=<ชื่อไฟล์ตัวอย่าง>&mode=jwt|ticket (2026-10-30 รอบสอง) — ลิงก์ "A Link
// Get(new)" ของ /note/dev: mint แล้ว 302 เข้า /note?fileUrl=…&t=… ให้ bootstrap
// แลก token ต่อเหมือนลิงก์จริงของ e-Court (identity dev คงที่ + fid=dev-<ไฟล์>)
import { createHmac } from "node:crypto";
import { NextResponse } from "next/server";
import { addinTicketSecret } from "@/lib/addin-ticket";
import { docverError } from "@/lib/document-versioning";
import { issueDocverToken } from "@/lib/docver-token";
import { devMintEnabled, LOGIN_TOKEN_TTL, parseDevLinkBody } from "@/lib/note-auth";
import { withBasePath } from "@/utils/base-path";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** identity dev คงที่ของลิงก์ตัวอย่าง — ตรงกับค่า default ของฟอร์ม mint หน้าเดียวกัน */
const DEV_USER = { userid: "oom", username: "สรวิชญ์" };
export async function GET(request: Request): Promise<Response> {
if (!devMintEnabled()) return new Response(null, { status: 404 });
const url = new URL(request.url);
const file = url.searchParams.get("file") ?? "";
const mode = url.searchParams.get("mode") === "ticket" ? "ticket" : "jwt";
// ชื่อไฟล์ตัวอย่างเท่านั้น — ห้าม path segment/.. (การมีไฟล์จริงตรวจตอน register
// ใน exchange ของ /api/note/auth/session อยู่แล้ว)
if (
file === "" || file.length > 200 ||
file.includes("/") || file.includes("\\") || file.includes("..")
) {
return docverError("file ต้องเป็นชื่อไฟล์ตัวอย่าง (ไม่มี path)", 400);
}
// fid คงที่ต่อไฟล์ → แต่ละตัวอย่างมี row ecourt: ของตัวเอง (annotation ไม่ปนกัน
// ข้ามไฟล์ และคลิกรอบหลังเจอ row เดิม) — นี่คือสิ่งที่ลิงก์จริงของ e-Court ทำเช่นกัน
const fid = `dev-${file}`;
const params = new URLSearchParams({ fileUrl: `/samples/${file}` });
if (mode === "jwt") {
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
}
params.set(
"t",
issueDocverToken({ ...DEV_USER, fid, template: null }, secret, LOGIN_TOKEN_TTL.default, "login"),
);
} else {
// ตรงตามตัว stamp จริง: key เรียงตามตัวอักษรหลังเติม exp → HMAC hex บน string ดิบ
const ticket = { exp: Math.floor(Date.now() / 1000) + LOGIN_TOKEN_TTL.default, fid, uid: DEV_USER.userid, uname: DEV_USER.username };
const ticketData = JSON.stringify(
Object.keys(ticket).sort().reduce<Record<string, unknown>>((result, key) => {
result[key] = ticket[key as keyof typeof ticket];
return result;
}, {}),
);
params.set("ticket_data", ticketData);
params.set("HmacSha256", createHmac("sha256", addinTicketSecret()).update(ticketData, "utf8").digest("hex"));
}
// ปลายทาง = หน้าแรก /note พร้อม credential + fileUrl เหมือนลิงก์จริง — ให้ bootstrap
// ของ NoteHome แลกเป็น session token แล้วพาเข้า viewer (เห็น URL ทุกขั้นตอน)
// Location relative เหมือน files/open — origin ของ request หลัง reverse proxy
// (Host ถูกเขียนทับเป็น upstream) ไม่น่าเชื่อพอจะประกอบ absolute URL
return new NextResponse(null, {
status: 302,
headers: { location: `${withBasePath("")}?${params.toString()}`, "cache-control": "no-store" },
});
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
}
export async function POST(request: Request): Promise<Response> {
if (!devMintEnabled()) return new Response(null, { status: 404 });
let raw: unknown;
try {
raw = await request.json();
} catch {
return docverError("body ต้องเป็น JSON", 400);
}
const parsed = parseDevLinkBody(raw);
if (!parsed.ok) return docverError(parsed.error, 400);
const params = new URLSearchParams();
if (parsed.fileUrl !== undefined) params.set("fileUrl", parsed.fileUrl);
if (parsed.typeId !== undefined) params.set("typeId", parsed.typeId);
if (parsed.mode === "jwt") {
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
}
params.set(
"t",
issueDocverToken(
{ userid: parsed.claims.userid, username: parsed.claims.username, fid: parsed.claims.fid, template: null },
secret,
parsed.ttlSeconds,
"login",
),
);
} else {
// ตรงตามตัว stamp จริง: key เรียงตามตัวอักษรหลังเติม exp → HMAC hex บน string ดิบ
const ticket = {
exp: Math.floor(Date.now() / 1000) + parsed.ttlSeconds,
fid: parsed.claims.fid,
uid: parsed.claims.userid,
uname: parsed.claims.username,
};
const ticketData = JSON.stringify(
Object.keys(ticket)
.sort()
.reduce<Record<string, unknown>>((result, key) => {
result[key] = ticket[key as keyof typeof ticket];
return result;
}, {}),
);
params.set("ticket_data", ticketData);
params.set("HmacSha256", createHmac("sha256", addinTicketSecret()).update(ticketData, "utf8").digest("hex"));
}
// origin ตรงของ request (ไม่ใช่ requestOrigin ที่บังคับ https โฮสต์ที่ไม่ใช่
// localhost) — เครื่องมือนี้ dev-only และ dev server แชร์บน LAN เป็น http ธรรมดา
// ลิงก์ที่คืนต้องคลิกได้จากที่ผู้ใช้เปิดหน้านั้นอยู่
// ปลายทาง = หน้าแรก /note ที่ client bootstrap แลก credential เป็น token
// (ฉบับ token 2026-10-30 — ไม่มี route e-court ที่ set cookie ให้ชี้แล้ว)
const origin = new URL(request.url).origin;
const url = `${origin}${withBasePath("")}?${params.toString()}`;
return new Response(JSON.stringify({ url, ttlSeconds: parsed.ttlSeconds }), {
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
});
}