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

@ -21,6 +21,10 @@ export type DocverTokenClaims = {
username: string;
fid: string;
template: string | null;
/** แยกสิทธิ์ของ token (PLAN-SSO.md D4): "docver" = ใช้กับ /api/documents/* ·
* "login" = ใช้กับ SSO route ของแอป note เท่านั้น · undefined = docver
* (token เก่าก่อนมี claim นี้ — verify ยอมรับเพื่อ token ในมือ user ตอน roll ไม่ตาย) */
purpose?: "docver" | "login";
};
function base64url(input: string): string {
@ -49,16 +53,19 @@ function decodeJsonSegment(segment: string): Record<string, unknown> | null {
}
}
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + purpose + iat/exp (วินาที)
* purpose default "docver" — route ของกลุ่ม docver ไม่ต้องส่ง (PLAN-SSO.md D4),
* เส้น mint login-token ของแอป note ส่ง "login" */
export function issueDocverToken(
claims: DocverTokenClaims,
secret: string,
ttlSeconds: number,
purpose: "docver" | "login" = "docver",
): string {
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const iat = Math.floor(Date.now() / 1000);
const payload = base64url(
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }),
JSON.stringify({ ...claims, purpose, iat, exp: iat + ttlSeconds }),
);
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
@ -76,22 +83,30 @@ function claimsFromPayload(
if (typeof username !== "string" || username === "") return null;
if (typeof fid !== "string" || fid === "") return null;
if (template !== null && typeof template !== "string") return null;
return { userid, username, fid, template };
// purpose ไม่บังคับ (token เก่าไม่มี = docver) แต่ค่าอื่นนอกสองค่านี้ = invalid;
// คืนเฉพาะเมื่อเป็น "login" เพื่อไม่ให้ claims ของ token docver เปลี่ยนรูปจากเดิม
const purpose = payload.purpose;
if (purpose !== undefined && purpose !== "docver" && purpose !== "login") {
return null;
}
const claims: DocverTokenClaims = { userid, username, fid, template };
if (purpose === "login") claims.purpose = purpose;
return claims;
}
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
export function verifyDocverToken(
/** ตรวจรูป + alg บังคับ HS256 (กัน alg-confusion เช่น "none") + เทียบ signature
* แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) — ใช้ร่วมโดย
* verifyDocverToken และ verifyDocverLoginToken · คืน payload ที่ decode แล้ว */
function verifyTokenSignature(
token: string,
secret: string,
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
): Record<string, unknown> | null {
const segments = token.split(".");
if (segments.length !== 3) return { ok: false };
if (segments.length !== 3) return null;
const [headerSegment, payloadSegment, signatureSegment] = segments;
const header = decodeJsonSegment(headerSegment);
if (header === null || header.alg !== "HS256") return { ok: false };
if (header === null || header.alg !== "HS256") return null;
const expected = createHmac("sha256", secret)
.update(`${headerSegment}.${payloadSegment}`)
@ -103,9 +118,19 @@ export function verifyDocverToken(
.update(Buffer.from(signatureSegment, "base64url"))
.digest();
const expectedHash = createHash("sha256").update(expected).digest();
if (!timingSafeEqual(provided, expectedHash)) return { ok: false };
if (!timingSafeEqual(provided, expectedHash)) return null;
const payload = decodeJsonSegment(payloadSegment);
return decodeJsonSegment(payloadSegment);
}
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
export function verifyDocverToken(
token: string,
secret: string,
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
const payload = verifyTokenSignature(token, secret);
if (payload === null) return { ok: false };
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
@ -116,14 +141,37 @@ export function verifyDocverToken(
return { ok: true, claims };
}
/** ตรวจ login-token ของ SSO (PLAN-SSO.md §3.2) — ต่างจาก verifyDocverToken:
* แยก "หมดอายุ" (signature ถูกแต่ exp เลยมาแล้ว → route ตอบ sso=expired) ออกจาก
* "ผิดทุกกรณีอื่น" (→ sso=invalid) และรับเฉพาะ purpose === "login" (D4 — token
* ของกลุ่ม docver เอามาเข้า SSO ไม่ได้) · ตัดสินชนิด token (purpose) ก่อนอายุ —
* token docver หมดอายุก็เป็น invalid ไม่ใช่ expired (มิฉะนั้นแบนเนอร์แนะนำ
* "กดลิงก์ใหม่" ทั้งที่ mint ใหม่ก็ได้ token ผิดชนิดเหมือนเดิม) */
export function verifyDocverLoginToken(
token: string,
secret: string,
nowSec: number = Math.floor(Date.now() / 1000),
): { ok: true; claims: DocverTokenClaims } | { ok: false; reason: "invalid" | "expired" } {
const payload = verifyTokenSignature(token, secret);
if (payload === null) return { ok: false, reason: "invalid" };
const claims = claimsFromPayload(payload);
if (claims === null) return { ok: false, reason: "invalid" };
if (claims.purpose !== "login") return { ok: false, reason: "invalid" };
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) {
return { ok: false, reason: "invalid" };
}
if (exp <= nowSec) return { ok: false, reason: "expired" };
return { ok: true, claims };
}
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
export function requireDocverApiKey(request: Request): Response | null {
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
const envKey = process.env.DOCVER_API_KEY?.trim();
// const envKey =
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
if (envKey === undefined || envKey === "") {
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
}
@ -151,8 +199,6 @@ export function requireDocverBearer(
const denied = () =>
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
// const secret =
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
if (secret === undefined || secret === "") return denied();
const header = request.headers.get("authorization");
if (header === null) return denied();
@ -160,6 +206,9 @@ export function requireDocverBearer(
if (match === null) return denied();
const verified = verifyDocverToken(match[1], secret);
if (!verified.ok) return denied();
// D4 (PLAN-SSO.md): login-token เป็นของ SSO ของแอป note เท่านั้น — เอามายิง
// API กลุ่ม docver ไม่ได้ (scope escalation) ตอบ 401 เดียวกับ token เน่า
if (verified.claims.purpose === "login") return denied();
return { ok: true, claims: verified.claims };
}

View file

@ -62,6 +62,7 @@ export async function registerExternalFile(
rawFileUrl: string,
rawFileId?: string,
rawTypeId?: string,
opts?: { trusted?: boolean },
): Promise<RegisterExternalFileResult> {
let trimmed = rawFileUrl.trim();
if (!trimmed) return { ok: false, error: "ไม่ได้ระบุลิงก์ไฟล์" };
@ -101,12 +102,21 @@ export async function registerExternalFile(
};
}
// แถว ecourt:<fileId> (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO route
// (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId
// พิมพ์เองโดยผู้มี session ใด ๆ เคยใช้เขียน/refresh pdf_url ของเอกสารคนอื่นให้ชี้
// PDF ที่ตัวเองเลือก — ตอนนี้เปิดดูของที่รู้จักแล้วได้อย่างเดียว (แถว url:… ของ
// samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง)
const ecourtRow = externalId.startsWith("ecourt:");
const trusted = opts?.trusted === true;
const [existing] = await db
.select()
.from(files)
.where(eq(files.externalId, externalId))
.limit(1);
if (existing) {
if (ecourtRow && !trusted) return { ok: true, fileId: existing.id };
// fileId เดิมเจออีกครั้ง: refresh Temp link ใหม่ + ย้ายกลุ่มตาม typeId ล่าสุด
// (ลิงก์ไม่ส่ง typeId มา = คงกลุ่มเดิม ห้ามเคลียร์ — PLAN-ECOURT.md การตัดสินใจ)
const patch: { pdfUrl?: string; typeId?: string } = {};
@ -118,6 +128,13 @@ export async function registerExternalFile(
return { ok: true, fileId: existing.id };
}
if (ecourtRow && !trusted) {
return {
ok: false,
error: "เอกสารของ e-Court รหัสนี้ยังไม่เคยเปิดในระบบ — กรุณาเปิดผ่านลิงก์เอกสารของ e-Court",
};
}
const id = randomUUID();
try {
await db.insert(files).values({

340
lib/note-auth.ts Normal file
View file

@ -0,0 +1,340 @@
// PLAN-SSO.md §4 — ส่วนกลางของ SSO ของแอป note (ฉบับ token 2026-10-30 ตามคำขอ
// ผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ของรอบ D15/D16): ลิงก์ e-Court พา
// browser มา `/note` พร้อม credential (`t` หรือ `ticket_data`+`HmacSha256`) →
// client แลกเป็น access_token ที่ POST /api/note/auth/session → เก็บใน
// localStorage ("note-auth") แล้วแนบ Authorization: Bearer กับทุก API และส่ง
// token เข้าทุก Server Action (identity แกะฝั่ง server — ห้ามเชื่อ userId จาก
// client ตรง ๆ)
//
// pure module — ห้าม import อะไรที่ลาก @/db หรือ node:crypto มาด้วย (client
// component ใช้ร่วมได้ — unit test tests/lib/note-auth.test.ts รันได้ปล่าว ๆ)
/** สาเหตุที่เข้าทางลิงก์ไม่สำเร็จ — "required" ใช้กับหน้า gate ฝั่ง client ตอน
* ไม่มี token ใน localStorage (คู่ขนาน sso=required ของรอบ middleware) */
export type SsoFailReason = "expired" | "invalid" | "error" | "required";
/** ตัวพา identity ในลิงก์ SSO — สองทางพร้อมกัน (มีทั้ง `t` และ ticket) = fail
* closed ตอน parse แล้ว (D12) */
export type SsoCredential =
| { kind: "jwt"; token: string }
| { kind: "ticket"; ticketData: string; hmacHex: string };
export type SsoLinkParams =
| { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string }
| { ok: false };
/** allowlist param ของ landing + cap ความยาว (t ตาม JWT ที่มี claims ไทย
* ยาวได้หลักพัน · ticket_data/hmac ตาม cap ของ parseTicketTokenBody) */
const SSO_PARAM_LIMITS = {
t: 8192,
ticket_data: 2048,
HmacSha256: 128,
fileUrl: 2048,
typeId: 200,
} as const;
/**
* แกะ query ของ URL ที่ลิงก์ e-Court ชี้มา (ใช้ทั้งฝั่ง client ของหน้า /note
* ตอน bootstrap และใน test) — allowlist 5 param เป๊ะ (D12): มี `t` = ทาง JWT ·
* ไม่มี `t` แต่มี `ticket_data`+`HmacSha256` ครบ = ทาง ticket · มีทั้งสองชุด /
* มีครึ่งเดียว / ไม่มีเลย / ยาวเกิน cap = ok:false
*/
export function parseSsoLinkParams(url: URL): SsoLinkParams {
const read = (name: keyof typeof SSO_PARAM_LIMITS): string =>
url.searchParams.get(name) ?? "";
const t = read("t");
const ticketData = read("ticket_data");
const hmacHex = read("HmacSha256");
let credential: SsoCredential;
if (t !== "") {
// มี ticket ปนมาพร้อม t แม้แต่ตัวเดียว = กำกวม → fail closed (D12)
if (ticketData !== "" || hmacHex !== "") return { ok: false };
if (t.length > SSO_PARAM_LIMITS.t) return { ok: false };
credential = { kind: "jwt", token: t };
} else if (ticketData !== "" && hmacHex !== "") {
if (ticketData.length > SSO_PARAM_LIMITS.ticket_data) return { ok: false };
if (hmacHex.length > SSO_PARAM_LIMITS.HmacSha256) return { ok: false };
credential = { kind: "ticket", ticketData, hmacHex };
} else {
// ไม่มีชุดไหนเลย หรือมี ticket ครึ่งเดียว
return { ok: false };
}
const params: { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string } = {
ok: true,
credential,
};
const fileUrl = read("fileUrl");
if (fileUrl !== "") {
if (fileUrl.length > SSO_PARAM_LIMITS.fileUrl) return { ok: false };
params.fileUrl = fileUrl;
}
const typeId = read("typeId");
if (typeId !== "") {
if (typeId.length > SSO_PARAM_LIMITS.typeId) return { ok: false };
params.typeId = typeId;
}
return params;
}
// ---------------------------------------------------------------------------
// body ของ POST /api/note/auth/session (แลก credential → access_token)
// ---------------------------------------------------------------------------
const SESSION_EXCHANGE_LIMITS = SSO_PARAM_LIMITS;
const SESSION_EXCHANGE_FIELDS = ["t", "ticket_data", "HmacSha256", "fileUrl", "typeId"] as const;
export type SessionExchangeBody =
| { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string }
| { ok: false; error: string };
/** body ของเส้นแลก token — เกณฑ์เดียวกับ parseSsoLinkParams (allowlist 5 field
* + cap ยาว + สอง credential พร้อมกัน = invalid ตาม D12) แค่รับทาง JSON body */
export function parseSessionExchangeBody(raw: unknown): SessionExchangeBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(SESSION_EXCHANGE_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${SESSION_EXCHANGE_FIELDS.join(", ")})`,
};
}
}
const str = (name: keyof typeof SESSION_EXCHANGE_LIMITS): string => {
const value = record[name];
return typeof value === "string" ? value : "";
};
const t = str("t");
const ticketData = str("ticket_data");
const hmacHex = str("HmacSha256");
let credential: SsoCredential;
if (t !== "") {
if (ticketData !== "" || hmacHex !== "") {
return { ok: false, error: "ส่ง credential ได้ทางเดียว (t หรือ ticket_data+HmacSha256)" };
}
if (t.length > SESSION_EXCHANGE_LIMITS.t) return { ok: false, error: "t ยาวเกินกำหนด" };
credential = { kind: "jwt", token: t };
} else if (ticketData !== "" && hmacHex !== "") {
if (ticketData.length > SESSION_EXCHANGE_LIMITS.ticket_data) {
return { ok: false, error: "ticket_data ยาวเกินกำหนด" };
}
if (hmacHex.length > SESSION_EXCHANGE_LIMITS.HmacSha256) {
return { ok: false, error: "HmacSha256 ยาวเกินกำหนด" };
}
credential = { kind: "ticket", ticketData, hmacHex };
} else {
return { ok: false, error: "ต้องระบุ t หรือ ticket_data+HmacSha256" };
}
const parsed: { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string } = {
ok: true,
credential,
};
const fileUrl = str("fileUrl");
if (fileUrl !== "") {
if (fileUrl.length > SESSION_EXCHANGE_LIMITS.fileUrl) {
return { ok: false, error: "fileUrl ยาวเกินกำหนด" };
}
parsed.fileUrl = fileUrl;
}
const typeId = str("typeId");
if (typeId !== "") {
if (typeId.length > SESSION_EXCHANGE_LIMITS.typeId) {
return { ok: false, error: "typeId ยาวเกินกำหนด" };
}
parsed.typeId = typeId;
}
return parsed;
}
// ---------------------------------------------------------------------------
// body ของ POST /api/note/auth/token (เส้น mint login-token — D1/D5)
// ---------------------------------------------------------------------------
/** ttl_seconds ทางเลือก — ช่วง 60–600 วินาที default 300 (credential ใน URL
* ต้องอายุสั้น) */
export const LOGIN_TOKEN_TTL = { min: 60, max: 600, default: 300 } as const;
const LOGIN_TOKEN_BODY_FIELDS = ["userid", "username", "fid", "ttl_seconds"] as const;
/** อักขระที่ identity (userid/username) ใช้ไม่ได้ — ค่าไหลลง DB เป็น user_id/
* user_name ของ annotation/bookmark (แสดงผล UI + เงื่อนไขค้นหา): & = + % เป็น
* ตัวที่เคยทำ identity เพี้ยนตอนแพ็กใน payload แบบ query string ของ cookie
* (รอบก่อนเลิก cookie 2026-10-30) — กันไว้เพราะเป็นชุดอักขระที่ตีความต่างกัน
* ตาม context ง่าย (ทาง ticket ใช้เกณฑ์เดียวกันที่ parseDocverTicket) */
const UNSAFE_IDENTITY_RE = /[&=+%]/;
export type LoginTokenBody =
| { ok: true; claims: { userid: string; username: string; fid: string }; ttlSeconds: number }
| { ok: false; error: string };
/** strict allowlist 4 field (ส่ง `purpose` มา = 400 — เส้นนี้เป็น login-token
* เอกสิทธิ์) · cap ความยาว identity ตาม parseTokenRequestBody ของกลุ่ม docver */
export function parseLoginTokenBody(raw: unknown): LoginTokenBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(LOGIN_TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${LOGIN_TOKEN_BODY_FIELDS.join(", ")})`,
};
}
}
const rawUserid = record.userid;
if (typeof rawUserid !== "string" || rawUserid.trim() === "") {
return { ok: false, error: "ต้องระบุ userid (string)" };
}
const userid = rawUserid.trim();
if (userid.length > 100) return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" };
if (UNSAFE_IDENTITY_RE.test(userid)) {
return { ok: false, error: "userid มีอักขระ & = + % ที่รองรับไม่ได้" };
}
const rawUsername = record.username;
if (typeof rawUsername !== "string" || rawUsername.trim() === "") {
return { ok: false, error: "ต้องระบุ username (string)" };
}
const username = rawUsername.trim();
if (username.length > 200) return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" };
if (UNSAFE_IDENTITY_RE.test(username)) {
return { ok: false, error: "username มีอักขระ & = + % ที่รองรับไม่ได้" };
}
const rawFid = record.fid;
if (typeof rawFid !== "string" || rawFid.trim() === "") {
return { ok: false, error: "ต้องระบุ fid (string)" };
}
const fid = rawFid.trim();
if (fid.length > 200) return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
let ttlSeconds: number = LOGIN_TOKEN_TTL.default;
const rawTtl = record.ttl_seconds;
if (rawTtl !== undefined) {
if (
typeof rawTtl !== "number" ||
!Number.isInteger(rawTtl) ||
rawTtl < LOGIN_TOKEN_TTL.min ||
rawTtl > LOGIN_TOKEN_TTL.max
) {
return {
ok: false,
error: `ttl_seconds ต้องเป็นจำนวนเต็ม ${LOGIN_TOKEN_TTL.min}–${LOGIN_TOKEN_TTL.max}`,
};
}
ttlSeconds = rawTtl;
}
return { ok: true, claims: { userid, username, fid }, ttlSeconds };
}
// ---------------------------------------------------------------------------
// dev-link — mint ลิงก์ SSO จากหน้า /note/dev (คำขอผู้ใช้ 2026-10-29 "ใช้ได้ใน note"
// — ย้ายตามหน้า 2026-10-30) เปิดด้วย env NOTE_SSO_DEV_MINT=1 เท่านั้น และตายเสมอ
// ใน production build — ไม่ใช่ทางเข้าจริง (แผน D7) แค่ทางทดสอบของ dev server
// ---------------------------------------------------------------------------
/** เปิดตัวช่วย mint ลิงก์ทดสอบหรือไม่ — env NOTE_SSO_DEV_MINT === "1" และไม่ใช่
* production (dev server บน LAN จึงคุมด้วยการตั้ง/ถอด env เอง) */
export function devMintEnabled(): boolean {
return (
process.env.NOTE_SSO_DEV_MINT?.trim() === "1" &&
process.env.NODE_ENV !== "production"
);
}
const DEV_LINK_BODY_FIELDS = ["userid", "username", "fid", "ttl_seconds", "fileUrl", "typeId", "mode"] as const;
export type DevLinkBody =
| {
ok: true;
claims: { userid: string; username: string; fid: string };
ttlSeconds: number;
fileUrl?: string;
typeId?: string;
mode: "jwt" | "ticket";
}
| { ok: false; error: string };
/** body ของ POST /api/note/auth/dev-link — identity/ttl ใช้เกณฑ์เดียวกับ
* parseLoginTokenBody (ตัด field ส่วนเสริมออกก่อนส่งต่อ ให้เกณฑ์/ข้อความเดียวกัน) */
export function parseDevLinkBody(raw: unknown): DevLinkBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(DEV_LINK_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${DEV_LINK_BODY_FIELDS.join(", ")})`,
};
}
}
const { fileUrl, typeId, mode, ...identity } = record;
const base = parseLoginTokenBody(identity);
if (!base.ok) return base;
const result: DevLinkBody = { ...base, mode: "jwt" };
if (fileUrl !== undefined) {
if (typeof fileUrl !== "string" || fileUrl.trim() === "" || fileUrl.length > 2048) {
return { ok: false, error: "fileUrl ต้องเป็น string (1–2048)" };
}
result.fileUrl = fileUrl.trim();
}
if (typeId !== undefined) {
if (typeof typeId !== "string" || typeId.trim() === "" || typeId.length > 200) {
return { ok: false, error: "typeId ต้องเป็น string (1–200)" };
}
result.typeId = typeId.trim();
}
if (mode !== undefined) {
if (mode !== "jwt" && mode !== "ticket") {
return { ok: false, error: "mode ต้องเป็น jwt หรือ ticket" };
}
result.mode = mode;
}
return result;
}
// ---------------------------------------------------------------------------
// แบนเนอร์สาเหตุ ?sso= / สถานะ gate ฝั่ง client (D8/D15) — ค่าอื่น = null (ไม่แสดง)
// ---------------------------------------------------------------------------
export type SsoBanner = { intent: "warning" | "error"; text: string };
export function ssoBanner(reason: string | undefined): SsoBanner | null {
switch (reason) {
case "expired":
return {
intent: "warning",
text: "ลิงก์เข้าสู่ระบบหมดอายุ — กลับไปที่ e-Court แล้วกดเปิดเอกสารใหม่อีกครั้ง",
};
case "invalid":
return {
intent: "warning",
text: "ลิงก์เข้าสู่ระบบไม่ถูกต้อง — กลับไปที่ e-Court แล้วกดเปิดเอกสารใหม่อีกครั้ง",
};
case "error":
return {
intent: "error",
text: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ (ข้อผิดพลาดฝั่งเซิร์ฟเวอร์) — ลองอีกครั้งภายหลัง หากยังไม่สำเร็จโปรดติดต่อผู้ดูแลระบบ",
};
case "required":
return {
intent: "warning",
text: "กรุณาเข้าสู่ระบบผ่านลิงก์เปิดเอกสารของ e-Court แล้วจึงใช้งานส่วนนี้",
};
default:
return null;
}
}

74
lib/note-session.ts Normal file
View file

@ -0,0 +1,74 @@
// session ของแอป note ฝั่ง client (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
// แบบ add-in" — pattern เดียวกับ wordaddin/services/authService): access_token
// + identity เก็บใน localStorage ("note-auth") หลังแลกจาก POST /api/note/auth/
// session · การเรียก API ของแอปผ่าน noteAuthFetch แนบ Authorization: Bearer
// อัตโนมัติ · token หมดอายุแล้วถือว่าไม่มี session (ล้างทิ้ง) — localStorage อาจ
// โยน (private mode/quota) ทุกจุดจึง try/catch
//
// client-only — ห้าม import จาก server component/module ฝั่ง server
import { withBasePath } from "@/utils/base-path";
export type NoteSession = {
token: string;
userid: string;
username: string;
/** exp epoch มิลลิวินาที — พ้นเวลานี้ถือว่าไม่มี session */
expMs: number;
};
const STORAGE_KEY = "note-auth";
export function readNoteSession(): NoteSession | null {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === null) return null;
const parsed = JSON.parse(raw) as Partial<NoteSession> | null;
if (
parsed === null ||
typeof parsed !== "object" ||
typeof parsed.token !== "string" ||
parsed.token === "" ||
typeof parsed.userid !== "string" ||
parsed.userid === "" ||
typeof parsed.username !== "string" ||
typeof parsed.expMs !== "number"
) {
window.localStorage.removeItem(STORAGE_KEY);
return null;
}
if (parsed.expMs <= Date.now()) {
window.localStorage.removeItem(STORAGE_KEY);
return null;
}
return parsed as NoteSession;
} catch {
return null;
}
}
export function saveNoteSession(session: NoteSession): void {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
} catch {
// private mode/quota — เงียบไว้: ผู้ใช้เข้าผ่านลิงก์ e-Court ใหม่ได้เสมอ
}
}
export function clearNoteSession(): void {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
/** fetch ของแอปพร้อม Bearer — path เป็น route API ของแอป ("/api/note/…")
* ไม่มี session = throw "no-session" ให้ caller เปลี่ยนเป็นหน้า gate เอง */
export async function noteAuthFetch(path: string, init: RequestInit = {}): Promise<Response> {
const session = readNoteSession();
if (session === null) throw new Error("no-session");
return fetch(withBasePath(path), {
...init,
headers: { ...(init.headers ?? {}), authorization: `Bearer ${session.token}` },
});
}

32
lib/note-token.ts Normal file
View file

@ -0,0 +1,32 @@
// session ของแอป note ฝั่ง server (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
// แบบ add-in") — แกะ identity จาก access_token (purpose="login" ออกที่
// POST /api/note/auth/session อายุ NOTE_SESSION_TTL) ให้ Server Actions และ API
// ของแอปใช้ร่วม: token ผิด/หมดอายุ/secret ไม่ตั้ง = null เสมอ (fail closed)
// · ห้าม log ค่า token ดิบ
//
// แยกจาก lib/note-auth.ts (pure ใช้ร่วมกับ client ได้) เพราะต้อง import
// lib/docver-token ซึ่งใช้ node:crypto — ห้ามให้ client component import ไฟล์นี้
import { verifyDocverLoginToken } from "@/lib/docver-token";
/** อายุ access_token ของ session note (วินาที) — 8 ชม. เท่า session สมัย cookie:
* เว็บไม่มี ticket ฝังไฟล์แบบ add-in ให้ renew อัตโนมัติ หมดแล้วผู้ใช้กลับ
* e-Court กดลิงก์ใหม่ */
export const NOTE_SESSION_TTL = 8 * 60 * 60;
export type NoteUserIdentity = { userid: string; username: string };
/** identity จาก token — null = ไม่ผ่านทุกกรณี (ไม่ตั้ง secret รวม) */
export function noteUserFromToken(token: string | null | undefined): NoteUserIdentity | null {
if (typeof token !== "string" || token === "") return null;
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") return null;
const verified = verifyDocverLoginToken(token, secret);
return verified.ok ? { userid: verified.claims.userid, username: verified.claims.username } : null;
}
/** อ่าน `Authorization: Bearer <token>` ของ request → identity (route ใช้) */
export function noteUserFromRequest(request: Request): NoteUserIdentity | null {
const header = request.headers.get("authorization");
if (header === null || !header.startsWith("Bearer ")) return null;
return noteUserFromToken(header.slice("Bearer ".length).trim());
}

View file

@ -55,8 +55,9 @@ export const openApiSpec = {
{
name: "Judge's Note",
description:
"API ภายนอก (N10) — backend e-Court เรียกแบบ server-to-server ของ (Judge's Note)" +
"ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้",
"API ภายนอกของแอป Judge's Note — N10: backend e-Court เรียกแบบ " +
"server-to-server ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้ · " +
"SSO: เส้น mint login-token สำหรับประกอบลิงก์เข้าสู่ระบบ (PLAN-SSO.md)",
},
],
paths: {
@ -615,6 +616,74 @@ export const openApiSpec = {
},
},
},
"/api/note/auth/token": {
post: {
tags: ["Judge's Note"],
summary: "mint login-token สำหรับลิงก์ SSO เข้าแอป note (PLAN-SSO.md)",
description:
"ออก `access_token` ชนิด `purpose=login` ไว้ฝังเป็น param เดียว `t=` ของลิงก์ " +
"`/note?fileUrl=…&t=<access_token>` ที่ให้ผู้ใช้คลิกจากหน้า e-Court " +
"(ทางเข้า session ทางเดียวของแอป note — หน้าแรกแลก credential เป็น session " +
"token ที่ POST /api/note/auth/session แบบ add-in)\n\n" +
"- token ใช้ได้กับ**การเข้าแอป note เท่านั้น** — เอาไปยิง `/api/documents/*` " +
"ของกลุ่ม docver ไม่ได้ (401)\n" +
"- แนะนำให้ mint แบบ real-time ตอน render หน้า/ปุ่มให้ user ปัจจุบันเท่านั้น — " +
"**ห้ามเก็บ token ลง DB / ฝังอีเมล / แชท** (อายุสั้นตาม ttl_seconds)\n" +
"- `userid`/`username` = ค่าเดียวกับ `u_userid`/`u_userlogin` ของ e-Court " +
"(สิทธิ์เจ้าของ annotation ของแอป note ผูกกับ userid — ส่งผิดคน = ข้อมูลผูกคนผิด)\n" +
"- `fileUrl` ของลิงก์ปลายทางต้องผ่าน allowlist ของ N8 (admincourt.go.th + `EXTERNAL_FILE_HOSTS`)",
security: [{ ApiKeyAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["userid", "username", "fid"],
properties: {
userid: { type: "string", description: "u_userid ของ e-Court (1–100 ตัวอักษร)" },
username: { type: "string", description: "u_userlogin ของ e-Court (1–200 ตัวอักษร)" },
fid: { type: "string", description: "fileId ของ e-Court — ใช้เป็น fileId ของ N8 เสมอ (1–200 ตัวอักษร)" },
ttl_seconds: {
type: "integer",
minimum: 60,
maximum: 600,
default: 300,
description: "อายุ token ทางเลือกช่วง 60–600 วินาที (credential อยู่ใน URL ต้องอายุสั้น)",
},
},
},
examples: {
"mint ให้ผู้ใช้ oom เปิดไฟล์ F123": {
value: { userid: "oom", username: "สรวิชญ์", fid: "F123", ttl_seconds: 300 },
},
},
},
},
},
responses: {
200: {
description: "login-token พร้อมฝังเป็น param t ของลิงก์ e-court",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/TokenResponse" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: {
description:
"ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY — ข้อความเดียวกันทุกสาเหตุ)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/token": {
post: {
tags: ["documents"],

View file

@ -1,8 +0,0 @@
// อ่าน identity จาก cookie oacCase ฝั่ง server (mock login e-Court) — เรียกจากทุก page
import { cookies } from "next/headers";
import { OAC_CASE_COOKIE, parseOacCase, type OacUser } from "@/utils/user";
export function getSessionUser(): OacUser | null {
return parseOacCase(cookies().get(OAC_CASE_COOKIE)?.value);
}