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
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>. 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.
Expand Down
144 changes: 144 additions & 0 deletions src/__tests__/adminTokenDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
22 changes: 19 additions & 3 deletions src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
51 changes: 40 additions & 11 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`).
* 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 <token>`).
* 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 <key>`.
*
Expand Down
2 changes: 2 additions & 0 deletions src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
}
})

Expand Down
1 change: 1 addition & 0 deletions src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 19 additions & 3 deletions src/api/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -153,6 +153,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn

// Admin endpoints (key issuance/revocation) — gated by ADMIN_TOKEN. Marked
// `config.public` so the API-key auth hook skips them.
if (!isAdminConfigured()) {
app.log.warn('ADMIN_TOKEN is not configured; admin routes (/admin/*) are disabled')
}
await registerAdminRoutes(app)
await registerUsageRoutes(app)
await app.register(registerFacilitatorRoutes)
Expand Down
Loading