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
5 changes: 5 additions & 0 deletions .changeset/enforce-rate-per-day-175.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'lens': patch
---

Enforce ratePerDay daily quota on authenticated API keys (#175)
77 changes: 77 additions & 0 deletions src/__tests__/dailyRateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
checkAndIncrementDailyUsage,
resetDailyUsage,
getSecondsUntilUtcMidnight,
} from '../api/auth'
import { redis } from '../redis'

vi.mock('../db', () => ({
prisma: {
apiKey: { findUnique: vi.fn() },
},
pgPool: { query: vi.fn() },
}))

describe('Daily API Key Quota Enforcement (#175)', () => {
beforeEach(() => {
resetDailyUsage()
})

it('calculates numeric seconds until UTC midnight correctly', () => {
const seconds = getSecondsUntilUtcMidnight()
expect(typeof seconds).toBe('number')
expect(seconds).toBeGreaterThan(0)
expect(seconds).toBeLessThanOrEqual(86400)
})

it('allows requests within daily quota', async () => {
const res1 = await checkAndIncrementDailyUsage('key-1', 5)
expect(res1.allowed).toBe(true)
expect(res1.remaining).toBe(4)

const res2 = await checkAndIncrementDailyUsage('key-1', 5)
expect(res2.allowed).toBe(true)
expect(res2.remaining).toBe(3)
})

it('rejects requests once daily quota is exhausted', async () => {
for (let i = 0; i < 3; i++) {
const res = await checkAndIncrementDailyUsage('key-limit', 3)
expect(res.allowed).toBe(true)
}

const fourth = await checkAndIncrementDailyUsage('key-limit', 3)
expect(fourth.allowed).toBe(false)
expect(fourth.remaining).toBe(0)
})

it('maintains independent quotas per API key', async () => {
await checkAndIncrementDailyUsage('key-a', 2)
await checkAndIncrementDailyUsage('key-a', 2)
const thirdA = await checkAndIncrementDailyUsage('key-a', 2)
expect(thirdA.allowed).toBe(false)

// key-b has separate quota
const firstB = await checkAndIncrementDailyUsage('key-b', 5)
expect(firstB.allowed).toBe(true)
})

it('fails open when Redis encounters an error rather than failing closed', async () => {
// If Redis is unreachable or throws, it allows the request
const originalIncr = redis.incr
try {
// Force Redis error
// @ts-expect-error test mock
redis.status = 'ready'
redis.incr = vi.fn().mockRejectedValueOnce(new Error('Connection lost'))

const res = await checkAndIncrementDailyUsage('key-failopen', 10)
expect(res.allowed).toBe(true)
} finally {
redis.incr = originalIncr
// @ts-expect-error test restore
redis.status = 'wait'
}
})
})
86 changes: 86 additions & 0 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { redis } from '../redis'
import { createLogger } from '../logger'
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { createHash, timingSafeEqual } from 'crypto'
import fp from 'fastify-plugin'
import { prisma } from '../db'
import { api_key_auth_failures_total } from '../metrics'

const log = createLogger('auth')

type ApiKeyAuthFailureReason = 'missing' | 'malformed' | 'unknown' | 'revoked' | 'expired' | 'insufficient_scope'

/**
Expand Down Expand Up @@ -108,6 +112,71 @@ export function isAdminAuthorized(req: FastifyRequest): boolean {
return a.length === b.length && timingSafeEqual(a, b)
}

/**
* Seconds remaining until UTC midnight.
* Used for both Redis TTL and the HTTP Retry-After response header.
*/
export function getSecondsUntilUtcMidnight(now = new Date()): number {
const tomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
return Math.max(1, Math.floor((tomorrow.getTime() - now.getTime()) / 1000))
}

// In-memory fallback daily request tracker (keyed by apiKeyId), used if Redis is unreachable or in tests
const inMemoryDailyUsage = new Map<string, { count: number; date: string }>()

/**
* Checks and increments the daily request count for an authenticated API key.
*
* Uses Redis (ratelimit:day:{apiKeyId}:{YYYY-MM-DD}) with an expiration set to
* the remaining seconds until UTC midnight.
*
* On Redis being unreachable, allow the request and log it — do not fail closed.
* Unlike x402 metering which guards an actual payment and must fail closed,
* a rate limiter failing closed would turn a cache/Redis outage into a total API
* outage for every authenticated caller, which is a far worse trade.
*/
export async function checkAndIncrementDailyUsage(
apiKeyId: string,
ratePerDay: number,
): Promise<{ allowed: boolean; remaining: number }> {
const now = new Date()
const today = now.toISOString().slice(0, 10)
const redisKey = `ratelimit:day:${apiKeyId}:${today}`

try {
if (redis && redis.status === 'ready') {
const count = await redis.incr(redisKey)
if (count === 1) {
const ttl = getSecondsUntilUtcMidnight(now)
await redis.expire(redisKey, ttl)
}
if (count > ratePerDay) {
return { allowed: false, remaining: 0 }
}
return { allowed: true, remaining: Math.max(0, ratePerDay - count) }
}
} catch (err) {
// Fail-open: allow request and log warning
log.warn({ err, apiKeyId }, 'redis rate limiter unreachable; failing open')
}

// In-memory fallback when Redis is offline or during testing
const entry = inMemoryDailyUsage.get(apiKeyId)
if (!entry || entry.date !== today) {
inMemoryDailyUsage.set(apiKeyId, { count: 1, date: today })
return { allowed: true, remaining: Math.max(0, ratePerDay - 1) }
}
if (entry.count >= ratePerDay) {
return { allowed: false, remaining: 0 }
}
entry.count++
return { allowed: true, remaining: Math.max(0, ratePerDay - entry.count) }
}

export function resetDailyUsage(): void {
inMemoryDailyUsage.clear()
}

/**
* Fastify plugin that authenticates requests via `Authorization: Bearer <key>`.
*
Expand Down Expand Up @@ -194,6 +263,23 @@ async function apiKeyAuthPlugin(app: FastifyInstance) {
})
}
}

// The per-minute limiter cannot express a daily cap, so this runs alongside it
if (keyContext.ratePerDay && keyContext.ratePerDay > 0) {
const { allowed, remaining } = await checkAndIncrementDailyUsage(keyContext.id, keyContext.ratePerDay)
const retryAfterSec = getSecondsUntilUtcMidnight()
reply.header('X-RateLimit-Limit-Day', keyContext.ratePerDay)
reply.header('X-RateLimit-Remaining-Day', remaining)
if (!allowed) {
reply.header('Retry-After', retryAfterSec)
return reply.status(429).send({
statusCode: 429,
error: 'Too Many Requests',
message: `Daily rate limit of ${keyContext.ratePerDay} requests exceeded for this API key. Quota resets at UTC midnight.`,
retryAfter: retryAfterSec,
})
}
}
})
}

Expand Down
Loading