From 34d0c4f3966aeebdfacf51edaefc097c74ada332 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Tue, 20 Oct 2026 00:15:28 +0530 Subject: [PATCH] feat(auth): record and throttle key lastUsedAt on successful authentication (#500) --- .env.example | 3 + prisma/schema.prisma | 1 + src/__tests__/keyLastUsedAt.test.ts | 167 ++++++++++++++++++++++++++++ src/api/admin.ts | 108 ++++++++++++------ src/api/auth.ts | 40 ++++++- 5 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 src/__tests__/keyLastUsedAt.test.ts diff --git a/.env.example b/.env.example index e9d0cb02..9dc77e1d 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 +# Minimum interval in seconds between database writes for updating ApiKey.lastUsedAt (default: 60) +# Throttles background database writes on high-throughput authenticated traffic to prevent read-path amplification. +API_KEY_LAST_USED_THROTTLE_SECONDS=60 # 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/prisma/schema.prisma b/prisma/schema.prisma index 119f6ee6..e35bb0a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -303,6 +303,7 @@ model ApiKey { dailyQuotaCents Int? @default(500) @map("daily_quota_cents") overagePolicy String @default("block") @map("overage_policy") scopes String[] @default([]) + lastUsedAt DateTime? @map("last_used_at") @@map("api_keys") } diff --git a/src/__tests__/keyLastUsedAt.test.ts b/src/__tests__/keyLastUsedAt.test.ts new file mode 100644 index 00000000..84f6dcf4 --- /dev/null +++ b/src/__tests__/keyLastUsedAt.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify from 'fastify' + +vi.mock('../db', () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + findMany: vi.fn(), + count: vi.fn(), + update: vi.fn(), + }, + }, +})) + +import { prisma } from '../db' +import { registerApiKeyAuth, hashApiKey, recordKeyLastUsed, clearLastUsedCache } from '../api/auth' +import { registerAdminRoutes } from '../api/admin' + +const mockFindUnique = prisma.apiKey.findUnique as any +const mockFindMany = prisma.apiKey.findMany as any +const mockCount = prisma.apiKey.count as any +const mockUpdate = prisma.apiKey.update as any + +const ORIGINAL_ENV = { ...process.env } + +beforeEach(() => { + vi.clearAllMocks() + clearLastUsedCache() + process.env.ADMIN_TOKEN = 'admin-secret' + process.env.API_KEY_LAST_USED_THROTTLE_SECONDS = '60' +}) + +afterEach(() => { + process.env = { ...ORIGINAL_ENV } +}) + +describe('Issue #500: ApiKey lastUsedAt recording and throttling', () => { + it('records lastUsedAt on successful authentication', async () => { + const rawKey = 'lens_test_plain_key_11111111111111111111111' + const keyHash = hashApiKey(rawKey) + + mockFindUnique.mockResolvedValue({ + id: 'key-uuid-1', + hash: keyHash, + label: 'Test Client', + ratePerMin: 60, + ratePerDay: 5000, + revokedAt: null, + expiresAt: null, + lastUsedAt: null, + }) + mockUpdate.mockResolvedValue({}) + + const app = Fastify() + await app.register(registerApiKeyAuth) + app.get('/protected', async (req, reply) => { + return reply.send({ ok: true, keyId: req.apiKey?.id }) + }) + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { + authorization: `Bearer ${rawKey}`, + }, + }) + + expect(res.statusCode).toBe(200) + expect(mockUpdate).toHaveBeenCalledTimes(1) + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'key-uuid-1' }, + data: expect.objectContaining({ + lastUsedAt: expect.any(Date), + }), + }) + ) + }) + + it('throttles writes so repeated requests do not cause a database write each time', async () => { + const rawKey = 'lens_test_plain_key_throttled_22222222222222' + const keyHash = hashApiKey(rawKey) + + mockFindUnique.mockResolvedValue({ + id: 'key-uuid-2', + hash: keyHash, + label: 'High Volume Client', + ratePerMin: 1000, + ratePerDay: 100000, + revokedAt: null, + expiresAt: null, + lastUsedAt: null, + }) + mockUpdate.mockResolvedValue({}) + + const app = Fastify() + await app.register(registerApiKeyAuth) + app.get('/data', async (req, reply) => reply.send({ success: true })) + + // Execute 10 consecutive requests within the throttle window + for (let i = 0; i < 10; i++) { + const res = await app.inject({ + method: 'GET', + url: '/data', + headers: { + authorization: `Bearer ${rawKey}`, + }, + }) + expect(res.statusCode).toBe(200) + } + + // Only 1 update should have been triggered across all 10 requests + expect(mockUpdate).toHaveBeenCalledTimes(1) + }) + + it('respects configurable throttle interval from API_KEY_LAST_USED_THROTTLE_SECONDS', async () => { + process.env.API_KEY_LAST_USED_THROTTLE_SECONDS = '1' // 1 second throttle + + mockUpdate.mockResolvedValue({}) + + // First call triggers write + const firstCall = recordKeyLastUsed('key-uuid-3') + expect(firstCall).toBe(true) + expect(mockUpdate).toHaveBeenCalledTimes(1) + + // Immediate second call is throttled + const secondCall = recordKeyLastUsed('key-uuid-3') + expect(secondCall).toBe(false) + expect(mockUpdate).toHaveBeenCalledTimes(1) + }) + + it('exposes lastUsedAt in the admin key listing (GET /admin/keys)', async () => { + const lastUsedDate = new Date('2026-10-19T22:30:00Z') + mockFindMany.mockResolvedValue([ + { + id: 'key-uuid-active', + label: 'Active Key', + ratePerMin: 60, + ratePerDay: 5000, + monthlyQuotaCents: 10000, + dailyQuotaCents: 500, + overagePolicy: 'block', + createdAt: new Date('2026-10-01T00:00:00Z'), + revokedAt: null, + expiresAt: null, + lastUsedAt: lastUsedDate, + }, + ]) + mockCount.mockResolvedValue(1) + + const app = Fastify() + await app.register(registerAdminRoutes) + + const res = await app.inject({ + method: 'GET', + url: '/admin/keys', + headers: { + 'x-admin-token': 'admin-secret', + }, + }) + + expect(res.statusCode).toBe(200) + const json = res.json() + expect(json.keys).toHaveLength(1) + expect(json.keys[0].lastUsedAt).toBe(lastUsedDate.toISOString()) + }) +}) diff --git a/src/api/admin.ts b/src/api/admin.ts index 131fefac..4889bef5 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -25,9 +25,16 @@ interface RotateKeyBody { overlapHours?: number } +interface ListKeysQuery { + limit?: string | number + offset?: string | number + includeRevoked?: string | boolean +} + /** * Registers admin-only endpoints for minting, listing, and revoking API keys. * + * - `GET /admin/keys` — list keys with pagination, revoked filter, and lastUsedAt. * - `POST /admin/keys` — mint a new key (returns the plaintext key ONCE). * - `GET /admin/keys` — list all existing keys with metadata and scopes. * - `DELETE /admin/keys/:id` — revoke an existing key. @@ -36,7 +43,7 @@ interface RotateKeyBody { * them; they enforce their own `ADMIN_TOKEN` check instead. */ export async function registerAdminRoutes(app: FastifyInstance) { - app.get<{ Querystring: { limit?: string } }>( +app.get<{ Querystring: { limit?: string } }>( '/admin/webhook-delivery-failures', { config: { public: true } }, async (req, reply) => { @@ -55,6 +62,72 @@ export async function registerAdminRoutes(app: FastifyInstance) { }, ) + app.get<{ Querystring: ListKeysQuery }>( + '/admin/keys', + { config: { public: true } }, + async (req: FastifyRequest<{ Querystring: ListKeysQuery }>, reply: FastifyReply) => { + if (!isAdminAuthorized(req)) { + admin_auth_failures_total.inc() + return reply.status(401).send({ + error: 'Unauthorized', + code: 'admin_unauthorized', + message: 'Provide a valid X-Admin-Token header.', + }) + } + + const limit = Math.min(Math.max(1, parseInt(String(req.query?.limit ?? 50), 10) || 50), 100) + const offset = Math.max(0, parseInt(String(req.query?.offset ?? 0), 10) || 0) + const includeRevoked = req.query?.includeRevoked === 'true' || req.query?.includeRevoked === true + + const where = includeRevoked ? {} : { revokedAt: null } + + const [total, records] = await Promise.all([ + prisma.apiKey.count({ where }), + prisma.apiKey.findMany({ + where, + select: { + id: true, + label: true, + ratePerMin: true, + ratePerDay: true, + monthlyQuotaCents: true, + dailyQuotaCents: true, + overagePolicy: true, + createdAt: true, + revokedAt: true, + expiresAt: true, + lastUsedAt: true, + }, + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + }), + ]) + + const keys = records.map((r) => ({ + id: r.id, + label: r.label, + ratePerMin: r.ratePerMin, + ratePerDay: r.ratePerDay, + monthlyQuotaCents: r.monthlyQuotaCents, + dailyQuotaCents: r.dailyQuotaCents, + overagePolicy: r.overagePolicy, + createdAt: r.createdAt, + revokedAt: r.revokedAt, + expiresAt: r.expiresAt, + lastUsedAt: r.lastUsedAt, + isRevoked: r.revokedAt !== null, + })) + + return reply.send({ + keys, + total, + limit, + offset, + hasMore: offset + keys.length < total, + }) + }, + ) app.post<{ Body: CreateKeyBody }>( '/admin/keys', { config: { public: true } }, @@ -116,43 +189,12 @@ export async function registerAdminRoutes(app: FastifyInstance) { scopes: record.scopes ?? [], key: plaintext, createdAt: record.createdAt, + lastUsedAt: (record as any).lastUsedAt ?? null, }) }, ) - app.get( - '/admin/keys', - { config: { public: true } }, - async (req: FastifyRequest, reply: FastifyReply) => { - if (!isAdminAuthorized(req)) { - admin_auth_failures_total.inc() - return reply.status(401).send({ - error: 'Unauthorized', - code: 'admin_unauthorized', - message: 'Provide a valid X-Admin-Token header.', - }) - } - - const keys = await prisma.apiKey.findMany({ - orderBy: { createdAt: 'desc' }, - select: { - id: true, - label: true, - scopes: true, - ratePerMin: true, - ratePerDay: true, - revokedAt: true, - expiresAt: true, - createdAt: true, - monthlyQuotaCents: true, - dailyQuotaCents: true, - overagePolicy: true, - }, - }) - return reply.status(200).send({ keys }) - }, - ) app.delete<{ Params: { id: string } }>( '/admin/keys/:id', diff --git a/src/api/auth.ts b/src/api/auth.ts index b4f44828..752afe7d 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -180,8 +180,7 @@ async function apiKeyAuthPlugin(app: FastifyInstance) { } req.apiKey = keyContext - - const requiredScopes = routeConfig.scopes ?? (routeConfig.scope ? [routeConfig.scope] : []) +const requiredScopes = routeConfig.scopes ?? (routeConfig.scope ? [routeConfig.scope] : []) for (const scope of requiredScopes) { if (!hasScope(keyContext, scope)) { api_key_auth_failures_total.inc({ reason: 'insufficient_scope' }) @@ -194,7 +193,44 @@ async function apiKeyAuthPlugin(app: FastifyInstance) { }) } } + + recordKeyLastUsed(keyContext.id) }) } +const lastUsedCache = new Map() + +export function getLastUsedThrottleMs(): number { + const seconds = parseInt(process.env.API_KEY_LAST_USED_THROTTLE_SECONDS ?? '60', 10) + return (Number.isFinite(seconds) && seconds > 0 ? seconds : 60) * 1000 +} + +export function clearLastUsedCache(): void { + lastUsedCache.clear() +} + +export function recordKeyLastUsed(keyId: string): boolean { + const now = Date.now() + const lastRecorded = lastUsedCache.get(keyId) ?? 0 + const throttleMs = getLastUsedThrottleMs() + if (now - lastRecorded < throttleMs) { + return false + } + lastUsedCache.set(keyId, now) + try { + const updatePromise = prisma.apiKey.update({ + where: { id: keyId }, + data: { lastUsedAt: new Date(now) }, + }) + if (updatePromise && typeof updatePromise.catch === 'function') { + updatePromise.catch(() => { + // Fire-and-forget: do not block or fail auth if lastUsedAt update errors + }) + } + } catch { + // Fire-and-forget: swallow errors if mock or DB client throws synchronously + } + return true +} + export const registerApiKeyAuth = fp(apiKeyAuthPlugin, { name: 'api-key-auth' })