Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ AMM_PAGE_SIZE=200
# Secret key required for admin endpoints (e.g., adding/removing pairs)
ADMIN_API_KEY=super-secret-admin-key

# Shared secret guarding the API-key admin endpoints (POST/DELETE /admin/keys)
# and the `npm run key:issue` CLI. Send as the X-Admin-Token header.
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

# --- App Logic ---
# Comma-separated list of asset pairs to watch.
# Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"version-packages": "changeset version",
"release": "changeset tag",
"oracle:relay": "tsx examples/oracle-relay/relay.ts",
"key:issue": "tsx scripts/issue-api-key.ts",
"db:push": "prisma db push",
"db:generate": "prisma generate"
},
Expand Down
12 changes: 12 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,15 @@ model Webhook {
@@index([assetA, assetB])
@@map("webhooks")
}

model ApiKey {
id String @id @default(uuid())
hash String @unique
label String
ratePerMin Int @default(60) @map("rate_per_min")
ratePerDay Int @default(10000) @map("rate_per_day")
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")

@@map("api_keys")
}
68 changes: 68 additions & 0 deletions scripts/issue-api-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* CLI to mint the first (or any) Lens API key.
*
* The plaintext key is printed exactly once — only its SHA-256 hash is stored.
* Run with:
* npm run key:issue -- --label "Acme integrator" --per-min 120 --per-day 50000
*
* Requires DATABASE_URL to be set (same as the server).
*/
import 'dotenv/config'
import { randomBytes, createHash } from 'crypto'
import { PrismaClient } from '@prisma/client'

function parseArgs(argv: string[]): Record<string, string> {
const args: Record<string, string> = {}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg.startsWith('--')) {
const key = arg.slice(2)
const next = argv[i + 1]
if (next && !next.startsWith('--')) {
args[key] = next
i++
} else {
args[key] = 'true'
}
}
}
return args
}

async function main() {
const args = parseArgs(process.argv.slice(2))
const label = args.label ?? 'default'
const ratePerMin = args['per-min'] ? parseInt(args['per-min'], 10) : undefined
const ratePerDay = args['per-day'] ? parseInt(args['per-day'], 10) : undefined

const plaintext = `lens_${randomBytes(24).toString('hex')}`
const hash = createHash('sha256').update(plaintext, 'utf8').digest('hex')

const prisma = new PrismaClient()
try {
const record = await prisma.apiKey.create({
data: {
hash,
label,
...(ratePerMin !== undefined ? { ratePerMin } : {}),
...(ratePerDay !== undefined ? { ratePerDay } : {}),
},
})

console.log('✅ API key created.')
console.log(` id: ${record.id}`)
console.log(` label: ${record.label}`)
console.log(` ratePerMin: ${record.ratePerMin}`)
console.log(` ratePerDay: ${record.ratePerDay}`)
console.log('')
console.log(' Plaintext key (store it now — it will not be shown again):')
console.log(` ${plaintext}`)
} finally {
await prisma.$disconnect()
}
}

main().catch((err) => {
console.error('Failed to create API key:', err)
process.exit(1)
})
14 changes: 14 additions & 0 deletions sql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,17 @@ CREATE TABLE IF NOT EXISTS indexer_state (
last_processed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- API keys for authenticated, rate-quota'd access.
-- Only the SHA-256 hash of each key is ever stored — never the plaintext.
CREATE TABLE IF NOT EXISTS api_keys (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
rate_per_min INTEGER NOT NULL DEFAULT 60,
rate_per_day INTEGER NOT NULL DEFAULT 10000,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys (hash);
262 changes: 262 additions & 0 deletions src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createHash } from 'crypto'
import Fastify from 'fastify'
import rateLimit from '@fastify/rate-limit'

// ── Mock Prisma (mirrors the pattern used by webhooks.test.ts) ─────────────────
vi.mock('../db', () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}))

import { prisma } from '../db'
import {
registerApiKeyAuth,
hashApiKey,
extractBearer,
lookupApiKey,
} from '../api/auth'
import { registerAdminRoutes, generateApiKey } from '../api/admin'

