Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ curl -X POST http://localhost:3000/api/v1/contracts \
| `INDEXER_START_LEDGER` | `0` | Ledger to start indexing from |
| `INDEXER_POLL_INTERVAL_MS` | `5000` | Polling interval |
| `INDEXER_BATCH_SIZE` | `100` | Ledgers per batch |
| `ADMIN_SECRET` | — | Bearer token required by every `/api/admin/*` route (indexer). See [`docs/ADMIN_AUTH.md`](./docs/ADMIN_AUTH.md). |

## Mainnet Config

Expand Down
70 changes: 70 additions & 0 deletions docs/ADMIN_AUTH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Admin API Authentication

Every route under `/api/admin/*` on the indexer (`indexer/src/routes/admin.js`,
mounted from `indexer/src/api.js`) — including the four analytics endpoints
the frontend rate-limit dashboard calls (`/api/admin/analytics/rate-limit-hits`,
`/api/admin/analytics/top-users`, `/api/admin/analytics/violation-heatmap`,
`/api/admin/analytics/upgrade-recommendations`) — requires a valid admin
bearer token. This document covers how that token is configured, its scope,
expiry, and rotation.

## Scheme

`Authorization: Bearer <token>`, checked in
[`indexer/src/admin/adminAuth.js`](../indexer/src/admin/adminAuth.js) against
the `ADMIN_SECRET` environment variable using `crypto.timingSafeEqual` (constant-time
comparison, to avoid leaking the secret via timing).

The check is applied once, to the whole admin router
(`router.use(adminAuthMiddleware)` in `indexer/src/routes/admin.js`), so every
route registered on that router — present and future — is covered without
needing to remember to add the middleware per-route.

## Issuance / configuration

`ADMIN_SECRET` is a single shared secret set as an environment variable at
deploy time (see `indexer/src/config.js`). There is no per-admin token
issuance flow — anyone holding the value of `ADMIN_SECRET` has full admin
access to every route on the router.

## Scope

All-or-nothing: a valid token authorizes every `/api/admin/*` route. There is
no tiering or per-route scoping, so there is no case where a *valid* token is
rejected for insufficient permission — the middleware therefore only ever
returns `401`, never `403`. If per-route scoping is introduced later, add the
`403` case here and in the corresponding tests.

## Expiry

None. The token is a static secret; it is valid until it is rotated (i.e.
until the `ADMIN_SECRET` env var is changed and the service is redeployed).

## Rotation

1. Generate a new high-entropy secret.
2. Update `ADMIN_SECRET` in the deployment environment.
3. Redeploy/restart the indexer so it picks up the new value (the middleware
reads `process.env.ADMIN_SECRET` per-request, so no code change is
needed — only the env var and a process restart to load it).
4. Revoke the old value by ensuring it is no longer set anywhere (old
requests using it now fail with `401`).

## Logging

The `Authorization` header and raw token are never logged: request logging
(`indexer/src/api.js`) does not log headers, and the audit logger
(`indexer/src/audit/auditLogger.js`) records only `method`, `endpoint`,
`status_code`, `ip`, and `user-agent` — never `authorization`.

## Status codes

| Condition | Status | Body |
|---|---|---|
| No `Authorization` header, or not `Bearer <token>` | `401` | `{ "error": "Unauthorized" }` |
| Token present but does not match `ADMIN_SECRET` | `401` | `{ "error": "Unauthorized" }` |
| `ADMIN_SECRET` not configured on the server | `401` | `{ "error": "Unauthorized" }` (fails closed) |
| Valid token | — | request proceeds |

See `tests/admin-auth.test.ts` for coverage of unauthenticated, invalid-token,
and valid-token access on each `/api/admin/*` route.
73 changes: 73 additions & 0 deletions indexer/test/api/admin-auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import request from "supertest";

// Issue #22: verify every /api/admin/* route enforces admin auth and returns
// the correct status for missing/invalid/valid tokens.
//
// Ensure process.env uses TEST_DATABASE_URL, matching the other test/api/*.test.js files.
const DB_URL = process.env.TEST_DATABASE_URL || process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/soroban_test";
process.env.DATABASE_URL = DB_URL;
process.env.ADMIN_SECRET = "test-admin-secret";
process.env.API_KEY = "test-api-key";
process.env.VERIFY_ABI = "false";

import { db } from "../../src/db.js";
import { startApi } from "../../src/api.js";

// Every route registered on the admin router in src/routes/admin.js.
const ADMIN_ROUTES = [
{ method: "get", path: "/api/admin/api-keys" },
{ method: "post", path: "/api/admin/api-keys" },
{ method: "patch", path: "/api/admin/api-keys/test-id" },
{ method: "delete", path: "/api/admin/api-keys/test-id" },
{ method: "post", path: "/api/admin/api-keys/test-id/rotate" },
{ method: "get", path: "/api/admin/api-keys/test-id/usage" },
{ 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" },
];

// The four routes the frontend rate-limit dashboard calls directly (issue #22).
const ANALYTICS_ROUTES = ADMIN_ROUTES.filter((r) => r.path.startsWith("/api/admin/analytics/"));

describe("Admin route authentication (issue #22)", () => {
let server;
let app;

beforeAll(async () => {
await db.init();
server = startApi();
app = server;
});

afterAll(async () => {
if (server && server.close) {
await new Promise((resolve) => server.close(resolve));
}
});

describe.each(ADMIN_ROUTES)("$method $path", ({ method, path }) => {
it("returns 401 with no Authorization header", async () => {
const res = await request(app)[method](path);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "Unauthorized" });
});

it("returns 401 with an invalid token", async () => {
const res = await request(app)[method](path).set("Authorization", "Bearer wrong-token");
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "Unauthorized" });
});
});

describe.each(ANALYTICS_ROUTES)("$method $path with a valid token", ({ method, path }) => {
it("does not return 401", async () => {
const res = await request(app)
[method](path)
.set("Authorization", `Bearer ${process.env.ADMIN_SECRET}`);
expect(res.status).not.toBe(401);
});
});
});
Loading