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
# 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
Expand Down
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
167 changes: 167 additions & 0 deletions src/__tests__/keyLastUsedAt.test.ts
Original file line number Diff line number Diff line change
@@ -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())
})
})
108 changes: 75 additions & 33 deletions src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) => {
Expand All @@ -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 } },
Expand Down Expand Up @@ -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',
Expand Down
40 changes: 38 additions & 2 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand All @@ -194,7 +193,44 @@ async function apiKeyAuthPlugin(app: FastifyInstance) {
})
}
}

recordKeyLastUsed(keyContext.id)
})
}

const lastUsedCache = new Map<string, number>()

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' })
Loading