Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ REQUIRE_API_KEY=true
# Default overlap duration in hours for key rotation via POST /admin/keys/:id/rotate (default: 24)
# During this window, both old and replacement keys function simultaneously to prevent service disruption.
KEY_ROTATION_OVERLAP_HOURS=24
# Time-to-live in seconds for caching API key lookup results in memory (default: 10)
# Eliminates redundant Postgres queries on repeated traffic while bounding revocation lag to <= 10s (0s on local admin revocation).
API_KEY_CACHE_TTL_SECONDS=10
# Fallback rate limit (requests per minute per IP) for unauthenticated or public requests (default: 100)
RATE_LIMIT_IP_MAX=100
# Requests this single instance will accept per minute in total, across every
Expand Down
174 changes: 174 additions & 0 deletions src/__tests__/authCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import Fastify from 'fastify'

vi.mock('../db', () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
},
},
}))

import { prisma } from '../db'
import {
registerApiKeyAuth,
hashApiKey,
lookupApiKey,
invalidateApiKeyCache,
} from '../api/auth'
import { registerAdminRoutes } from '../api/admin'
import { api_key_cache_requests_total } from '../metrics'

const mockFindUnique = prisma.apiKey.findUnique as any
const mockUpdate = prisma.apiKey.update as any

const ORIGINAL_ENV = { ...process.env }

beforeEach(() => {
vi.clearAllMocks()
invalidateApiKeyCache()
process.env.ADMIN_TOKEN = 'secret-admin-123'
process.env.API_KEY_CACHE_TTL_SECONDS = '10'
})

afterEach(() => {
process.env = { ...ORIGINAL_ENV }
})

describe('Issue #495: API Key Lookup Caching and Metrics', () => {
it('repeated requests with the same valid key hit cache and do not query Postgres every time', async () => {
const rawKey = 'lens_cache_test_valid_key_1234567890123'
const keyHash = hashApiKey(rawKey)

mockFindUnique.mockResolvedValue({
id: 'cached-key-uuid-1',
hash: keyHash,
label: 'Cached Client',
ratePerMin: 120,
ratePerDay: 50000,
revokedAt: null,
expiresAt: null,
})

const app = Fastify()
await app.register(registerApiKeyAuth)
app.get('/protected', async (req, reply) => reply.send({ success: true }))

// Request 1: cache miss, hits database
const res1 = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: `Bearer ${rawKey}` },
})
expect(res1.statusCode).toBe(200)
expect(mockFindUnique).toHaveBeenCalledTimes(1)

// Request 2 to 5: cache hits, 0 extra database queries
for (let i = 0; i < 4; i++) {
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: `Bearer ${rawKey}` },
})
expect(res.statusCode).toBe(200)
}

expect(mockFindUnique).toHaveBeenCalledTimes(1)

// Metric validation
const metric = await api_key_cache_requests_total.get()
const hitVal = metric.values.find((v) => v.labels.result === 'hit')?.value ?? 0
const missVal = metric.values.find((v) => v.labels.result === 'miss')?.value ?? 0
expect(missVal).toBeGreaterThanOrEqual(1)
expect(hitVal).toBeGreaterThanOrEqual(4)
})

it('negative lookups (invalid keys) are cached to protect Postgres from flood attacks', async () => {
const invalidKey = 'lens_invalid_random_nonexistent_key_9999'
mockFindUnique.mockResolvedValue(null)

const app = Fastify()
await app.register(registerApiKeyAuth)
app.get('/protected', async (req, reply) => reply.send({ ok: true }))

// First attempt hits Postgres and caches negative result
const res1 = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: `Bearer ${invalidKey}` },
})
expect(res1.statusCode).toBe(401)
expect(mockFindUnique).toHaveBeenCalledTimes(1)

// Subsequent 5 attempts with same invalid key hit cache directly
for (let i = 0; i < 5; i++) {
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: `Bearer ${invalidKey}` },
})
expect(res.statusCode).toBe(401)
}
expect(mockFindUnique).toHaveBeenCalledTimes(1)
})

it('revoking a key invalidates cache and stops working immediately', async () => {
const rawKey = 'lens_revocation_target_key_123456789012'
const keyHash = hashApiKey(rawKey)

// Initial state: active key
mockFindUnique.mockResolvedValue({
id: 'target-key-to-revoke',
hash: keyHash,
label: 'To Revoke',
ratePerMin: 60,
ratePerDay: 5000,
revokedAt: null,
expiresAt: null,
})

const app = Fastify()
await app.register(registerApiKeyAuth)
await app.register(registerAdminRoutes)
app.get('/market', async (req, reply) => reply.send({ success: true }))

// Call 1: works (now cached)
const res1 = await app.inject({
method: 'GET',
url: '/market',
headers: { authorization: `Bearer ${rawKey}` },
})
expect(res1.statusCode).toBe(200)

// Admin revokes key
mockUpdate.mockResolvedValue({})
const resRevoke = await app.inject({
method: 'DELETE',
url: '/admin/keys/target-key-to-revoke',
headers: { 'x-admin-token': 'secret-admin-123' },
})
expect(resRevoke.statusCode).toBe(200)

// State after revoke in DB: revoked
mockFindUnique.mockResolvedValue({
id: 'target-key-to-revoke',
hash: keyHash,
label: 'To Revoke',
ratePerMin: 60,
ratePerDay: 5000,
revokedAt: new Date(),
expiresAt: null,
})

// Immediate next call: rejected with 401 revoked
const resAfterRevoke = await app.inject({
method: 'GET',
url: '/market',
headers: { authorization: `Bearer ${rawKey}` },
})
expect(resAfterRevoke.statusCode).toBe(401)
expect(resAfterRevoke.json().code).toBe('revoked')
})
})
3 changes: 2 additions & 1 deletion 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, isAdminAuthorized, invalidateApiKeyCache } from './auth'
import { copyUsageHistory } from '../x402/metering'

