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
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")
scopes String[] @default([])

@@map("api_keys")
}
180 changes: 180 additions & 0 deletions src/__tests__/keyScopes.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
10 changes: 9 additions & 1 deletion src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface CreateKeyBody {
monthlyQuotaCents?: number
dailyQuotaCents?: number
overagePolicy?: 'block' | 'charge_402' | 'allow_overage'
scopes?: string[]
}

/**
Expand All @@ -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' })
}
Expand All @@ -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()
Expand All @@ -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()) } : {}),
},
})

Expand All @@ -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,
})
Expand Down
18 changes: 18 additions & 0 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
label: string
ratePerMin: number
ratePerDay: number
scopes?: string[]
}

declare module 'fastify' {
Expand All @@ -24,6 +25,8 @@
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
}
}

Expand Down Expand Up @@ -63,6 +66,7 @@
label: record.label,
ratePerMin: record.ratePerMin,
ratePerDay: record.ratePerDay,
...(record.scopes && record.scopes.length > 0 ? { scopes: record.scopes } : {}),
},
}
}
Expand Down Expand Up @@ -157,6 +161,20 @@
}

req.apiKey = keyContext

const requiredScope = routeConfig.scope

Check failure on line 165 in src/api/auth.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

Property 'scope' does not exist on type '{ public?: boolean | undefined; }'.
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,
})
}
}
})
}

Expand Down
2 changes: 2 additions & 0 deletions src/routes/price.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading