From e775741c673e7ea6ffd2c5e15c64374f47865780 Mon Sep 17 00:00:00 2001 From: samuel2926i39-art <308312945+samuel2926i39-art@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:03:40 -0700 Subject: [PATCH] Document admin auth scheme and add auth coverage for analytics routes Verifies all /api/admin/* routes (including the four analytics endpoints the dashboard calls) already enforce adminAuthMiddleware via router.use(), returning 401 for missing/invalid bearer tokens. Adds a docs page covering token configuration, scope, expiration, and rotation, and a test suite that exercises every admin route's auth boundary without requiring a live database. --- docs/admin-authentication.md | 72 +++++++++++++++++++++++++++++ indexer/test/api/admin-auth.test.js | 68 +++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 docs/admin-authentication.md create mode 100644 indexer/test/api/admin-auth.test.js diff --git a/docs/admin-authentication.md b/docs/admin-authentication.md new file mode 100644 index 0000000..e09f81f --- /dev/null +++ b/docs/admin-authentication.md @@ -0,0 +1,72 @@ +# Admin Endpoint Authentication + +This document describes the authentication scheme protecting all `/api/admin/*` +routes, including the admin analytics endpoints consumed by the frontend +dashboard (`/api/admin/analytics/rate-limit-hits`, `/top-users`, +`/violation-heatmap`, `/upgrade-recommendations`), the API key management +routes, and the audit log routes. + +## Scheme + +Admin routes use a single static bearer token, not per-user tokens or JWTs. + +- **Configuration**: set the `ADMIN_SECRET` environment variable (see + `indexer/.env.example`). It is optional at the schema level + (`indexer/src/config.js`), but if it is unset, `adminAuthMiddleware` + rejects **every** request to `/api/admin/*` with `401`. +- **Request format**: clients send `Authorization: Bearer `. +- **Scope**: the token is all-or-nothing — it grants access to every route + under `/api/admin/*`. There are no per-route or per-action scopes. +- **Comparison**: the middleware (`indexer/src/admin/adminAuth.js`) compares + the supplied token to `ADMIN_SECRET` with `crypto.timingSafeEqual` to avoid + timing side-channels, after normalizing buffer length. + +## Expiration and rotation + +`ADMIN_SECRET` is a long-lived static secret — it does not expire on its own. + +To rotate it: + +1. Generate a new secret value. +2. Update `ADMIN_SECRET` in the deployment environment. +3. Restart the indexer process (the middleware reads `process.env.ADMIN_SECRET` + per request, so no code change is needed, but the running process must pick + up the new environment variable). +4. Update any clients (e.g. the admin dashboard) with the new value. + +There is no dual-secret grace period today — rotation is a hard cutover. +Treat `ADMIN_SECRET` the same as any other production credential: store it in +your secrets manager, not in source control or `.env` files committed to git. + +## Enforcement + +Every route under `/api/admin/*` is mounted on an Express `Router` that +applies `adminAuthMiddleware` via `router.use(adminAuthMiddleware)` +(`indexer/src/routes/admin.js`), so new routes added to that router are +protected automatically — there is no per-route opt-in. + +- Missing or malformed `Authorization` header → `401 { "error": "Unauthorized" }` +- Present but incorrect token → `401 { "error": "Unauthorized" }` +- `ADMIN_SECRET` not configured on the server → `401` for all requests +- Valid token → request proceeds + +The middleware does not return `403` today: there is no notion of an +authenticated-but-insufficiently-privileged admin caller, since the token is +all-or-nothing. + +## Logging + +The admin token is never logged: there is no request logger in +`indexer/src/api.js` that dumps request headers, and `adminAuth.js` does not +log the `Authorization` header or the configured secret. Keep it that way — +avoid adding wholesale header/body logging (e.g. `morgan('combined')`) to +routes under `/api/admin/*` without redacting `Authorization`. + +## Test coverage + +`indexer/test/api/admin-auth.test.js` asserts: + +- `401` for every `/api/admin/*` route (including all four analytics routes) + when the `Authorization` header is missing +- `401` when the header carries a well-formed but incorrect bearer token +- `200` for a representative admin analytics route when the token is valid diff --git a/indexer/test/api/admin-auth.test.js b/indexer/test/api/admin-auth.test.js new file mode 100644 index 0000000..065788b --- /dev/null +++ b/indexer/test/api/admin-auth.test.js @@ -0,0 +1,68 @@ +import request from "supertest"; +import express from "express"; +import { jest } from "@jest/globals"; + +// Issue #22: verify every /api/admin/* route (including the four analytics +// routes consumed by the dashboard) enforces authentication, returns the +// correct status codes, and that a valid token still reaches the handler. +// +// The db pool is mocked so this suite runs without a live Postgres instance, +// unlike the other test/api/*.test.js files which exercise a real database. + +process.env.ADMIN_SECRET = "test-admin-secret"; + +const queryMock = jest.fn().mockResolvedValue({ rows: [] }); + +jest.unstable_mockModule("../../src/db.js", () => ({ + db: {}, + pool: { query: queryMock }, +})); + +const { default: registerAdminRoutes } = await import("../../src/routes/admin.js"); + +function buildApp() { + const app = express(); + app.use(express.json()); + registerAdminRoutes(app); + return app; +} + +const ADMIN_ROUTES = [ + { method: "get", path: "/api/admin/api-keys" }, + { method: "get", path: "/api/admin/audit-log" }, + { method: "get", path: "/api/admin/audit-log/export" }, + { method: "get", path: "/api/admin/analytics/rate-limit-hits" }, + { method: "get", path: "/api/admin/analytics/top-users" }, + { method: "get", path: "/api/admin/analytics/violation-heatmap" }, + { method: "get", path: "/api/admin/analytics/upgrade-recommendations" }, +]; + +describe("/api/admin/* authentication (issue #22)", () => { + const app = buildApp(); + + beforeEach(() => { + queryMock.mockClear(); + }); + + it.each(ADMIN_ROUTES)("returns 401 for $path with no Authorization header", async ({ method, path }) => { + const res = await request(app)[method](path); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Unauthorized" }); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it.each(ADMIN_ROUTES)("returns 401 for $path with an invalid bearer token", async ({ method, path }) => { + const res = await request(app)[method](path).set("Authorization", "Bearer wrong-token"); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Unauthorized" }); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it("returns 200 for an admin analytics route with a valid bearer token", async () => { + const res = await request(app) + .get("/api/admin/analytics/rate-limit-hits") + .set("Authorization", `Bearer ${process.env.ADMIN_SECRET}`); + expect(res.status).toBe(200); + expect(queryMock).toHaveBeenCalledTimes(1); + }); +});