From bfb8275b2fa581b3e2dc6fccd1e35f38d992245b Mon Sep 17 00:00:00 2001 From: Rbanh Date: Wed, 18 Feb 2026 20:59:18 -0500 Subject: [PATCH] Add optional API token auth and validation tests --- README.md | 2 ++ docs/operations-runbook.md | 7 +++++++ docs/release-checklist.md | 1 + src/server.js | 35 +++++++++++++++++++++++++++++++++++ tests/auth.test.js | 24 ++++++++++++++++++++++++ 5 files changed, 69 insertions(+) create mode 100644 tests/auth.test.js diff --git a/README.md b/README.md index 40f31a6..5f4e3eb 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Version metadata: Operational limits: - `MAX_BODY_BYTES` (default `2097152`) limits request payload size. - `MAX_REQUESTS_PER_MINUTE` (default `120`) applies per-client throttling for POST API endpoints. +- `SCHEMETA_AUTH_TOKEN` (optional) enforces bearer/API-key auth for POST API endpoints. + - send either `Authorization: Bearer ` or `x-api-key: ` Docs: - `docs/release-checklist.md` diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index 2b35d0d..26f11ef 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -15,6 +15,10 @@ This runbook covers baseline production operation for Schemeta API + UI. - Hard limit for request body size on `POST` endpoints. - `MAX_REQUESTS_PER_MINUTE` (default `120`) - Per-client IP rate limit window for `POST` endpoints. +- `SCHEMETA_AUTH_TOKEN` (optional) + - When set, all `POST` API routes require either: + - `Authorization: Bearer ` + - `x-api-key: ` - `CORS_ORIGIN` (optional) - If set, CORS is enabled for this origin only. @@ -41,6 +45,9 @@ This runbook covers baseline production operation for Schemeta API + UI. - post same sample to `/analyze`. 4. Verify rate limiting: - exceed `MAX_REQUESTS_PER_MINUTE` with repeated `POST` and confirm `429`. +5. Verify auth (if enabled): + - request `POST /compile` without token and confirm `401`. + - request with valid token and confirm `200`. ## Incident Playbook diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 6b2b8bc..d7ca4d5 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -31,6 +31,7 @@ Use this checklist before cutting a release tag. - [ ] `PORT` - [ ] `MAX_BODY_BYTES` - [ ] `MAX_REQUESTS_PER_MINUTE` + - [ ] `SCHEMETA_AUTH_TOKEN` (if hosted deployment requires auth) - [ ] `CORS_ORIGIN` - [ ] Rate limiting behavior manually validated. - [ ] Health endpoint checked in target environment. diff --git a/src/server.js b/src/server.js index f6151e5..b73042c 100644 --- a/src/server.js +++ b/src/server.js @@ -10,6 +10,7 @@ const PORT = Number(process.env.PORT ?? "8787"); const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 2 * 1024 * 1024); const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*"; const MAX_REQUESTS_PER_MINUTE = Number(process.env.MAX_REQUESTS_PER_MINUTE ?? 120); +const SCHEMETA_AUTH_TOKEN = String(process.env.SCHEMETA_AUTH_TOKEN ?? "").trim(); const FRONTEND_ROOT = join(process.cwd(), "frontend"); export const API_VERSION = "0.3.0"; export const SCHEMA_VERSION = "1.0.0"; @@ -47,6 +48,24 @@ export function errorEnvelope(code, message) { }; } +export function isAuthorizedRequest(req) { + if (!SCHEMETA_AUTH_TOKEN) { + return true; + } + const auth = req.headers?.authorization; + if (typeof auth === "string") { + const m = /^Bearer\s+(.+)$/i.exec(auth.trim()); + if (m && m[1] === SCHEMETA_AUTH_TOKEN) { + return true; + } + } + const apiKey = req.headers?.["x-api-key"]; + if (typeof apiKey === "string" && apiKey.trim() === SCHEMETA_AUTH_TOKEN) { + return true; + } + return false; +} + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -213,6 +232,10 @@ export function createRequestHandler() { } if (req.method === "POST" && pathname === "/analyze") { + if (!isAuthorizedRequest(req)) { + res.setHeader("WWW-Authenticate", "Bearer"); + return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.")); + } if (!withinRateLimit(req)) { return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); } @@ -232,6 +255,10 @@ export function createRequestHandler() { } if (req.method === "POST" && pathname === "/compile") { + if (!isAuthorizedRequest(req)) { + res.setHeader("WWW-Authenticate", "Bearer"); + return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.")); + } if (!withinRateLimit(req)) { return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); } @@ -251,6 +278,10 @@ export function createRequestHandler() { } if (req.method === "POST" && pathname === "/layout/auto") { + if (!isAuthorizedRequest(req)) { + res.setHeader("WWW-Authenticate", "Bearer"); + return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.")); + } if (!withinRateLimit(req)) { return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); } @@ -279,6 +310,10 @@ export function createRequestHandler() { } if (req.method === "POST" && pathname === "/layout/tidy") { + if (!isAuthorizedRequest(req)) { + res.setHeader("WWW-Authenticate", "Bearer"); + return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.")); + } if (!withinRateLimit(req)) { return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); } diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..b2038ab --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("auth is open when SCHEMETA_AUTH_TOKEN is not configured", async () => { + delete process.env.SCHEMETA_AUTH_TOKEN; + const mod = await import(`../src/server.js?auth_open=${Date.now()}`); + const req = { headers: {} }; + assert.equal(mod.isAuthorizedRequest(req), true); +}); + +test("auth accepts bearer token when configured", async () => { + process.env.SCHEMETA_AUTH_TOKEN = "topsecret"; + const mod = await import(`../src/server.js?auth_bearer=${Date.now()}`); + assert.equal(mod.isAuthorizedRequest({ headers: {} }), false); + assert.equal(mod.isAuthorizedRequest({ headers: { authorization: "Bearer topsecret" } }), true); + assert.equal(mod.isAuthorizedRequest({ headers: { authorization: "Bearer wrong" } }), false); +}); + +test("auth accepts x-api-key when configured", async () => { + process.env.SCHEMETA_AUTH_TOKEN = "topsecret"; + const mod = await import(`../src/server.js?auth_xapikey=${Date.now()}`); + assert.equal(mod.isAuthorizedRequest({ headers: { "x-api-key": "topsecret" } }), true); + assert.equal(mod.isAuthorizedRequest({ headers: { "x-api-key": "wrong" } }), false); +});