Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
96 changes: 96 additions & 0 deletions integration/testUtils/machineAuthHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ export type MachineAuthTestAdapter = {
callbackPath: string;
addRoutes: RouteBuilder;
};
rateLimit?: {
path: string;
addRoutes: RouteBuilder;
};
};

const createApiKeysEnv = (): EnvironmentConfig => appConfigs.envs.withAPIKeys.clone();
Expand Down Expand Up @@ -445,3 +449,95 @@ export const registerOAuthAuthTests = (adapter: MachineAuthTestAdapter): void =>
}
});
};

export const registerRateLimitTests = (adapter: MachineAuthTestAdapter): void => {
if (!adapter.rateLimit) {
return;
}

test.describe('Machine token rate limiting', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
let fakeUser: FakeUser;
let fakeBapiUser: User;
let fakeAPIKey: FakeAPIKey;

test.beforeAll(async () => {
test.setTimeout(120_000);

app = await buildApp(adapter, adapter.rateLimit!.addRoutes);
await app.setup();
await app.withEnv(createApiKeysEnv());
await app.dev();

const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser();
fakeBapiUser = await u.services.users.createBapiUser(fakeUser);
fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id);
});

test.afterAll(async () => {
await fakeAPIKey?.revoke();
await fakeUser?.deleteIfExists();
await app?.teardown();
});

test('rate-limits opaque machine tokens after burst exhaustion', async ({ request }) => {
const url = new URL(adapter.rateLimit!.path, app.serverUrl).toString();
// Use a dedicated test IP so this test's bucket is isolated from others
const testIp = '203.0.113.42';

for (let i = 0; i < 20; i++) {
await request.get(url, {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
'x-forwarded-for': testIp,
},
});
}

const res = await request.get(url, {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
'x-forwarded-for': testIp,
},
});
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.reason).toBe('machine-token-rate-limit');
});

test('tracks different source IPs independently', async ({ request }) => {
const url = new URL(adapter.rateLimit!.path, app.serverUrl).toString();
const ipA = '203.0.113.1';
const ipB = '203.0.113.2';

for (let i = 0; i < 20; i++) {
await request.get(url, {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
'x-forwarded-for': ipA,
},
});
}

const resA = await request.get(url, {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
'x-forwarded-for': ipA,
},
});
expect(resA.status()).toBe(401);
const bodyA = await resA.json();
expect(bodyA.reason).toBe('machine-token-rate-limit');

const resB = await request.get(url, {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
'x-forwarded-for': ipB,
},
});
expect(resB.status()).toBe(200);
});
});
};
29 changes: 28 additions & 1 deletion integration/tests/next-machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { MachineAuthTestAdapter } from '../testUtils/machineAuthHelpers';
import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../testUtils/machineAuthHelpers';
import {
registerApiKeyAuthTests,
registerM2MAuthTests,
registerOAuthAuthTests,
registerRateLimitTests,
} from '../testUtils/machineAuthHelpers';

const adapter: MachineAuthTestAdapter = {
baseConfig: appConfigs.next.appRouter,
Expand Down Expand Up @@ -88,10 +93,32 @@ const adapter: MachineAuthTestAdapter = {
`,
),
},
rateLimit: {
path: '/api/rate-limit-test',
addRoutes: config =>
config.addFile(
'src/app/api/rate-limit-test/route.ts',
() => `
import { auth } from '@clerk/nextjs/server';

export async function GET(request: Request) {
const { userId, tokenType } = await auth({ acceptsToken: 'api_key' });

if (!userId) {
const reason = request.headers.get('x-clerk-auth-reason');
return Response.json({ error: 'Unauthorized', reason }, { status: 401 });
}

return Response.json({ userId, tokenType });
}
`,
),
},
};

test.describe('Next.js machine authentication @machine', () => {
registerApiKeyAuthTests(adapter);
registerM2MAuthTests(adapter);
registerOAuthAuthTests(adapter);
registerRateLimitTests(adapter);
});
3 changes: 3 additions & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,13 @@ const Headers = {
ContentSecurityPolicy: 'content-security-policy',
ContentSecurityPolicyReportOnly: 'content-security-policy-report-only',
EnableDebug: 'x-clerk-debug',
CfConnectingIp: 'cf-connecting-ip',
ForwardedFor: 'x-forwarded-for',
ForwardedHost: 'x-forwarded-host',
ForwardedPort: 'x-forwarded-port',
ForwardedProto: 'x-forwarded-proto',
Host: 'host',
RealIp: 'x-real-ip',
Location: 'location',
Nonce: 'x-nonce',
Origin: 'origin',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { checkMachineTokenRateLimit, resetMachineTokenRateLimiter } from '../machineTokenRateLimiter';

afterEach(() => {
resetMachineTokenRateLimiter();
vi.useRealTimers();
});

describe('checkMachineTokenRateLimit', () => {
it('allows the first request from an IP', () => {
expect(checkMachineTokenRateLimit('1.2.3.4')).toBe(true);
});

it('allows up to MAX_BURST requests in a burst', () => {
const ip = '10.0.0.1';
for (let i = 0; i < 20; i++) {
expect(checkMachineTokenRateLimit(ip), `request ${i + 1} should be allowed`).toBe(true);
}
});

it('blocks requests that exceed MAX_BURST', () => {
const ip = '10.0.0.2';
for (let i = 0; i < 20; i++) {
checkMachineTokenRateLimit(ip);
}
expect(checkMachineTokenRateLimit(ip)).toBe(false);
});

it('allows requests again after tokens refill', () => {
vi.useFakeTimers();
const ip = '10.0.0.3';
for (let i = 0; i < 20; i++) {
checkMachineTokenRateLimit(ip);
}
expect(checkMachineTokenRateLimit(ip)).toBe(false);

// Advance 2 seconds: at 10 tokens/s, 20 new tokens should be available
vi.advanceTimersByTime(2000);
expect(checkMachineTokenRateLimit(ip)).toBe(true);
});

it('tracks different IPs independently', () => {
const ipA = '192.168.1.1';
const ipB = '192.168.1.2';
for (let i = 0; i < 20; i++) {
checkMachineTokenRateLimit(ipA);
}
expect(checkMachineTokenRateLimit(ipA)).toBe(false);
expect(checkMachineTokenRateLimit(ipB)).toBe(true);
});

it('treats the unknown sentinel as a single IP', () => {
for (let i = 0; i < 20; i++) {
checkMachineTokenRateLimit('unknown');
}
expect(checkMachineTokenRateLimit('unknown')).toBe(false);
});

it('clears all buckets and allows requests again when MAX_BUCKETS is reached', () => {
// Fill up to MAX_BUCKETS (10 000) unique IPs
for (let i = 0; i < 10_000; i++) {
checkMachineTokenRateLimit(`10.${Math.floor(i / 65536)}.${Math.floor((i % 65536) / 256)}.${i % 256}`);
}
// The 10 001st IP triggers the clear; the new IP gets a fresh bucket
const freshIp = '172.16.0.1';
expect(checkMachineTokenRateLimit(freshIp)).toBe(true);
});

it('allows a previously blocked IP after resetMachineTokenRateLimiter', () => {
const ip = '5.5.5.5';
for (let i = 0; i < 21; i++) {
checkMachineTokenRateLimit(ip);
}
expect(checkMachineTokenRateLimit(ip)).toBe(false);
resetMachineTokenRateLimiter();
expect(checkMachineTokenRateLimit(ip)).toBe(true);
});
});
Loading
Loading