Add optional API token auth and validation tests
This commit is contained in:
parent
f65e4d9876
commit
bfb8275b2f
@ -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`
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
24
tests/auth.test.js
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user