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>
This commit is contained in:
Your Name 2026-10-30 18:05:36 +07:00
parent b1449ab668
commit d37bc2a3f0
49 changed files with 2921 additions and 1307 deletions

View file

@ -0,0 +1,130 @@
// dev-only (NOTE_SSO_DEV_MINT=1 + ไม่ใช่ production) — mint ลิงก์ SSO จากหน้า /note/dev
// ให้ทดสอบ flow จริงจาก browser ได้โดยไม่ต้องรัน script: คำนวณ token/ticket ฝั่ง
// server ด้วย secret ของตัวเอง (ไม่มีค่าใดหลุดไปหน้า public) แล้วคืน URL เต็มเดียว
// พร้อมคลิก — ปิดอยู่ = 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 ทุกขั้นตอน)
const res = NextResponse.redirect(new URL(`${withBasePath("")}?${params.toString()}`, url), 302);
res.headers.set("cache-control", "no-store");
return res;
}
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" },
});
}

View file

@ -0,0 +1,105 @@
// POST /api/note/auth/session — แลก credential ของลิงก์ e-Court เป็น access_token
// (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — แทน GET e-court ที่
// set cookie คู่ oacCase ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?,
// typeId?} → verify → ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก
// fid ที่เซ็นมา — ไม่ต้องมี link proof อีก) → ออก login-token อายุ NOTE_SESSION_TTL
// (8 ชม.) ให้ client เก็บ localStorage "note-auth"
// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง
// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ
import { NextResponse } from "next/server";
import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket";
import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token";
import { registerExternalFile } from "@/lib/external-import";
import { parseSessionExchangeBody } from "@/lib/note-auth";
import { NOTE_SESSION_TTL } from "@/lib/note-token";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json(body: unknown, status = 200): NextResponse {
return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } });
}
export async function POST(request: Request): Promise<Response> {
let raw: unknown;
try {
raw = await request.json();
} catch {
raw = null;
}
const parsed = parseSessionExchangeBody(raw);
if (!parsed.ok) return json({ error: parsed.error }, 400);
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") {
console.error("[sso] DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า");
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
}
let identity: { userid: string; username: string; fid: string };
if (parsed.credential.kind === "jwt") {
const verified = verifyDocverLoginToken(parsed.credential.token, secret);
if (!verified.ok) {
return json(
{ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: verified.reason },
401,
);
}
identity = verified.claims;
} else {
// ค่า default "super_secret" ของ addinTicketSecret() เป็นที่รู้กันทั้ง repo/
// .env.example — ใครก็ปลอม ticket ของ uid ใครก็ได้ จึงปฏิเสธใน production
// (fail-closed — กลุ่ม docver/add-in อนุญาต default ตาม requirement อยู่แล้ว
// คนละประตู) · **dev ยอมรับค่า default** เพื่อให้ทดสอบทาง ticket รอบเครื่องได้
// โดยไม่ต้องตั้ง env ใหม่ — เครื่องมือ dev (make-login-link/make-ticket-docx/
// dev-link) ก็ default ค่าเดียวกัน (review 2026-10-30 รอบสอง)
if (process.env.NODE_ENV === "production" && addinTicketSecret() === "super_secret") {
console.error("[sso] ticket secret ยังเป็นค่า default (ADDIN_TICKET_SECRET/SECRET) — ปฏิเสธทาง ticket");
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
}
const verified = verifyAddinTicket(
parsed.credential.ticketData,
parsed.credential.hmacHex,
addinTicketSecret(),
);
if (!verified.ok) {
return json({ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: "invalid" }, 401);
}
identity = { userid: verified.ticket.uid, username: verified.ticket.uname, fid: verified.ticket.fid };
}
// N8: ลงทะเบียนเอกสารตอนแลก token เลย — ฝั่ง server เชื่อ fid จาก credential
// ที่ verify แล้วเท่านั้น (trusted) ส่วน ?fileUrl พิมพ์เองบนหน้าเว็บไม่มีทาง
// เข้ามาถึงจุดนี้ · ล้ม = ยังออก token ให้ + fileError ให้หน้าแรกแสดง (คู่ขนาน
// พฤติกรรมสมัย cookie: session ตั้งก่อน แล้วค่อยแจ้ง error ของไฟล์)
let fileId: string | undefined;
let fileError: string | undefined;
if (parsed.fileUrl !== undefined) {
const result = await registerExternalFile(parsed.fileUrl, identity.fid, parsed.typeId, {
trusted: true,
});
if (result.ok) fileId = result.fileId;
else fileError = result.error;
}
const access_token = issueDocverToken(
{ userid: identity.userid, username: identity.username, fid: identity.fid, template: null },
secret,
NOTE_SESSION_TTL,
"login",
);
// audit: เฉพาะ userid + เวลา — ห้าม log token/query ดิบ · strip อักขระควบคุม
// ก่อนพิมพ์ (claims ไม่จำกัด charset — กันฝัง \n ปลอมบรรทัด log)
const auditId = identity.userid.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
console.log(`[sso] e-Court login ok: userid=${auditId}`);
return json({
access_token,
token_type: "Bearer",
expires_in: NOTE_SESSION_TTL,
userid: identity.userid,
username: identity.username,
...(fileId !== undefined ? { fileId } : {}),
...(fileError !== undefined ? { fileError } : {}),
});
}

