diff --git a/.env.example b/.env.example index a23c6be6..857a21c2 100644 --- a/.env.example +++ b/.env.example @@ -151,8 +151,10 @@ WS_BUFFER_SWEEP_INTERVAL_MS=5000 # Secret key required for admin endpoints (e.g., adding/removing pairs) ADMIN_API_KEY=super-secret-admin-key -# Shared secret guarding the API-key admin endpoints (POST/DELETE /admin/keys) -# and the `npm run key:issue` CLI. Send as the X-Admin-Token header. +# Shared secret guarding the API-key admin endpoints (POST/DELETE /admin/keys, +# GET /admin/usage) and the `npm run key:issue` CLI. Send as X-Admin-Token header +# or Authorization: Bearer . When unset, empty, or a placeholder, the admin +# surface is disabled at startup and admin routes return 503 admin_not_configured. ADMIN_TOKEN=super-secret-admin-token # Set to "false" to disable API-key auth entirely (e.g. local dev). Any other # value (or unset) leaves auth enabled, so every non-public route needs a key. diff --git a/src/__tests__/adminTokenDiagnostics.test.ts b/src/__tests__/adminTokenDiagnostics.test.ts new file mode 100644 index 00000000..86d43d0e --- /dev/null +++ b/src/__tests__/adminTokenDiagnostics.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify from 'fastify' + +vi.mock('../db', () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, + pgPool: { + query: vi.fn().mockResolvedValue({ rows: [{ last_ledger: 100, last_processed_at: new Date() }] }), + }, +})) + +vi.mock('../redis', () => ({ + redis: { + get: vi.fn(), + multi: vi.fn().mockReturnThis(), + on: vi.fn(), + }, + getCachedPrice: vi.fn(), + setCachedPrice: vi.fn(), +})) + +vi.mock('../x402/metering', () => ({ + getUsageSummary: vi.fn().mockResolvedValue({ keyId: 'k1', dailyCalls: 5 }), + getAllUsageSummaries: vi.fn().mockResolvedValue([]), +})) + +import { registerAdminRoutes } from '../api/admin' +import { registerUsageRoutes } from '../api/usage' +import { registerRESTRoutes } from '../api/rest' +import { registerNetworkSelector } from '../middleware/network' +import { checkAdminAuth, isAdminConfigured } from '../api/auth' + +const ORIGINAL_ADMIN_TOKEN = process.env.ADMIN_TOKEN + +afterEach(() => { + if (ORIGINAL_ADMIN_TOKEN === undefined) { + delete process.env.ADMIN_TOKEN + } else { + process.env.ADMIN_TOKEN = ORIGINAL_ADMIN_TOKEN + } +}) + +function buildApp() { + const app = Fastify({ logger: false }) + app.register(registerNetworkSelector) + app.register(registerAdminRoutes) + app.register(registerUsageRoutes) + app.register(registerRESTRoutes) + return app +} + +describe('ADMIN_TOKEN unset diagnostics and distinguishing responses (#508)', () => { + it('detects when ADMIN_TOKEN is not configured', () => { + delete process.env.ADMIN_TOKEN + expect(isAdminConfigured()).toBe(false) + + process.env.ADMIN_TOKEN = '' + expect(isAdminConfigured()).toBe(false) + + process.env.ADMIN_TOKEN = ' ' + expect(isAdminConfigured()).toBe(false) + + process.env.ADMIN_TOKEN = 'your-admin-token-here' + expect(isAdminConfigured()).toBe(false) + + process.env.ADMIN_TOKEN = 'real-admin-secret-token-12345' + expect(isAdminConfigured()).toBe(true) + }) + + it('returns 503 admin_not_configured for all admin endpoints when ADMIN_TOKEN is unset', async () => { + delete process.env.ADMIN_TOKEN + const app = buildApp() + + const endpoints = [ + { method: 'POST' as const, url: '/admin/keys', payload: { label: 'test' } }, + { method: 'DELETE' as const, url: '/admin/keys/key-123' }, + { method: 'GET' as const, url: '/admin/usage' }, + { method: 'GET' as const, url: '/admin/usage/key-123' }, + ] + + for (const ep of endpoints) { + const res = await app.inject({ + method: ep.method, + url: ep.url, + payload: ep.payload, + headers: { 'x-admin-token': 'any-token' }, + }) + + expect(res.statusCode).toBe(503) + const body = res.json() + expect(body.error).toBe('Service Unavailable') + expect(body.code).toBe('admin_not_configured') + expect(body.message).toContain('Admin surface is disabled') + expect(JSON.stringify(body)).not.toContain('super-secret') + } + }) + + it('distinguishes wrong token (401) from unconfigured server (503)', async () => { + process.env.ADMIN_TOKEN = 'configured-secret-token' + const app = buildApp() + + // 1. Wrong token -> 401 admin_unauthorized + const wrongRes = await app.inject({ + method: 'POST', + url: '/admin/keys', + payload: { label: 'key-1' }, + headers: { 'x-admin-token': 'wrong-token' }, + }) + expect(wrongRes.statusCode).toBe(401) + expect(wrongRes.json().code).toBe('admin_unauthorized') + expect(wrongRes.json().message).toContain('Provide a valid X-Admin-Token header') + + // 2. Unset token -> 503 admin_not_configured + delete process.env.ADMIN_TOKEN + const unsetRes = await app.inject({ + method: 'POST', + url: '/admin/keys', + payload: { label: 'key-1' }, + headers: { 'x-admin-token': 'wrong-token' }, + }) + expect(unsetRes.statusCode).toBe(503) + expect(unsetRes.json().code).toBe('admin_not_configured') + }) + + it('surfaces adminConfigured on /status endpoint', async () => { + delete process.env.ADMIN_TOKEN + const appUnset = buildApp() + const resUnset = await appUnset.inject({ method: 'GET', url: '/status' }) + expect(resUnset.statusCode).toBe(200) + expect(resUnset.json().adminConfigured).toBe(false) + + process.env.ADMIN_TOKEN = 'valid-token' + const appSet = buildApp() + const resSet = await appSet.inject({ method: 'GET', url: '/status' }) + expect(resSet.statusCode).toBe(200) + expect(resSet.json().adminConfigured).toBe(true) + }) +}) diff --git a/src/api/admin.ts b/src/api/admin.ts index 6cd01f68..fd887d27 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import { randomBytes } from 'crypto' import { prisma } from '../db' import { admin_auth_failures_total } from '../metrics' -import { hashApiKey, isAdminAuthorized } from './auth' +import { hashApiKey, checkAdminAuth } from './auth' /** Generates a new opaque API key with a recognizable `lens_` prefix. */ export function generateApiKey(): string { @@ -33,7 +33,15 @@ export async function registerAdminRoutes(app: FastifyInstance) { '/admin/keys', { config: { public: true } }, async (req: FastifyRequest<{ Body: CreateKeyBody }>, reply: FastifyReply) => { - if (!isAdminAuthorized(req)) { + const auth = checkAdminAuth(req) + if (auth === 'not_configured') { + return reply.status(503).send({ + error: 'Service Unavailable', + code: 'admin_not_configured', + message: 'Admin surface is disabled: ADMIN_TOKEN is not configured on this server.', + }) + } + if (auth !== 'authorized') { admin_auth_failures_total.inc() return reply.status(401).send({ error: 'Unauthorized', @@ -88,7 +96,15 @@ export async function registerAdminRoutes(app: FastifyInstance) { '/admin/keys/:id', { config: { public: true } }, async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - if (!isAdminAuthorized(req)) { + const auth = checkAdminAuth(req) + if (auth === 'not_configured') { + return reply.status(503).send({ + error: 'Service Unavailable', + code: 'admin_not_configured', + message: 'Admin surface is disabled: ADMIN_TOKEN is not configured on this server.', + }) + } + if (auth !== 'authorized') { admin_auth_failures_total.inc() return reply.status(401).send({ error: 'Unauthorized', diff --git a/src/api/auth.ts b/src/api/auth.ts index d0eec6dd..58af9a21 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -67,28 +67,57 @@ async function lookupApiKeyResult( } } +export type AdminAuthResult = 'authorized' | 'unauthorized' | 'not_configured' + /** - * Guards admin routes with a shared secret supplied via the `ADMIN_TOKEN` - * env var, sent as `X-Admin-Token` (or `Authorization: Bearer `). - * Returns true if the caller is authorized. - * - * This is the single implementation used by every admin route (keys and - * usage), so the two surfaces cannot drift apart. + * Checks whether ADMIN_TOKEN is set to a non-empty, non-placeholder value. + * An unset token, whitespace-only string, or obvious placeholder disables the admin surface. */ -export function isAdminAuthorized(req: FastifyRequest): boolean { - const adminToken = process.env.ADMIN_TOKEN - if (!adminToken) return false +export function isAdminConfigured(): boolean { + const adminToken = process.env.ADMIN_TOKEN?.trim() + if (!adminToken || adminToken.length === 0) return false + const weakPlaceholders = new Set(['your-admin-token-here', 'change-me', 'placeholder']) + return !weakPlaceholders.has(adminToken.toLowerCase()) +} + +/** + * Evaluates admin authorization for an incoming request: + * - 'not_configured': ADMIN_TOKEN is not set or empty (admin surface disabled) + * - 'unauthorized': valid token configured, but request has missing/wrong token + * - 'authorized': request provided matching token in constant-time + */ +export function checkAdminAuth(req: FastifyRequest): AdminAuthResult { + if (!isAdminConfigured()) { + return 'not_configured' + } + const adminToken = process.env.ADMIN_TOKEN!.trim() const supplied = (req.headers['x-admin-token'] as string | undefined) ?? req.headers['authorization']?.replace(/^Bearer\s+/i, '') - if (!supplied) return false + if (!supplied) return 'unauthorized' const a = Buffer.from(supplied) const b = Buffer.from(adminToken) // Constant-time comparison (timingSafeEqual requires equal-length buffers). // The length guard is not itself a meaningful leak for a high-entropy token. - return a.length === b.length && timingSafeEqual(a, b) + if (a.length !== b.length || !timingSafeEqual(a, b)) { + return 'unauthorized' + } + return 'authorized' } +/** + * Guards admin routes with a shared secret supplied via the `ADMIN_TOKEN` + * env var, sent as `X-Admin-Token` (or `Authorization: Bearer `). + * Returns true if the caller is authorized. + * + * This is the single implementation used by every admin route (keys and + * usage), so the two surfaces cannot drift apart. + */ +export function isAdminAuthorized(req: FastifyRequest): boolean { + return checkAdminAuth(req) === 'authorized' +} + + /** * Fastify plugin that authenticates requests via `Authorization: Bearer `. * diff --git a/src/api/rest.ts b/src/api/rest.ts index 4dcea006..a0db3d9d 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -5,6 +5,7 @@ import { getAggregatedPrice } from '../aggregator/vwap' import { getBestRoute } from '../aggregator/bestRoute' import { pgPool } from '../db' import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config' +import { isAdminConfigured } from './auth' import '../middleware/network' // declares req.network on the FastifyRequest type import { statusResponseSchema, @@ -92,6 +93,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { ingestLagSeconds: lastProcessedAt ? Math.max(0, Math.round((Date.now() - new Date(lastProcessedAt).getTime()) / 1000)) : null, + adminConfigured: isAdminConfigured(), } }) diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 4c9a2233..255bd060 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -82,6 +82,7 @@ export const statusResponseSchema = { // Seconds since the last ingester write for this network, null before the // first one. Lets `/status` reveal a stalled ingester on its own. ingestLagSeconds: { type: ['integer', 'null'] }, + adminConfigured: { type: 'boolean' }, }, } as const diff --git a/src/api/usage.ts b/src/api/usage.ts index 63754383..ac105ea4 100644 --- a/src/api/usage.ts +++ b/src/api/usage.ts @@ -2,14 +2,22 @@ import type { FastifyInstance, FastifyReply } from 'fastify' import fp from 'fastify-plugin' import { getUsageSummary, getAllUsageSummaries } from '../x402/metering' import { admin_auth_failures_total, api_key_auth_failures_total } from '../metrics' -import { isAdminAuthorized } from './auth' +import { checkAdminAuth } from './auth' export async function registerUsageRoutes(app: FastifyInstance) { app.get<{ Params: { keyId: string } }>( '/admin/usage/:keyId', { config: { public: true } }, async (req, reply) => { - if (!isAdminAuthorized(req)) { + const auth = checkAdminAuth(req) + if (auth === 'not_configured') { + return reply.status(503).send({ + error: 'Service Unavailable', + code: 'admin_not_configured', + message: 'Admin surface is disabled: ADMIN_TOKEN is not configured on this server.', + }) + } + if (auth !== 'authorized') { admin_auth_failures_total.inc() return reply.status(401).send({ error: 'Unauthorized', @@ -27,7 +35,15 @@ export async function registerUsageRoutes(app: FastifyInstance) { '/admin/usage', { config: { public: true } }, async (req, reply) => { - if (!isAdminAuthorized(req)) { + const auth = checkAdminAuth(req) + if (auth === 'not_configured') { + return reply.status(503).send({ + error: 'Service Unavailable', + code: 'admin_not_configured', + message: 'Admin surface is disabled: ADMIN_TOKEN is not configured on this server.', + }) + } + if (auth !== 'authorized') { admin_auth_failures_total.inc() return reply.status(401).send({ error: 'Unauthorized', diff --git a/src/app.ts b/src/app.ts index 405e4c48..04c8f9b9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,7 +13,7 @@ import { registerX402 } from './middleware/x402' import { registerNetworkSelector } from './middleware/network' import { registerHttpMetrics } from './middleware/httpMetrics' import { registerWebSocket } from './api/websocket' -import { registerApiKeyAuth } from './api/auth' +import { registerApiKeyAuth, isAdminConfigured } from './api/auth' import { registerAdminRoutes } from './api/admin' import { registerUsageRoutes } from './api/usage' import { registerFacilitatorRoutes } from './api/facilitator' @@ -153,6 +153,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise