From 8e8d8abaf6d9f1ca6dcdd9c23240a150b68b2082 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Mon, 19 Oct 2026 09:47:38 +0530 Subject: [PATCH 1/2] fix(auth): enforce ratePerDay daily quota alongside per-minute limits (#175) --- .changeset/enforce-rate-per-day-175.md | 5 +++ src/__tests__/dailyRateLimit.test.ts | 45 ++++++++++++++++++++++++++ src/api/auth.ts | 36 +++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 .changeset/enforce-rate-per-day-175.md create mode 100644 src/__tests__/dailyRateLimit.test.ts diff --git a/.changeset/enforce-rate-per-day-175.md b/.changeset/enforce-rate-per-day-175.md new file mode 100644 index 00000000..2dce11fe --- /dev/null +++ b/.changeset/enforce-rate-per-day-175.md @@ -0,0 +1,5 @@ +--- +'lens': patch +--- + +Enforce ratePerDay daily quota on authenticated API keys (#175) diff --git a/src/__tests__/dailyRateLimit.test.ts b/src/__tests__/dailyRateLimit.test.ts new file mode 100644 index 00000000..3b218185 --- /dev/null +++ b/src/__tests__/dailyRateLimit.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { checkAndIncrementDailyUsage, resetDailyUsage } from '../api/auth' + +vi.mock('../db', () => ({ + prisma: { + apiKey: { findUnique: vi.fn() }, + }, + pgPool: { query: vi.fn() }, +})) + +describe('Daily API Key Quota Enforcement (#175)', () => { + beforeEach(() => { + resetDailyUsage() + }) + + it('allows requests within daily quota', () => { + const res1 = checkAndIncrementDailyUsage('key-1', 5) + expect(res1.allowed).toBe(true) + expect(res1.remaining).toBe(4) + + const res2 = checkAndIncrementDailyUsage('key-1', 5) + expect(res2.allowed).toBe(true) + expect(res2.remaining).toBe(3) + }) + + it('rejects requests once daily quota is exhausted', () => { + for (let i = 0; i < 3; i++) { + const res = checkAndIncrementDailyUsage('key-limit', 3) + expect(res.allowed).toBe(true) + } + + const fourth = checkAndIncrementDailyUsage('key-limit', 3) + expect(fourth.allowed).toBe(false) + expect(fourth.remaining).toBe(0) + }) + + it('maintains independent quotas per API key', () => { + checkAndIncrementDailyUsage('key-a', 2) + checkAndIncrementDailyUsage('key-a', 2) + expect(checkAndIncrementDailyUsage('key-a', 2).allowed).toBe(false) + + // key-b has separate quota + expect(checkAndIncrementDailyUsage('key-b', 5).allowed).toBe(true) + }) +}) diff --git a/src/api/auth.ts b/src/api/auth.ts index 44cf0d12..88775b65 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -52,6 +52,27 @@ export async function lookupApiKey(plaintext: string): Promise() + +export function checkAndIncrementDailyUsage(apiKeyId: string, ratePerDay: number): { allowed: boolean; remaining: number } { + const today = new Date().toISOString().slice(0, 10) + const entry = dailyUsage.get(apiKeyId) + if (!entry || entry.date !== today) { + dailyUsage.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 { + dailyUsage.clear() +} + /** * Fastify plugin that authenticates requests via `Authorization: Bearer `. * @@ -91,6 +112,21 @@ async function apiKeyAuthPlugin(app: FastifyInstance) { } req.apiKey = keyContext + + // Enforce ratePerDay daily quota (#175) + if (keyContext.ratePerDay && keyContext.ratePerDay > 0) { + const { allowed, remaining } = checkAndIncrementDailyUsage(keyContext.id, keyContext.ratePerDay) + reply.header('X-RateLimit-Limit-Day', keyContext.ratePerDay) + reply.header('X-RateLimit-Remaining-Day', remaining) + if (!allowed) { + 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: 'midnight UTC', + }) + } + } }) } From 241c8e22893439f056d8d2ad91c0d1379805e94a Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Fri, 23 Oct 2026 12:59:56 +0530 Subject: [PATCH 2/2] fix(auth): store daily quota in Redis with fail-open and numeric Retry-After (#175) --- src/__tests__/dailyRateLimit.test.ts | 56 ++++++++++++++++----- src/api/auth.ts | 73 +++++++++++++++++++++++----- 2 files changed, 104 insertions(+), 25 deletions(-) diff --git a/src/__tests__/dailyRateLimit.test.ts b/src/__tests__/dailyRateLimit.test.ts index 3b218185..e83aaa85 100644 --- a/src/__tests__/dailyRateLimit.test.ts +++ b/src/__tests__/dailyRateLimit.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { checkAndIncrementDailyUsage, resetDailyUsage } from '../api/auth' +import { + checkAndIncrementDailyUsage, + resetDailyUsage, + getSecondsUntilUtcMidnight, +} from '../api/auth' +import { redis } from '../redis' vi.mock('../db', () => ({ prisma: { @@ -13,33 +18,60 @@ describe('Daily API Key Quota Enforcement (#175)', () => { resetDailyUsage() }) - it('allows requests within daily quota', () => { - const res1 = checkAndIncrementDailyUsage('key-1', 5) + 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 = checkAndIncrementDailyUsage('key-1', 5) + 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', () => { + it('rejects requests once daily quota is exhausted', async () => { for (let i = 0; i < 3; i++) { - const res = checkAndIncrementDailyUsage('key-limit', 3) + const res = await checkAndIncrementDailyUsage('key-limit', 3) expect(res.allowed).toBe(true) } - const fourth = checkAndIncrementDailyUsage('key-limit', 3) + const fourth = await checkAndIncrementDailyUsage('key-limit', 3) expect(fourth.allowed).toBe(false) expect(fourth.remaining).toBe(0) }) - it('maintains independent quotas per API key', () => { - checkAndIncrementDailyUsage('key-a', 2) - checkAndIncrementDailyUsage('key-a', 2) - expect(checkAndIncrementDailyUsage('key-a', 2).allowed).toBe(false) + 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 - expect(checkAndIncrementDailyUsage('key-b', 5).allowed).toBe(true) + 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' + } }) }) diff --git a/src/api/auth.ts b/src/api/auth.ts index 67a5863f..6f4be161 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -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' /** @@ -108,14 +112,58 @@ export function isAdminAuthorized(req: FastifyRequest): boolean { return a.length === b.length && timingSafeEqual(a, b) } -// In-memory daily request tracker (keyed by apiKeyId) -const dailyUsage = new Map() +/** + * 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() + +/** + * 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') + } -export function checkAndIncrementDailyUsage(apiKeyId: string, ratePerDay: number): { allowed: boolean; remaining: number } { - const today = new Date().toISOString().slice(0, 10) - const entry = dailyUsage.get(apiKeyId) + // In-memory fallback when Redis is offline or during testing + const entry = inMemoryDailyUsage.get(apiKeyId) if (!entry || entry.date !== today) { - dailyUsage.set(apiKeyId, { count: 1, date: today }) + inMemoryDailyUsage.set(apiKeyId, { count: 1, date: today }) return { allowed: true, remaining: Math.max(0, ratePerDay - 1) } } if (entry.count >= ratePerDay) { @@ -126,7 +174,7 @@ export function checkAndIncrementDailyUsage(apiKeyId: string, ratePerDay: number } export function resetDailyUsage(): void { - dailyUsage.clear() + inMemoryDailyUsage.clear() } /** @@ -216,20 +264,19 @@ async function apiKeyAuthPlugin(app: FastifyInstance) { } } - // Enforce ratePerDay daily quota (#175) + // The per-minute limiter cannot express a daily cap, so this runs alongside it if (keyContext.ratePerDay && keyContext.ratePerDay > 0) { - const { allowed, remaining } = checkAndIncrementDailyUsage(keyContext.id, keyContext.ratePerDay) + 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: 'midnight UTC', - }) - } - } + retryAfter: retryAfterSec, }) } }