View file

@ -0,0 +1,48 @@
// PLAN-SSO.md §3.1 — POST /api/note/auth/token: เส้น mint login-token ของแอป note
// (ทางเข้า x-api-key ตัวเดียวกับ docver แต่แยกเส้นจาก /api/documents/token — D1
// contract docver ไม่ถูกแต้) — สำหรับ backend e-Court ประกอบลิงก์
// `/note?…&t=<access_token>` ให้ผู้ใช้คลิก (ฉบับ token 2026-10-30: หน้าแรกเป็น
// client bootstrap แลก credential เป็น session token ที่ /api/note/auth/session)
// token ที่ได้เป็น purpose="login" ใช้ได้กับการเข้าแอป note เท่านั้น (D4)
// ไม่ import @/db · ห้าม log ค่า key/body ดิบ
import { docverError } from "@/lib/document-versioning";
import { issueDocverToken, requireDocverApiKey } from "@/lib/docver-token";
import { parseLoginTokenBody } from "@/lib/note-auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request): Promise<Response> {
const apiKeyFail = requireDocverApiKey(request);
if (apiKeyFail !== null) return apiKeyFail;
let raw: unknown;
try {
raw = await request.json();
} catch {
return docverError("body ต้องเป็น JSON", 400);
}
const parsed = parseLoginTokenBody(raw);
if (!parsed.ok) return docverError(parsed.error, 400);
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
}
const accessToken = issueDocverToken(
{ userid: parsed.claims.userid, username: parsed.claims.username, fid: parsed.claims.fid, template: null },
secret,
parsed.ttlSeconds,
"login",
);
return new Response(
JSON.stringify({
access_token: accessToken,
token_type: "Bearer",
expires_in: parsed.ttlSeconds,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}

View file

@ -0,0 +1,81 @@
// GET /api/note/files/[id]/bootstrap — ข้อมูลเริ่มต้นของ viewer (file + annotations
// + bookmarks + replies เป็น DTO) ให้ client gate ของหน้า /note/annotate/[id]
// ดึงหลังมี access_token (ฉบับ token 2026-10-30 — เดิมหน้านี้เป็น server component
// อ่าน cookie ผ่าน getSessionUser) · Bearer บังคับ (401) · ไม่ publish SSE ·
// query ชุดเดียวกับหน้าเดิมตอนเป็น server component
import { asc, eq, inArray } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/db";
import {
annotationReplies,
annotations,
bookmarks,
files,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
} from "@/db/schema";
import { noteUserFromRequest } from "@/lib/note-token";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(
request: Request,
{ params }: { params: { id: string } },
): Promise<Response> {
const user = noteUserFromRequest(request);
if (user === null) {
return NextResponse.json(
{ error: "session หมดอายุ — กรุณากลับ e-Court กดลิงก์เปิดเอกสารใหม่" },
{ status: 401, headers: { "cache-control": "no-store" } },
);
}
const { id } = params;
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: "ไม่พบไฟล์นี้" }, { status: 404 });
}
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) {
return NextResponse.json({ error: "ไม่พบไฟล์นี้" }, { status: 404 });
}
const [annotationRows, bookmarkRows] = await Promise.all([
db
.select()
.from(annotations)
.where(eq(annotations.fileId, id))
.orderBy(asc(annotations.createdAt)),
db
.select()
.from(bookmarks)
.where(eq(bookmarks.fileId, id))
.orderBy(asc(bookmarks.createdAt)),
]);
// replies ของทุก comment ในไฟล์นี้ (F9) — แม่ถูก CASCADE ไปแล้ว row ก็หายตาม
const annotationIds = annotationRows.map((r) => r.id);
const replyRows = annotationIds.length
? await db
.select()
.from(annotationReplies)
.where(inArray(annotationReplies.annotationId, annotationIds))
.orderBy(asc(annotationReplies.createdAt))
: [];
return NextResponse.json(
{
file: { id: file.id, title: file.title, pdfUrl: file.pdfUrl },
annotations: annotationRows.map(toAnnotationDTO),
bookmarks: bookmarkRows.map(toBookmarkDTO),
replies: replyRows.map(toReplyDTO),
user: { userId: user.userid, userName: user.username },
},
{ headers: { "cache-control": "no-store" } },
);
}

