Skip to content
Open
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 @@ -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
Expand Down
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
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())
})
})
76 changes: 75 additions & 1 deletion src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,89 @@ 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.
*
* All routes are marked `config.public = true` so the API-key auth hook skips
* 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 } },
Expand Down Expand Up @@ -80,6 +153,7 @@ export async function registerAdminRoutes(app: FastifyInstance) {
overagePolicy: record.overagePolicy,
key: plaintext,
createdAt: record.createdAt,
lastUsedAt: (record as any).lastUsedAt ?? null,
})
},
)
Expand Down
36 changes: 36 additions & 0 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,43 @@ async function apiKeyAuthPlugin(app: FastifyInstance) {
}

req.apiKey = keyContext
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