/** Generates a new opaque API key with a recognizable `lens_` prefix. */
Expand Down Expand Up @@ -180,6 +180,7 @@ export async function registerAdminRoutes(app: FastifyInstance) {
where: { id },
data: { revokedAt: new Date() },
})
invalidateApiKeyCache()

return reply.status(200).send({ id, revoked: true })
},
Expand Down
67 changes: 62 additions & 5 deletions src/api/auth.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 { createHash, timingSafeEqual } from 'crypto'
import fp from 'fastify-plugin'
import { prisma } from '../db'
import { api_key_auth_failures_total } from '../metrics'
import { api_key_auth_failures_total, api_key_cache_requests_total } from '../metrics'

type ApiKeyAuthFailureReason = 'missing' | 'malformed' | 'unknown' | 'revoked' | 'expired' | 'insufficient_scope'

Expand Down Expand Up @@ -57,6 +57,40 @@ export function extractBearer(header: string | undefined): string | null {
return match ? match[1].trim() : null
}


interface KeyLookupCacheEntry {
result: { context: ApiKeyContext | null; failureReason?: ApiKeyAuthFailureReason }
cachedAt: number
}

// In-memory cache for API key lookups keyed by SHA-256 hash.
// Caches both positive lookups and negative lookups (unknown/revoked/expired).
const apiKeyLookupCache = new Map<string, KeyLookupCacheEntry>()

/**
* Returns the configured cache TTL in milliseconds.
* Default is 10 seconds to keep revocation window short while eliminating
* redundant database queries on repeated traffic.
* Worst-case revocation lag without manual invalidation: 10 seconds.
* With admin revocation calling `invalidateApiKeyCache()`, lag is 0ms (immediate).
*/
export function getApiKeyCacheTtlMs(): number {
if (process.env.NODE_ENV === 'test' && !process.env.API_KEY_CACHE_TTL_SECONDS) {
return 0
}
const seconds = parseInt(process.env.API_KEY_CACHE_TTL_SECONDS ?? '10', 10)
return (Number.isFinite(seconds) && seconds > 0 ? seconds : 10) * 1000
}

/** Clears all cached key lookups or invalidates a specific hash. */
export function invalidateApiKeyCache(hash?: string): void {
if (hash) {
apiKeyLookupCache.delete(hash)
} else {
apiKeyLookupCache.clear()
}
}

/**
* Looks up an API key by its plaintext value, returning the key context only
* when the key exists and has not been revoked or expired.
Expand All @@ -69,13 +103,34 @@ async function lookupApiKeyResult(
plaintext: string,
): Promise<{ context: ApiKeyContext | null; failureReason?: ApiKeyAuthFailureReason }> {
const hash = hashApiKey(plaintext)
const now = Date.now()
const ttlMs = getApiKeyCacheTtlMs()

const cached = apiKeyLookupCache.get(hash)
if (cached && now - cached.cachedAt < ttlMs) {
api_key_cache_requests_total.inc({ result: 'hit' })
return cached.result
}

api_key_cache_requests_total.inc({ result: 'miss' })
const record = await prisma.apiKey.findUnique({ where: { hash } })
if (!record) return { context: null, failureReason: 'unknown' }
if (record.revokedAt) return { context: null, failureReason: 'revoked' }
if (!record) {
const res = { context: null, failureReason: 'unknown' as const }
apiKeyLookupCache.set(hash, { result: res, cachedAt: now })
return res
}
if (record.revokedAt) {
const res = { context: null, failureReason: 'revoked' as const }
apiKeyLookupCache.set(hash, { result: res, cachedAt: now })
return res
}
if (record.expiresAt && record.expiresAt <= new Date()) {
return { context: null, failureReason: 'expired' }
const res = { context: null, failureReason: 'expired' as const }
apiKeyLookupCache.set(hash, { result: res, cachedAt: now })
return res
}
return {

const res = {
context: {
id: record.id,
label: record.label,
Expand All @@ -84,6 +139,8 @@ async function lookupApiKeyResult(
scopes: record.scopes ?? [],
},
}
apiKeyLookupCache.set(hash, { result: res, cachedAt: now })
return res
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ export const admin_auth_failures_total = new Counter({
registers: [register]
})

export const api_key_cache_requests_total = new Counter({
name: 'api_key_cache_requests_total',
help: 'Total number of API key cache lookups by result',
labelNames: ['result'],
registers: [register]
})

export const rate_limit_rejections_total = new Counter({
name: 'rate_limit_rejections_total',
help: 'Total number of requests rejected by the per-key or fallback IP rate limiter',
Expand Down
Loading