diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6f8838c4..83b88a61 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") + scopes String[] @default([]) @@map("api_keys") } diff --git a/src/__tests__/keyScopes.test.ts b/src/__tests__/keyScopes.test.ts new file mode 100644 index 00000000..9bc51f19 --- /dev/null +++ b/src/__tests__/keyScopes.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify from 'fastify' + +vi.mock('../db', () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})) + +import { prisma } from '../db' +import { registerApiKeyAuth, hashApiKey } from '../api/auth' +import { registerAdminRoutes } from '../api/admin' + +const mockFindUnique = prisma.apiKey.findUnique as any +const mockCreate = prisma.apiKey.create as any + +const ORIGINAL_ENV = { ...process.env } + +beforeEach(() => { + vi.clearAllMocks() + process.env.ADMIN_TOKEN = 'admin-secret' +}) + +afterEach(() => { + process.env = { ...ORIGINAL_ENV } +}) + +describe('Issue #504: ApiKey Scopes Enforcement', () => { + it('allows minting a key with a custom scope set and validates scope format', async () => { + const app = Fastify() + await app.register(registerAdminRoutes) + + mockCreate.mockImplementation(({ data }: any) => { + return Promise.resolve({ + id: 'new-scoped-key-id', + ...data, + createdAt: new Date(), + }) + }) + + // Valid scope minting + const res = await app.inject({ + method: 'POST', + url: '/admin/keys', + headers: { 'x-admin-token': 'admin-secret' }, + payload: { + label: 'Prices Only Partner', + scopes: ['read:prices', 'read:twap'], + }, + }) + + expect(res.statusCode).toBe(201) + const json = res.json() + expect(json.scopes).toEqual(['read:prices', 'read:twap']) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + scopes: ['read:prices', 'read:twap'], + }), + }) + ) + + // Invalid scope validation (e.g. non-string array) + const resInvalid = await app.inject({ + method: 'POST', + url: '/admin/keys', + headers: { 'x-admin-token': 'admin-secret' }, + payload: { + label: 'Bad Key', + scopes: [' ', 123 as any], + }, + }) + expect(resInvalid.statusCode).toBe(400) + expect(resInvalid.json().error).toContain('scopes') + }) + + it('allows a call when key carries the required scope or wildcard *', async () => { + const app = Fastify() + await app.register(registerApiKeyAuth) + app.get('/market/prices', { config: { scope: 'read:prices' } }, async (req, reply) => { + return reply.send({ success: true, price: 100 }) + }) + + const plainKey = 'lens_scoped_key_for_prices_1234567890123' + const keyHash = hashApiKey(plainKey) + + mockFindUnique.mockResolvedValue({ + id: 'scoped-key-1', + hash: keyHash, + label: 'Price Reader', + ratePerMin: 60, + ratePerDay: 5000, + scopes: ['read:prices'], + revokedAt: null, + expiresAt: null, + }) + + const res = await app.inject({ + method: 'GET', + url: '/market/prices', + headers: { authorization: `Bearer ${plainKey}` }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json().success).toBe(true) + }) + + it('refuses a key lacking the required scope with 403 Forbidden naming the required scope', async () => { + const app = Fastify() + await app.register(registerApiKeyAuth) + app.get('/market/history', { config: { scope: 'read:history' } }, async (req, reply) => { + return reply.send({ data: [] }) + }) + + const plainKey = 'lens_scoped_key_missing_history_1234567' + const keyHash = hashApiKey(plainKey) + + // Key only has 'read:prices', but route requires 'read:history' + mockFindUnique.mockResolvedValue({ + id: 'scoped-key-2', + hash: keyHash, + label: 'Price Only Reader', + ratePerMin: 60, + ratePerDay: 5000, + scopes: ['read:prices'], + revokedAt: null, + expiresAt: null, + }) + + const res = await app.inject({ + method: 'GET', + url: '/market/history', + headers: { authorization: `Bearer ${plainKey}` }, + }) + + expect(res.statusCode).toBe(403) + const err = res.json() + expect(err).toMatchObject({ + error: 'Forbidden', + code: 'insufficient_scope', + requiredScope: 'read:history', + }) + expect(err.message).toContain('Required: read:history') + }) + + it('guarantees legacy unscoped keys (empty array or unset scopes) continue working on all routes', async () => { + const app = Fastify() + await app.register(registerApiKeyAuth) + app.get('/route-a', { config: { scope: 'read:prices' } }, async (req, reply) => reply.send({ route: 'A' })) + app.get('/route-b', { config: { scope: 'read:history' } }, async (req, reply) => reply.send({ route: 'B' })) + app.get('/route-c', async (req, reply) => reply.send({ route: 'C' })) + + const legacyKey = 'lens_legacy_unscoped_key_1234567890123' + const legacyHash = hashApiKey(legacyKey) + + mockFindUnique.mockResolvedValue({ + id: 'legacy-key-uuid', + hash: legacyHash, + label: 'Legacy Integration', + ratePerMin: 60, + ratePerDay: 5000, + scopes: [], // empty array represents legacy key with no scopes + revokedAt: null, + expiresAt: null, + }) + + const resA = await app.inject({ method: 'GET', url: '/route-a', headers: { authorization: `Bearer ${legacyKey}` } }) + expect(resA.statusCode).toBe(200) + + const resB = await app.inject({ method: 'GET', url: '/route-b', headers: { authorization: `Bearer ${legacyKey}` } }) + expect(resB.statusCode).toBe(200) + + const resC = await app.inject({ method: 'GET', url: '/route-c', headers: { authorization: `Bearer ${legacyKey}` } }) + expect(resC.statusCode).toBe(200) + }) +}) diff --git a/src/api/admin.ts b/src/api/admin.ts index 6cd01f68..1096dd80 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -17,6 +17,7 @@ interface CreateKeyBody { monthlyQuotaCents?: number dailyQuotaCents?: number overagePolicy?: 'block' | 'charge_402' | 'allow_overage' + scopes?: string[] } /** @@ -42,7 +43,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { }) } - const { label, expiresAt, ratePerMin, ratePerDay, monthlyQuotaCents, dailyQuotaCents, overagePolicy } = req.body ?? {} + const { label, expiresAt, ratePerMin, ratePerDay, monthlyQuotaCents, dailyQuotaCents, overagePolicy, scopes } = req.body ?? {} if (!label || typeof label !== 'string' || label.trim().length === 0) { return reply.status(400).send({ error: 'label is required' }) } @@ -53,6 +54,11 @@ export async function registerAdminRoutes(app: FastifyInstance) { if (expiration && (!Number.isFinite(expiration.getTime()) || expiration <= new Date())) { return reply.status(400).send({ error: 'expiresAt must be a valid future date' }) } + if (scopes !== undefined) { + if (!Array.isArray(scopes) || scopes.some(s => typeof s !== 'string' || s.trim().length === 0)) { + return reply.status(400).send({ error: 'scopes must be an array of non-empty strings' }) + } + } // Generate the key, store only its hash, and return the plaintext once. const plaintext = generateApiKey() @@ -66,6 +72,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { ...(monthlyQuotaCents !== undefined ? { monthlyQuotaCents } : {}), ...(dailyQuotaCents !== undefined ? { dailyQuotaCents } : {}), ...(overagePolicy !== undefined ? { overagePolicy } : {}), + ...(scopes !== undefined ? { scopes: scopes.map(s => s.trim()) } : {}), }, }) @@ -78,6 +85,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { monthlyQuotaCents: record.monthlyQuotaCents, dailyQuotaCents: record.dailyQuotaCents, overagePolicy: record.overagePolicy, + scopes: (record as any).scopes ?? [], key: plaintext, createdAt: record.createdAt, }) diff --git a/src/api/auth.ts b/src/api/auth.ts index d0eec6dd..dabdaee8 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -15,6 +15,7 @@ export interface ApiKeyContext { label: string ratePerMin: number ratePerDay: number + scopes?: string[] } declare module 'fastify' { @@ -24,6 +25,8 @@ declare module 'fastify' { interface FastifyContextConfig { /** When true, the API-key auth hook skips this route (e.g. health, admin). */ public?: boolean + /** Required capability scope for this route (e.g. 'read:prices', 'read:history'). */ + scope?: string } } @@ -63,6 +66,7 @@ async function lookupApiKeyResult( label: record.label, ratePerMin: record.ratePerMin, ratePerDay: record.ratePerDay, + ...(record.scopes && record.scopes.length > 0 ? { scopes: record.scopes } : {}), }, } } @@ -157,6 +161,20 @@ async function apiKeyAuthPlugin(app: FastifyInstance) { } req.apiKey = keyContext + + const requiredScope = routeConfig.scope + if (requiredScope) { + const keyScopes = keyContext.scopes ?? [] + // Existing keys with no scopes recorded mean "everything" (unrestricted access) + if (keyScopes.length > 0 && !keyScopes.includes(requiredScope) && !keyScopes.includes('*')) { + return reply.status(403).send({ + error: 'Forbidden', + code: 'insufficient_scope', + message: `Insufficient scope. Required: ${requiredScope}`, + requiredScope, + }) + } + } }) } diff --git a/src/routes/price.ts b/src/routes/price.ts index ac259a25..2ea88235 100644 --- a/src/routes/price.ts +++ b/src/routes/price.ts @@ -36,6 +36,7 @@ export async function registerPriceRoutes(app: FastifyInstance) { } }>( '/price/twap/:assetA/:assetB', + { config: { scope: 'read:prices' } }, async (req, reply) => { const log = req.log.child({ component: 'route:price' }) const { assetA, assetB } = req.params @@ -88,6 +89,7 @@ export async function registerPriceRoutes(app: FastifyInstance) { } }>( '/price/vwap/:assetA/:assetB', + { config: { scope: 'read:prices' } }, async (req, reply) => { const log = req.log.child({ component: 'route:price' }) const { assetA, assetB } = req.params