Files
triggerdotdev--trigger.dev/apps/webapp/test/authorizationRateLimitMiddleware.test.ts
T
Chris Arderne 088f68b373 feat(webapp): share rate limit bucket across additional API keys per environment (#4508)
## What

Rate-limit the API by **environment** rather than per API key.

Previously the limiter keyed its bucket on the hash of the full
`Authorization` header — one bucket per key. With additional environment
API keys (`tr_*_sk_*`), an environment can mint many keys and each got
its own full bucket, so more keys = higher effective rate limit. This
collapses all of an environment's keys onto a single shared
per-environment bucket, so the ceiling is exactly the configured limit
regardless of key mix.

## How

- `authorizationRateLimitMiddleware` now lets the override return `{
config?, identifier? }`. `identifier`, when present, is the rate limit
bucket key; otherwise it falls back to the hashed `Authorization` header
(unchanged legacy behavior, still used by `engineRateLimiter` and any
unauthenticated fallthrough).
- `apiRateLimiter`'s override resolves the environment id and uses it as
the identifier:
- **Additional keys** (`isAdditionalApiKey`) resolve via a new
`resolveAdditionalApiKeyRateLimitScope()` — a **scope-agnostic** keyHash
→ (environmentId, org limiter config) lookup. It is deliberately
permissive (restricted keys resolve too) because it's used **only for
bucketing, never as an auth decision** — request auth still goes through
the RBAC bearer controller, which enforces scopes. Revoked/expired keys
are excluded so they can't hold a bucket warm.
- **Root/legacy keys** reuse the environment already resolved by
`authenticateAuthorizationHeader` and key on `environment.id` too.
- The identifier is always the stable environment id, never the secret
key (which can rotate and would split the bucket).
- The whole override result is cached per key by the existing SWR cache,
so **no extra per-request lookup and no separate Redis mapping** is
added.

## Behavior notes

- Root + additional keys of the same environment now share one bucket
(ceiling = configured limit, not a multiple of it). Restricted
additional keys are included — they were the biggest gap, since they
authenticate via the RBAC controller and previously fell back to per-key
buckets.
- **Public JWTs** keep their existing fixed-window, per-token bucketing.
- One-time bucket reset on deploy (bucket keys change); harmless.

## Tests

- New: two tokens resolving to the same identifier share one bucket.
- New: with no identifier, bucketing stays per-key (legacy behavior
preserved).
- Updated existing override tests to the new `{ config }` return shape.

Base: `feat/multi-keys-surface`. Closes TRI-12888.
2026-08-06 16:05:27 +01:00

492 lines
16 KiB
TypeScript

import { redisTest } from "@internal/testcontainers";
import { describe, expect, vi, beforeEach } from "vitest";
vi.setConfig({ testTimeout: 30_000 }); // 30 seconds timeout
// Mock the logger
vi.mock("./logger.server", () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
import type { Express } from "express";
import express from "express";
import request from "supertest";
import { authorizationRateLimitMiddleware } from "../app/services/authorizationRateLimitMiddleware.server.js";
describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware", () => {
let app: Express;
beforeEach(() => {
app = express();
});
redisTest("should allow requests within the rate limit", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
pathMatchers: [/^\/api/],
log: {
rejections: false,
requests: false,
},
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
const response = await request(app).get("/api/test").set("Authorization", "Bearer test-token");
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: "Success" });
expect(response.headers["x-ratelimit-limit"]).toBeDefined();
expect(response.headers["x-ratelimit-remaining"]).toBeDefined();
expect(response.headers["x-ratelimit-reset"]).toBeDefined();
});
redisTest("should reject requests without an Authorization header", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
const response = await request(app).get("/api/test");
expect(response.status).toBe(401);
expect(response.body).toHaveProperty("title", "Unauthorized");
});
redisTest("should reject requests that exceed the rate limit", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
// First request should succeed
await request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Second request should be rate limited
const response = await request(app).get("/api/test").set("Authorization", "Bearer test-token");
expect(response.status).toBe(429);
expect(response.body).toHaveProperty("title", "Rate Limit Exceeded");
});
redisTest("should not apply rate limiting to whitelisted paths", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
pathMatchers: [/^\/api/],
pathWhiteList: ["/api/whitelist"],
});
app.use(rateLimitMiddleware);
app.get("/api/whitelist", (req, res) => {
res.status(200).json({ message: "Whitelisted" });
});
const response = await request(app)
.get("/api/whitelist")
.set("Authorization", "Bearer test-token");
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: "Whitelisted" });
expect(response.headers["x-ratelimit-limit"]).toBeUndefined();
});
redisTest(
"should apply different rate limits based on limiterConfigOverride",
async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
limiterConfigOverride: async (authorizationValue) => {
if (authorizationValue === "Bearer premium-token") {
return {
config: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
};
}
return undefined;
},
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
// Regular user should be rate limited after 1 request
await request(app).get("/api/test").set("Authorization", "Bearer regular-token");
const regularResponse = await request(app)
.get("/api/test")
.set("Authorization", "Bearer regular-token");
expect(regularResponse.status).toBe(429);
// Premium user should be able to make multiple requests
const premiumResponse1 = await request(app)
.get("/api/test")
.set("Authorization", "Bearer premium-token");
expect(premiumResponse1.status).toBe(200);
const premiumResponse2 = await request(app)
.get("/api/test")
.set("Authorization", "Bearer premium-token");
expect(premiumResponse2.status).toBe(200);
}
);
redisTest(
"should share a bucket across tokens that resolve to the same identifier",
async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test-identifier",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
// Both tokens map to the same environment identifier, so they should
// consume from a single shared bucket rather than one bucket each.
limiterConfigOverride: async () => ({ identifier: "env_shared" }),
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
// First token uses the single token in the shared bucket.
const first = await request(app)
.get("/api/test")
.set("Authorization", "Bearer tr_prod_sk_aaaaaaaaaaaaaaaaaaaaaaaa");
expect(first.status).toBe(200);
// A different token that resolves to the same identifier is limited,
// because the bucket is shared rather than per-key.
const second = await request(app)
.get("/api/test")
.set("Authorization", "Bearer tr_prod_sk_bbbbbbbbbbbbbbbbbbbbbbbb");
expect(second.status).toBe(429);
}
);
redisTest("should key per token when no identifier is supplied", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test-no-identifier",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
// Override supplies a config but no identifier: bucketing stays per-key
// (hashed Authorization header), the legacy behavior.
limiterConfigOverride: async () => ({
config: { type: "tokenBucket", refillRate: 1, interval: "1m", maxTokens: 1 },
}),
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const first = await request(app).get("/api/test").set("Authorization", "Bearer token-a");
expect(first.status).toBe(200);
// Same token is limited...
const firstAgain = await request(app).get("/api/test").set("Authorization", "Bearer token-a");
expect(firstAgain.status).toBe(429);
// ...but a different token gets its own bucket.
const second = await request(app).get("/api/test").set("Authorization", "Bearer token-b");
expect(second.status).toBe(200);
});
describe("Advanced Cases", () => {
// 1. Test different rate limit configurations
redisTest("should enforce fixed window rate limiting", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test-fixed",
defaultLimiter: {
type: "fixedWindow",
window: "10s",
tokens: 3,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Should allow 3 requests
for (let i = 0; i < 3; i++) {
const response = await makeRequest();
expect(response.status).toBe(200);
}
// 4th request should be rate limited
const limitedResponse = await makeRequest();
expect(limitedResponse.status).toBe(429);
// Wait for the window to reset
await new Promise((resolve) => setTimeout(resolve, 10000));
// Should allow requests again
const newResponse = await makeRequest();
expect(newResponse.status).toBe(200);
});
redisTest("should enforce sliding window rate limiting", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test-sliding",
defaultLimiter: {
type: "slidingWindow",
window: "10s",
tokens: 3,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Should allow 3 requests
for (let i = 0; i < 3; i++) {
const response = await makeRequest();
expect(response.status).toBe(200);
}
// 4th request should be rate limited
const limitedResponse = await makeRequest();
expect(limitedResponse.status).toBe(429);
// Wait for part of the window to pass
await new Promise((resolve) => setTimeout(resolve, 1000));
// Should still be limited
const stillLimitedResponse = await makeRequest();
expect(stillLimitedResponse.status).toBe(429);
// Wait for the full window to pass
await new Promise((resolve) => setTimeout(resolve, 10000));
// Should allow requests again
const newResponse = await makeRequest();
expect(newResponse.status).toBe(200);
});
// 2. Test edge cases around rate limit calculations
redisTest("should handle token refill correctly", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test-refill",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "5s",
maxTokens: 3,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Use up all tokens
for (let i = 0; i < 3; i++) {
const response = await makeRequest();
expect(response.status).toBe(200);
}
// Next request should be limited
const limitedResponse = await makeRequest();
expect(limitedResponse.status).toBe(429);
// Wait for one token to be refilled
await new Promise((resolve) => setTimeout(resolve, 5000));
// Should allow one request
const newResponse = await makeRequest();
expect(newResponse.status).toBe(200);
// But the next one should be limited again
const limitedAgainResponse = await makeRequest();
expect(limitedAgainResponse.status).toBe(429);
});
redisTest("should handle near-zero remaining tokens correctly", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test-near-zero",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1, // 1 token every 5 seconds
interval: "5s",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// First request should succeed
const firstResponse = await makeRequest();
expect(firstResponse.status).toBe(200);
// Immediate second request should fail
const secondResponse = await makeRequest();
expect(secondResponse.status).toBe(429);
// Wait for almost one token to be refilled (4.9 seconds)
await new Promise((resolve) => setTimeout(resolve, 4900));
// This request should still fail as we're just shy of a full token
const thirdResponse = await makeRequest();
expect(thirdResponse.status).toBe(429);
// Wait for the full token to be refilled (additional 200ms)
await new Promise((resolve) => setTimeout(resolve, 200));
// This request should now succeed
const fourthResponse = await makeRequest();
expect(fourthResponse.status).toBe(200);
// Immediate next request should fail again
const fifthResponse = await makeRequest();
expect(fifthResponse.status).toBe(429);
});
// 3. Test the limiterCache functionality
redisTest("should use cached limiter configurations", async ({ redisOptions }) => {
let configOverrideCalls = 0;
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: { ...redisOptions, tlsDisabled: true },
keyPrefix: "test-cache",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 10,
},
pathMatchers: [/^\/api/],
limiterCache: {
fresh: 1000, // 1 second
stale: 2000, // 2 seconds
maxItems: 1000,
},
limiterConfigOverride: async (authorizationValue) => {
configOverrideCalls++;
if (authorizationValue === "Bearer premium-token") {
return {
config: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
};
}
return undefined;
},
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer premium-token");
// First request should call the override
await makeRequest();
expect(configOverrideCalls).toBe(1);
// Subsequent requests within 1 second should use the cache
await makeRequest();
await makeRequest();
expect(configOverrideCalls).toBe(1);
// Wait for the cache to become stale
await new Promise((resolve) => setTimeout(resolve, 1100));
// This should still use the cache, but also trigger a refresh
await makeRequest();
expect(configOverrideCalls).toBe(2);
// Wait for the cache to expire completely
await new Promise((resolve) => setTimeout(resolve, 1000));
// This should trigger a new override call
await makeRequest();
expect(configOverrideCalls).toBe(3);
});
});
});