From f2938e8e20a93b16ba76f8ac95c220cc017cb826 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 | 76 ++++++++++++- src/api/auth.ts | 36 ++++++ 5 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/keyLastUsedAt.test.ts diff --git a/.env.example b/.env.example index a23c6be6..ed8ef2ff 100644 --- a/.env.example +++ b/.env.example @@ -157,6 +157,9 @@ 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. REQUIRE_API_KEY=true +# 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 6f8838c4..d7085b09 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -268,6 +268,7 @@ model ApiKey { monthlyQuotaCents Int? @default(10000) @map("monthly_quota_cents") dailyQuotaCents Int? @default(500) @map("daily_quota_cents") overagePolicy String @default("block") @map("overage_policy") + 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 6cd01f68..016a8dae 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -19,9 +19,16 @@ interface CreateKeyBody { overagePolicy?: 'block' | 'charge_402' | 'allow_overage' } +interface ListKeysQuery { + limit?: string | number + offset?: string | number + includeRevoked?: string | boolean +} + /** - * Registers admin-only endpoints for minting and revoking API keys. + * 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). * - `DELETE /admin/keys/:id` — revoke an existing key. * @@ -29,6 +36,72 @@ interface CreateKeyBody { * them; they enforce their own `ADMIN_TOKEN` check instead. */ 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 } }, @@ -80,6 +153,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { overagePolicy: record.overagePolicy, key: plaintext, createdAt: record.createdAt, + lastUsedAt: (record as any).lastUsedAt ?? null, }) }, ) diff --git a/src/api/auth.ts b/src/api/auth.ts index d0eec6dd..dd21db5f 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -157,7 +157,43 @@ async function apiKeyAuthPlugin(app: FastifyInstance) { } req.apiKey = keyContext + 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' })