Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/admin-authentication.md
Original file line number Diff line number Diff line change
@@ -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 <ADMIN_SECRET>`.
- **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
68 changes: 68 additions & 0 deletions indexer/test/api/admin-auth.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});