Add optional API token auth and validation tests

This commit is contained in:
Rbanh 2026-02-18 20:59:18 -05:00
parent f65e4d9876
commit bfb8275b2f
5 changed files with 69 additions and 0 deletions

View File

@ -31,6 +31,8 @@ Version metadata:
Operational limits: Operational limits:
- `MAX_BODY_BYTES` (default `2097152`) limits request payload size. - `MAX_BODY_BYTES` (default `2097152`) limits request payload size.
- `MAX_REQUESTS_PER_MINUTE` (default `120`) applies per-client throttling for POST API endpoints. - `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 <token>` or `x-api-key: <token>`
Docs: Docs:
- `docs/release-checklist.md` - `docs/release-checklist.md`

View File

@ -15,6 +15,10 @@ This runbook covers baseline production operation for Schemeta API + UI.
- Hard limit for request body size on `POST` endpoints. - Hard limit for request body size on `POST` endpoints.
- `MAX_REQUESTS_PER_MINUTE` (default `120`) - `MAX_REQUESTS_PER_MINUTE` (default `120`)
- Per-client IP rate limit window for `POST` endpoints. - Per-client IP rate limit window for `POST` endpoints.
- `SCHEMETA_AUTH_TOKEN` (optional)
- When set, all `POST` API routes require either:
- `Authorization: Bearer <token>`
- `x-api-key: <token>`
- `CORS_ORIGIN` (optional) - `CORS_ORIGIN` (optional)
- If set, CORS is enabled for this origin only. - 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`. - post same sample to `/analyze`.
4. Verify rate limiting: 4. Verify rate limiting:
- exceed `MAX_REQUESTS_PER_MINUTE` with repeated `POST` and confirm `429`. - 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 ## Incident Playbook

View File

@ -31,6 +31,7 @@ Use this checklist before cutting a release tag.
- [ ] `PORT` - [ ] `PORT`
- [ ] `MAX_BODY_BYTES` - [ ] `MAX_BODY_BYTES`
- [ ] `MAX_REQUESTS_PER_MINUTE` - [ ] `MAX_REQUESTS_PER_MINUTE`
- [ ] `SCHEMETA_AUTH_TOKEN` (if hosted deployment requires auth)
- [ ] `CORS_ORIGIN` - [ ] `CORS_ORIGIN`
- [ ] Rate limiting behavior manually validated. - [ ] Rate limiting behavior manually validated.
- [ ] Health endpoint checked in target environment. - [ ] Health endpoint checked in target environment.

View File

@ -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 MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 2 * 1024 * 1024);
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*"; const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*";
const MAX_REQUESTS_PER_MINUTE = Number(process.env.MAX_REQUESTS_PER_MINUTE ?? 120); 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"); const FRONTEND_ROOT = join(process.cwd(), "frontend");
export const API_VERSION = "0.3.0"; export const API_VERSION = "0.3.0";
export const SCHEMA_VERSION = "1.0.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) { function readBody(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = []; const chunks = [];
@ -213,6 +232,10 @@ export function createRequestHandler() {
} }
if (req.method === "POST" && pathname === "/analyze") { 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)) { if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); 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 (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)) { if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); 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 (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)) { if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); 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 (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)) { if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
} }

24
tests/auth.test.js Normal file
View File

@ -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);
});