feat(docver): add access token endpoint and Bearer auth on all endpoints

- POST /api/documents/token issues JWT HS256 (claims userid/username/fid/template) after x-api-key check against new env DOCVER_API_KEY (fail-closed, timing-safe, uniform 401)
- all docver endpoints now require Authorization: Bearer, verified with env DOCVER_TOKEN_SECRET (TTL via DOCVER_TOKEN_TTL_SECONDS, default 3600); secret unset = 401 on verify / 500 on issue
- identity (userId/userName) + templateId extracted from token claims instead of request payload; removed from multipart form (legacy fields now 400)
- lib/docver-token.ts: hand-rolled JWT via node:crypto only — alg pinned to HS256 (anti alg-confusion), timing-safe compare with both sides hashed, exp enforced, identical 401 for all bearer failure modes
- openapi spec: /api/documents/token path + DocverBearer scheme; PLAN-DOCVER.md / CLAUDE.md / .env.example updated
- tests: new tests/lib/docver-token.test.ts + adjusted parseVersionUploadForm tests (258 pass, no DB env needed)
- package-lock.json: sync minio (^8.0.7) that was missing from the earlier docver commit

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-26 06:29:19 +07:00
parent 280cc2da40
commit 98b370c925
13 changed files with 932 additions and 131 deletions

View file

@ -1,12 +1,14 @@
// PLAN-DOCVER.md §1 — GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
// ของผู้ใช้ — เช็คทั้ง docId และ versionId พร้อมกันกันโยน id ข้ามเอกสาร
// auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม)
import {
docverError,
parseDocId,
parseDownloadFormat,
parseVersionIdParam,
} from "@/lib/document-versioning";
import { requireDocverBearer } from "@/lib/docver-token";
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
export const runtime = "nodejs";
@ -16,6 +18,9 @@ export async function GET(
request: Request,
{ params }: { params: { docId: string; versionId: string } },
) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
const docId = parseDocId(params.docId);
if (!docId.ok) return docverError(docId.error, 400);
const versionId = parseVersionIdParam(params.versionId);

View file

@ -1,6 +1,8 @@
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/{docId}/versions
// กลุ่ม API stand-alone ไม่มี auth (เปิดกว้างเหมือน api/template — intranet) — ผู้เรียกบอก
// identity เองผ่าน field userId/userName เพราะไม่มี session ร่วมกับใคร
// กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่
// POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ
// Authorization: Bearer ทุก request — identity (userid/username) และ templateId
// แกะจาก claims ของ token ไม่รับจาก multipart form อีก (lib/docver-token.ts)
import { createHash, randomUUID } from "node:crypto";
import { toDocumentVersionDTO } from "@/db/schema";
@ -14,6 +16,7 @@ import {
parseVersionUploadForm,
sanitizeFilePartName,
} from "@/lib/document-versioning";
import { requireDocverBearer } from "@/lib/docver-token";
import {
findDocumentByDocId,
insertVersionRecord,
@ -27,11 +30,14 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
* ลำดับ: validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
export async function POST(request: Request, { params }: { params: { docId: string } }) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
const docId = parseDocId(params.docId);
if (!docId.ok) return docverError(docId.error, 400);
@ -114,14 +120,14 @@ export async function POST(request: Request, { params }: { params: { docId: stri
contentHash,
message: parsed.form.message,
fields: parsed.form.fields,
templateId: parsed.form.templateId,
templateId: auth.claims.template,
fileName: docxDisplayName,
docxObjectKey: keys.docx,
pdfObjectKey: pdfBytes ? keys.pdf : null,
docxSize: docxBytes.length,
pdfSize: pdfBytes?.length ?? null,
userId: parsed.form.userId,
userName: parsed.form.userName,
userId: auth.claims.userid,
userName: auth.claims.username,
});
} catch (err) {
// DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้
@ -142,7 +148,10 @@ export async function POST(request: Request, { params }: { params: { docId: stri
/** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404
* (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */
export async function GET(_request: Request, { params }: { params: { docId: string } }) {
export async function GET(request: Request, { params }: { params: { docId: string } }) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
const docId = parseDocId(params.docId);
if (!docId.ok) return docverError(docId.error, 400);

View file

@ -0,0 +1,47 @@
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
// ยืนยันด้วย header "x-api-key" เทียบ env DOCVER_API_KEY (fail closed เมื่อ env ไม่
// ตั้ง/ว่าง) — token เป็น JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
import { docverError } from "@/lib/document-versioning";
import {
issueDocverToken,
parseTokenRequestBody,
requireDocverApiKey,
} from "@/lib/docver-token";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
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 = parseTokenRequestBody(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 ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
const ttlSeconds = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
const accessToken = issueDocverToken(parsed.claims, secret, ttlSeconds);
return new Response(
JSON.stringify({
access_token: accessToken,
token_type: "Bearer",
expires_in: ttlSeconds,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}