const mockFindUnique = prisma.apiKey.findUnique as unknown as ReturnType<typeof vi.fn>
const mockCreate = prisma.apiKey.create as unknown as ReturnType<typeof vi.fn>
const mockUpdate = prisma.apiKey.update as unknown as ReturnType<typeof vi.fn>

function sha256(s: string): string {
return createHash('sha256').update(s, 'utf8').digest('hex')
}

/** Builds an app with auth enabled + one protected and one public route. */
async function buildAuthedApp() {
const app = Fastify()
// Auth must register before rate-limit: both run in `onRequest`, and the
// limiter reads req.apiKey, which the auth hook populates.
await app.register(registerApiKeyAuth)
await app.register(rateLimit, {
max: (req) => req.apiKey?.ratePerMin ?? 100,
timeWindow: '1 minute',
keyGenerator: (req) => req.apiKey?.id ?? req.ip,
})
app.get('/price/test', async () => ({ ok: true }))
app.get('/status', { config: { public: true } }, async () => ({ ok: true }))
await app.ready()
return app
}

beforeEach(() => {
mockFindUnique.mockReset()
mockCreate.mockReset()
mockUpdate.mockReset()
})

describe('hashApiKey / extractBearer', () => {
it('hashes keys with SHA-256 (never stores plaintext)', () => {
expect(hashApiKey('lens_secret')).toBe(sha256('lens_secret'))
// 64 hex chars = SHA-256
expect(hashApiKey('x')).toMatch(/^[a-f0-9]{64}$/)
})

it('extracts the bearer token, case-insensitively', () => {
expect(extractBearer('Bearer abc123')).toBe('abc123')
expect(extractBearer('bearer spaced ')).toBe('spaced')
expect(extractBearer(undefined)).toBeNull()
expect(extractBearer('Basic abc')).toBeNull()
})
})

describe('lookupApiKey', () => {
it('looks up by hash and returns context for a valid key', async () => {
mockFindUnique.mockResolvedValue({
id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null,
})
const ctx = await lookupApiKey('lens_plain')
expect(mockFindUnique).toHaveBeenCalledWith({ where: { hash: sha256('lens_plain') } })
expect(ctx).toEqual({ id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000 })
})

it('returns null for an unknown key', async () => {
mockFindUnique.mockResolvedValue(null)
expect(await lookupApiKey('nope')).toBeNull()
})

it('returns null for a revoked key', async () => {
mockFindUnique.mockResolvedValue({
id: 'key-1', label: 'acme', ratePerMin: 60, ratePerDay: 1000, revokedAt: new Date(),
})
expect(await lookupApiKey('lens_plain')).toBeNull()
})
})

describe('API key auth hook', () => {
it('returns 401 when no key is provided', async () => {
const app = await buildAuthedApp()
const res = await app.inject({ method: 'GET', url: '/price/test' })
expect(res.statusCode).toBe(401)
expect(res.json()).toMatchObject({ error: 'Unauthorized' })
expect(mockFindUnique).not.toHaveBeenCalled()
})

it('returns 401 for an invalid key', async () => {
mockFindUnique.mockResolvedValue(null)
const app = await buildAuthedApp()
const res = await app.inject({
method: 'GET',
url: '/price/test',
headers: { authorization: 'Bearer wrong' },
})
expect(res.statusCode).toBe(401)
})

it('returns 401 for a revoked key', async () => {
mockFindUnique.mockResolvedValue({
id: 'key-1', label: 'acme', ratePerMin: 60, ratePerDay: 1000, revokedAt: new Date(),
})
const app = await buildAuthedApp()
const res = await app.inject({
method: 'GET',
url: '/price/test',
headers: { authorization: 'Bearer revoked' },
})
expect(res.statusCode).toBe(401)
})

it('allows a valid key through and attaches metadata', async () => {
mockFindUnique.mockResolvedValue({
id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null,
})
const app = await buildAuthedApp()
const res = await app.inject({
method: 'GET',
url: '/price/test',
headers: { authorization: 'Bearer good' },
})
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true })
})

it('lets public routes through without a key', async () => {
const app = await buildAuthedApp()
const res = await app.inject({ method: 'GET', url: '/status' })
expect(res.statusCode).toBe(200)
expect(mockFindUnique).not.toHaveBeenCalled()
})
})

