From f1f2111fb323b9507a188298f6c07948471baed4 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Tue, 20 Oct 2026 00:30:47 +0530 Subject: [PATCH] feat(auth): cache API key lookups with hit rate metrics and revocation invalidation (#495) --- .env.example | 3 + src/__tests__/authCache.test.ts | 174 ++++++++++++++++++++++++++++++++ src/api/admin.ts | 3 +- src/api/auth.ts | 67 +++++++++++- src/metrics.ts | 7 ++ 5 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/authCache.test.ts diff --git a/.env.example b/.env.example index e9d0cb02..e292825a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/__tests__/authCache.test.ts b/src/__tests__/authCache.test.ts new file mode 100644 index 00000000..c6d6bdfc --- /dev/null +++ b/src/__tests__/authCache.test.ts @@ -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') + }) +}) diff --git a/src/api/admin.ts b/src/api/admin.ts index 131fefac..9c44c467 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, isAdminAuthorized, invalidateApiKeyCache } from './auth' import { copyUsageHistory } from '../x402/metering' /** Generates a new opaque API key with a recognizable `lens_` prefix. */ @@ -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 }) }, diff --git a/src/api/auth.ts b/src/api/auth.ts index b4f44828..e10c13f7 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -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' @@ -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() + +/** + * 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. @@ -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, @@ -84,6 +139,8 @@ async function lookupApiKeyResult( scopes: record.scopes ?? [], }, } + apiKeyLookupCache.set(hash, { result: res, cachedAt: now }) + return res } /** diff --git a/src/metrics.ts b/src/metrics.ts index aa2b8196..d8cf2cde 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -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',