View file

@ -0,0 +1,38 @@
// GET /api/note/files/open?fileUrl=/samples/… — ทางเปิดเอกสารตัวอย่างของหน้า /note/dev
// แบบ "A Link Get(new)" ของ /tmpl (คำขอผู้ใช้ 2026-10-30): ลิงก์เปล่า <Link> ยิง GET ได้เลย
// ไม่มี Bearer (browser navigation ใส่ header ไม่ได้) จึง**รับเฉพาะ path ในเครื่องที่ขึ้นต้น
// "/" เท่านั้น** — registerExternalFile ตรวจว่ามีไฟล์จริงใน public/ ผ่าน path guard เดิม
// (สร้างแถว url:… เท่านั้น — แถว ecourt: เกิดไม่ได้เพราะไม่รับ fileId และ path รีโมตโดนเด็ดขาด)
// แล้ว 302 เข้า viewer · ไฟล์ไม่ผ่าน = 302 /note?fileError=… ให้หน้าแรกแสดงสาเหตุ
import { NextResponse } from "next/server";
import { registerExternalFile } from "@/lib/external-import";
import { withBasePath } from "@/utils/base-path";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
// withBasePath("") = หน้าแรก /docedit/note · withBasePath("/annotate/x") = viewer —
// อย่าส่ง "/note" เข้า withBasePath (มันเติม app path ให้อีกชั้นเป็น /note/note — D11)
const redirectTo = (path: string, query = ""): NextResponse => {
const res = NextResponse.redirect(new URL(`${withBasePath(path)}${query}`, url), 302);
res.headers.set("cache-control", "no-store");
return res;
};
const fileUrl = url.searchParams.get("fileUrl") ?? "";
if (!fileUrl.startsWith("/") || fileUrl.includes("..") || fileUrl.length > 2048) {
return redirectTo(
"",
`?fileError=${encodeURIComponent("ลิงก์เปิดเอกสารตัวอย่างต้องเป็น path ในเครื่อง เช่น /samples/…")}`,
);
}
const result = await registerExternalFile(fileUrl, undefined, undefined, { trusted: false });
if (!result.ok) {
return redirectTo("", `?fileError=${encodeURIComponent(result.error)}`);
}
return redirectTo(`/annotate/${result.fileId}`);
}