describe('per-key rate quotas', () => {
it('honors each key’s own ratePerMin (429 after the quota)', async () => {
// A key allowed only 2 requests/min.
mockFindUnique.mockResolvedValue({
id: 'key-limited', label: 'small', ratePerMin: 2, ratePerDay: 1000, revokedAt: null,
})
const app = await buildAuthedApp()
const headers = { authorization: 'Bearer limited' }

const r1 = await app.inject({ method: 'GET', url: '/price/test', headers })
const r2 = await app.inject({ method: 'GET', url: '/price/test', headers })
const r3 = await app.inject({ method: 'GET', url: '/price/test', headers })

expect(r1.statusCode).toBe(200)
expect(r2.statusCode).toBe(200)
expect(r3.statusCode).toBe(429) // quota exhausted
})

it('tracks quotas independently per key', async () => {
const app = await buildAuthedApp()
// First key: limited to 1/min.
mockFindUnique.mockResolvedValue({
id: 'key-a', label: 'a', ratePerMin: 1, ratePerDay: 100, revokedAt: null,
})
const a1 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer a' } })
const a2 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer a' } })
expect(a1.statusCode).toBe(200)
expect(a2.statusCode).toBe(429)

// Second, distinct key gets its own fresh bucket.
mockFindUnique.mockResolvedValue({
id: 'key-b', label: 'b', ratePerMin: 1, ratePerDay: 100, revokedAt: null,
})
const b1 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer b' } })
expect(b1.statusCode).toBe(200)
})
})

describe('admin endpoints', () => {
const ORIGINAL = process.env.ADMIN_TOKEN
beforeEach(() => { process.env.ADMIN_TOKEN = 'admin-secret' })
afterEach(() => { process.env.ADMIN_TOKEN = ORIGINAL })

async function buildAdminApp() {
const app = Fastify()
await registerAdminRoutes(app)
await app.ready()
return app
}

it('generateApiKey produces a prefixed opaque key', () => {
const k = generateApiKey()
expect(k).toMatch(/^lens_[a-f0-9]{48}$/)
})

it('rejects minting without the admin token', async () => {
const app = await buildAdminApp()
const res = await app.inject({
method: 'POST', url: '/admin/keys', payload: { label: 'x' },
})
expect(res.statusCode).toBe(401)
expect(mockCreate).not.toHaveBeenCalled()
})

it('mints a key and returns the plaintext once (stored as hash only)', async () => {
mockCreate.mockImplementation(async ({ data }: any) => ({
id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, ...data,
}))
const app = await buildAdminApp()
const res = await app.inject({
method: 'POST',
url: '/admin/keys',
headers: { 'x-admin-token': 'admin-secret' },
payload: { label: 'acme', ratePerMin: 120, ratePerDay: 50000 },
})
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body.key).toMatch(/^lens_[a-f0-9]{48}$/)
expect(body.label).toBe('acme')
// What got persisted is the HASH of the returned key, not the key itself.
const stored = mockCreate.mock.calls[0][0].data
expect(stored.hash).toBe(sha256(body.key))
expect(stored.hash).not.toBe(body.key)
})

it('revokes a key by id', async () => {
mockFindUnique.mockResolvedValue({ id: 'key-1', revokedAt: null })
mockUpdate.mockResolvedValue({ id: 'key-1', revokedAt: new Date() })
const app = await buildAdminApp()
const res = await app.inject({
method: 'DELETE',
url: '/admin/keys/key-1',
headers: { 'x-admin-token': 'admin-secret' },
})
expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ id: 'key-1', revoked: true })
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: 'key-1' },
data: { revokedAt: expect.any(Date) },
})
})

it('returns 404 revoking a non-existent key', async () => {
mockFindUnique.mockResolvedValue(null)
const app = await buildAdminApp()
const res = await app.inject({
method: 'DELETE',
url: '/admin/keys/missing',
headers: { 'x-admin-token': 'admin-secret' },
})
expect(res.statusCode).toBe(404)
})
})
Loading
Loading