add /api/auth short-path endpoints (token/session) + swagger group "auth"

- POST /api/auth/token = alias of /api/documents/token (docver access_token,
  both x-api-key and ticket entrances)
- POST /api/auth/session = alias of /api/note/auth/session (exchange e-Court
  link credential for note login token, 8h TTL)
- lib/openapi.ts: new "auth" tag + both paths + SessionExchangeRequest /
  SessionResponse schemas (session endpoint had never been documented)
- tests/lib/openapi.test.ts: guard all $refs resolve + auth paths exist

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-11-05 13:06:45 +07:00
parent ccd257c825
commit 545c9d5703
4 changed files with 525 additions and 2 deletions

View file

@ -1,6 +1,7 @@
// OpenAPI 3.0 spec ของ API ทดสอบทั้งหมดของ judes-note — เส้นเดียวกับที่ Swagger UI
// แสดงผล: กลุ่ม template (DOCX Content Control) + N10 external API + docver
// (จัด version เอกสาร)
// (จัด version เอกสาร) + auth (ทางเข้า path สั้น /api/auth/* — alias ของ
// /api/documents/token กับ /api/note/auth/session เพิ่ม 2026-11-05)
// route handler GET /api/swagger (app/api/swagger/route.ts) เป็นคนเดียวที่ใช้
// — inline เป็น JSON ลงใน HTML ตอน respond (ไม่มี endpoint spec แยก)
//
@ -25,7 +26,8 @@ export const openApiSpec = {
version: "1.0.0",
description:
"API ทดสอบของระบบ Judge's Note — กลุ่ม Template (DOCX Content Control), " +
"API ภายนอกสำหรับ backend e-Court (N10) และ API จัด version เอกสาร (docver)",
"API ภายนอกสำหรับ backend e-Court (N10), API จัด version เอกสาร (docver) " +
"และทางเข้า auth แบบ path สั้น (/api/auth/*)",
},
servers: [{ url: BASE_PATH, description: "origin เดียวกับหน้านี้ (basePath /docedit)" }],
tags: [
@ -59,6 +61,15 @@ export const openApiSpec = {
"server-to-server ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้ · " +
"SSO: เส้น mint login-token สำหรับประกอบลิงก์เข้าสู่ระบบ (PLAN-SSO.md)",
},
{
name: "auth",
description:
"ทางเข้า auth แบบ **path สั้น** `/api/auth/*` (เพิ่ม 2026-11-05) — endpoint " +
"ก้อนเดียวกับของเดิมคนละ URL เลือกใช้ทางใดทางหนึ่งได้:\n" +
"- `POST /api/auth/token` = `POST /api/documents/token` — ออก access_token ของกลุ่ม documents (docver)\n" +
"- `POST /api/auth/session` = `POST /api/note/auth/session` — แลก credential ของลิงก์ e-Court " +
"เป็น session token ของแอป note",
},
],
paths: {
"/api/template/test": {
@ -687,6 +698,181 @@ export const openApiSpec = {
},
},
},
"/api/auth/token": {
post: {
tags: ["auth"],
summary: "ขอ access_token ของกลุ่ม documents (docver) — path สั้นของ /api/documents/token",
description:
"ทางเข้า **path สั้น** ของ `POST /api/documents/token` — endpoint ก้อนเดียวกัน " +
"พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05)\n\n" +
"ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " +
"token ฝัง claims `{userid, username, fid, template}` อายุตาม env " +
"`DOCVER_TOKEN_TTL_SECONDS` (default 604800 วินาที = 7 วัน — token อายุยาวแทน" +
"ระบบ refresh/renew ของ add-in)\n\n" +
"**ทางเข้า 2 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body ได้ทั้งคู่):\n\n" +
"1. **ระบบภายนอก (server-to-server)** — แนบ header `x-api-key` (ค่าจาก env " +
"`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest`\n" +
"2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " +
"ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " +
"ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " +
'(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง ' +
"(contract 2026-11-04: ฟอร์แม็ตพัง/ไม่ครบ = 400 · ลายเซ็นไม่ถูก = 401 · " +
"**ลายเซ็นถูกแต่ ticket หมดอายุ = 403** — add-in ที่มี access_token เดิมของ `fid` " +
"นี้ใน session ยังใช้ต่อได้ ไม่มี = ขอไฟล์ใหม่จาก e-Court)\n\n" +
"ได้ token แล้วแนบเป็น `Authorization: Bearer <token>` กับทุก request ถัดไป",
security: [{ ApiKeyAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
oneOf: [
{ $ref: "#/components/schemas/TokenRequest" },
{ $ref: "#/components/schemas/TicketTokenRequest" },
],
},
examples: {
"server-to-server ด้วย x-api-key (ครบทุก claim)": {
value: {
userid: "123",
username: "Sorawit Bholsithi",
fid: "3",
template: "T12",
},
},
"server-to-server ขั้นต่ำ (template เป็นทางเลือก)": {
value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" },
},
"add-in ด้วย ticket จากไฟล์ (ไม่ต้องมี x-api-key)": {
value: {
ticket_data:
'{"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}',
HmacSha256:
"1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9",
},
},
},
},
},
},
responses: {
200: {
description: "access_token พร้อมใช้ (แนบเป็น Authorization: Bearer ทุก request ถัดไป)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/TokenResponse" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: {
description:
"ทางเข้า x-api-key: ไม่ได้ส่ง header หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY) · " +
"ทางเข้า ticket: ลายเซ็น HMAC ไม่ถูก (ข้อความเดียวกันทุกสาเหตุ)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
403: {
description:
"เฉพาะทางเข้า ticket: ลายเซ็นถูกแต่ ticket หมดอายุ — ใช้ access_token เดิมของ " +
"fid นี้ใน session ต่อ หรือขอไฟล์ใหม่จาก e-Court (contract 2026-11-04)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/auth/session": {
post: {
tags: ["auth"],
summary: "แลก credential ของลิงก์ e-Court เป็น session token ของแอป note (SSO)",
description:
"ทางเข้า **path สั้น** ของ `POST /api/note/auth/session` — endpoint ก้อนเดียวกัน " +
"พฤติกรรมเหมือนกันทุกประการ (เพิ่ม 2026-11-05) · flow SSO ของแอป note " +
"(PLAN-SSO.md §3.2 ฉบับ token): ลิงก์ e-Court พา browser มาหน้า `/note` พร้อม " +
"credential → client เรียกเส้นนี้แลกเป็น access_token เก็บ localStorage " +
'("note-auth") แล้วแนบ `Authorization: Bearer` กับทุก API/Server Action ของแอป\n\n' +
"- ส่ง credential **ทางเดียว**: `t` (login-token จาก `POST /api/note/auth/token`) " +
"หรือ `ticket_data`+`HmacSha256` (คู่ค่าชุดเดียวกับที่ e-Court stamp ใน custom " +
"properties ของไฟล์ .docx ของ add-in) — สองชุดพร้อมกัน = 400 (D12)\n" +
"- token ที่ได้เป็นชนิด `purpose=login` อายุ 8 ชม. (28800 วินาที) — ใช้ได้กับแอป " +
"note เท่านั้น เอาไปยิง `/api/documents/*` ของกลุ่ม docver ไม่ได้ (401)\n" +
"- ส่ง `fileUrl` มาด้วย = ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server ทันที " +
"(fid จาก credential ที่ verify แล้ว) — ล้มก็ยังได้ token พร้อม `fileError` " +
"ให้หน้าแรกแสดงสาเหตุ · `fileUrl` ต้องผ่าน allowlist ของ N8 " +
"(admincourt.go.th + env `EXTERNAL_FILE_HOSTS`) หรือเป็น path `/samples/…`\n" +
"- **ทาง ticket ใน production**: secret ต้องตั้งจริง (env `ADDIN_TICKET_SECRET` " +
'→ `SECRET`) — ค่า default `"super_secret"` ที่รู้กันทั้งระบบถูกปฏิเสธ (500)',
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionExchangeRequest" },
examples: {
"ทาง JWT (param t ของลิงก์ e-Court)": {
value: {
t: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwdXJwb3NlIjoibG9naW4iLCJ1c2VyaWQiOiJvb20iLCJ1c2VybmFtZSI6IuC4geC4m+C4tOC5iOC4quC4lOC5iCIsImZpZCI6IkYxMjMifQ.….….…",
fileUrl: "https://service.admincourt.go.th/uploads/F123.pdf",
typeId: "T12",
},
},
"ทาง ticket (คู่ค่าเดียวกับไฟล์ .docx ของ add-in)": {
value: {
ticket_data:
'{"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}',
HmacSha256:
"1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9",
},
},
},
},
},
},
responses: {
200: {
description:
"session token ของแอป note (พร้อม fileId ถ้าลงทะเบียน fileUrl สำเร็จ)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SessionResponse" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: {
description:
"credential หมดอายุหรือไม่ถูกต้อง — ทาง JWT แยก `reason: expired` (หมดอายุ) กับ " +
"`invalid` · ทาง ticket รวมเป็น `invalid` ทั้งลายเซ็นผิดและหมดอายุ",
content: {
"application/json": {
schema: {
allOf: [
{ $ref: "#/components/schemas/Error" },
{
type: "object",
properties: {
reason: {
type: "string",
enum: ["expired", "invalid"],
},
},
},
],
},
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/token": {
post: {
tags: ["documents"],
@ -1149,6 +1335,73 @@ export const openApiSpec = {
},
},
},
SessionExchangeRequest: {
type: "object",
description:
"credential ของลิงก์ e-Court ที่จะแลกเป็น session token — ส่งทางเดียว: `t` " +
"**หรือ** `ticket_data`+`HmacSha256` (สองชุดพร้อมกัน = 400 ตาม D12) · " +
"allowlist 5 field เป๊ะ — ส่ง field อื่น = 400",
properties: {
t: {
type: "string",
description:
"login-token (JWT purpose=login) จาก POST /api/note/auth/token — ≤ 8192 ตัวอักษร",
},
ticket_data: {
type: "string",
description:
"JSON string ของ ticket ชุดเดียวกับที่ e-Court stamp ในไฟล์ .docx — " +
"≤ 2048 ตัวอักษร (ส่งตรง ๆ ไม่แก้ string — HMAC ผูกกับค่าดิบ)",
},
HmacSha256: {
type: "string",
description: "hex 64 ตัว ของ HMAC-SHA256(secret, ticket_data ดิบ) — ≤ 128 ตัวอักษร",
},
fileUrl: {
type: "string",
description:
"URL เอกสารบน e-Court — ต้องผ่าน allowlist ของ N8 (admincourt.go.th + env " +
"EXTERNAL_FILE_HOSTS) · ส่งมา = ลงทะเบียนเอกสารตอนแลก token (≤ 2048 ตัวอักษร)",
},
typeId: {
type: "string",
description: "รหัสกลุ่มเอกสารของ e-Court (≤ 200 ตัวอักษร)",
},
},
},
SessionResponse: {
type: "object",
description:
"session ของแอป note — client เก็บใน localStorage (\"note-auth\") แล้วแนบ " +
"Authorization: Bearer กับทุก API/Server Action ของแอป",
properties: {
access_token: {
type: "string",
description: "JWT purpose=login อายุ 8 ชม. — ใช้ได้กับแอป note เท่านั้น",
},
token_type: { type: "string", example: "Bearer" },
expires_in: {
type: "integer",
description: "อายุวินาที (NOTE_SESSION_TTL = 28800 = 8 ชม.)",
example: 28800,
},
userid: { type: "string", description: "รหัสผู้ใช้จาก credential (u_userid ของ e-Court)" },
username: { type: "string", description: "ชื่อแสดงผลจาก credential" },
fileId: {
type: "string",
format: "uuid",
description:
"มีเมื่อส่ง fileUrl และลงทะเบียนสำเร็จ — id ของเอกสารในระบบนี้ " +
"(ใช้เข้าหน้าอ่าน /note/annotate/<fileId>)",
},
fileError: {
type: "string",
description:
"มีเมื่อลงทะเบียน fileUrl ไม่สำเร็จ (ไม่ผ่าน allowlist ฯลฯ) — token ยังออกให้ " +
"หน้าแรกแสดงสาเหตุ",
},
},
},
ExtractResponse: {
type: "object",
properties: {