Skip to content

Commit eca73d4

Browse files
committed
chore: limit solution to oauth opaque tokens
1 parent 2a1b3a8 commit eca73d4

6 files changed

Lines changed: 74 additions & 40 deletions

File tree

packages/backend/src/tokens/__tests__/machineTokenRateLimiter.test.ts renamed to packages/backend/src/tokens/__tests__/oauthTokenRateLimiter.test.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,79 @@
11
import { afterEach, describe, expect, it, vi } from 'vitest';
22

3-
import { checkMachineTokenRateLimit, resetMachineTokenRateLimiter } from '../machineTokenRateLimiter';
3+
import { checkOAuthTokenRateLimit, resetOAuthTokenRateLimiter } from '../oauthTokenRateLimiter';
44

55
afterEach(() => {
6-
resetMachineTokenRateLimiter();
6+
resetOAuthTokenRateLimiter();
77
vi.useRealTimers();
88
});
99

10-
describe('checkMachineTokenRateLimit', () => {
10+
describe('checkOAuthTokenRateLimit', () => {
1111
it('allows the first request from an IP', () => {
12-
expect(checkMachineTokenRateLimit('1.2.3.4')).toBe(true);
12+
expect(checkOAuthTokenRateLimit('1.2.3.4')).toBe(true);
1313
});
1414

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

2222
it('blocks requests that exceed MAX_BURST', () => {
2323
const ip = '10.0.0.2';
2424
for (let i = 0; i < 20; i++) {
25-
checkMachineTokenRateLimit(ip);
25+
checkOAuthTokenRateLimit(ip);
2626
}
27-
expect(checkMachineTokenRateLimit(ip)).toBe(false);
27+
expect(checkOAuthTokenRateLimit(ip)).toBe(false);
2828
});
2929

3030
it('allows requests again after tokens refill', () => {
3131
vi.useFakeTimers();
3232
const ip = '10.0.0.3';
3333
for (let i = 0; i < 20; i++) {
34-
checkMachineTokenRateLimit(ip);
34+
checkOAuthTokenRateLimit(ip);
3535
}
36-
expect(checkMachineTokenRateLimit(ip)).toBe(false);
36+
expect(checkOAuthTokenRateLimit(ip)).toBe(false);
3737

3838
// Advance 2 seconds: at 10 tokens/s, 20 new tokens should be available
3939
vi.advanceTimersByTime(2000);
40-
expect(checkMachineTokenRateLimit(ip)).toBe(true);
40+
expect(checkOAuthTokenRateLimit(ip)).toBe(true);
4141
});
4242

4343
it('tracks different IPs independently', () => {
4444
const ipA = '192.168.1.1';
4545
const ipB = '192.168.1.2';
4646
for (let i = 0; i < 20; i++) {
47-
checkMachineTokenRateLimit(ipA);
47+
checkOAuthTokenRateLimit(ipA);
4848
}
49-
expect(checkMachineTokenRateLimit(ipA)).toBe(false);
50-
expect(checkMachineTokenRateLimit(ipB)).toBe(true);
49+
expect(checkOAuthTokenRateLimit(ipA)).toBe(false);
50+
expect(checkOAuthTokenRateLimit(ipB)).toBe(true);
5151
});
5252

5353
it('treats the unknown sentinel as a single IP', () => {
5454
for (let i = 0; i < 20; i++) {
55-
checkMachineTokenRateLimit('unknown');
55+
checkOAuthTokenRateLimit('unknown');
5656
}
57-
expect(checkMachineTokenRateLimit('unknown')).toBe(false);
57+
expect(checkOAuthTokenRateLimit('unknown')).toBe(false);
5858
});
5959

6060
it('evicts the oldest bucket and allows a new IP when MAX_BUCKETS is reached', () => {
6161
// Fill up to MAX_BUCKETS (10 000) unique IPs
6262
for (let i = 0; i < 10_000; i++) {
63-
checkMachineTokenRateLimit(`10.${Math.floor(i / 65536)}.${Math.floor((i % 65536) / 256)}.${i % 256}`);
63+
checkOAuthTokenRateLimit(`10.${Math.floor(i / 65536)}.${Math.floor((i % 65536) / 256)}.${i % 256}`);
6464
}
6565
// The 10 001st IP triggers eviction of the oldest entry; the new IP gets a fresh bucket
6666
const freshIp = '172.16.0.1';
67-
expect(checkMachineTokenRateLimit(freshIp)).toBe(true);
67+
expect(checkOAuthTokenRateLimit(freshIp)).toBe(true);
6868
});
6969

70-
it('allows a previously blocked IP after resetMachineTokenRateLimiter', () => {
70+
it('allows a previously blocked IP after resetOAuthTokenRateLimiter', () => {
7171
const ip = '5.5.5.5';
7272
for (let i = 0; i < 21; i++) {
73-
checkMachineTokenRateLimit(ip);
73+
checkOAuthTokenRateLimit(ip);
7474
}
75-
expect(checkMachineTokenRateLimit(ip)).toBe(false);
76-
resetMachineTokenRateLimiter();
77-
expect(checkMachineTokenRateLimit(ip)).toBe(true);
75+
expect(checkOAuthTokenRateLimit(ip)).toBe(false);
76+
resetOAuthTokenRateLimiter();
77+
expect(checkOAuthTokenRateLimit(ip)).toBe(true);
7878
});
7979
});

packages/backend/src/tokens/__tests__/request.test.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { http, HttpResponse } from 'msw';
22
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
33

44
import { MachineTokenVerificationErrorCode, TokenVerificationErrorReason } from '../../errors';
5-
import { checkMachineTokenRateLimit, resetMachineTokenRateLimiter } from '../machineTokenRateLimiter';
5+
import { checkOAuthTokenRateLimit, resetOAuthTokenRateLimiter } from '../oauthTokenRateLimiter';
66
import {
77
mockExpiredJwt,
88
mockInvalidSignatureJwt,
@@ -1766,12 +1766,12 @@ describe('tokens.authenticateRequest(options)', () => {
17661766

17671767
describe('Rate limiting', () => {
17681768
afterEach(() => {
1769-
resetMachineTokenRateLimiter();
1769+
resetOAuthTokenRateLimiter();
17701770
});
17711771

17721772
const exhaustBucket = (ip: string) => {
17731773
for (let i = 0; i < 20; i++) {
1774-
checkMachineTokenRateLimit(ip);
1774+
checkOAuthTokenRateLimit(ip);
17751775
}
17761776
};
17771777

@@ -1789,7 +1789,7 @@ describe('tokens.authenticateRequest(options)', () => {
17891789
);
17901790
expect(rateLimited).toBeMachineUnauthenticated({
17911791
tokenType: 'oauth_token',
1792-
reason: AuthErrorReason.MachineTokenRateLimit,
1792+
reason: AuthErrorReason.OAuthTokenRateLimit,
17931793
message: '',
17941794
});
17951795
});
@@ -1813,7 +1813,7 @@ describe('tokens.authenticateRequest(options)', () => {
18131813
);
18141814
expect(rateLimited).toBeMachineUnauthenticated({
18151815
tokenType: 'oauth_token',
1816-
reason: AuthErrorReason.MachineTokenRateLimit,
1816+
reason: AuthErrorReason.OAuthTokenRateLimit,
18171817
message: '',
18181818
});
18191819
// x-forwarded-for IP is untouched: a request using only that header must be allowed
@@ -1841,7 +1841,7 @@ describe('tokens.authenticateRequest(options)', () => {
18411841
);
18421842
expect(rateLimited).toBeMachineUnauthenticated({
18431843
tokenType: 'oauth_token',
1844-
reason: AuthErrorReason.MachineTokenRateLimit,
1844+
reason: AuthErrorReason.OAuthTokenRateLimit,
18451845
message: '',
18461846
});
18471847
});
@@ -1863,7 +1863,7 @@ describe('tokens.authenticateRequest(options)', () => {
18631863
);
18641864
expect(rateLimited).toBeMachineUnauthenticated({
18651865
tokenType: 'oauth_token',
1866-
reason: AuthErrorReason.MachineTokenRateLimit,
1866+
reason: AuthErrorReason.OAuthTokenRateLimit,
18671867
message: '',
18681868
});
18691869
});
@@ -1899,7 +1899,7 @@ describe('tokens.authenticateRequest(options)', () => {
18991899
mockRequest({ authorization: `Bearer ${oauthJwt}`, 'cf-connecting-ip': ip }),
19001900
mockOptions({ acceptsToken: 'oauth_token' }),
19011901
);
1902-
expect(result.reason).not.toBe(AuthErrorReason.MachineTokenRateLimit);
1902+
expect(result.reason).not.toBe(AuthErrorReason.OAuthTokenRateLimit);
19031903
vi.useRealTimers();
19041904
});
19051905

@@ -1917,9 +1917,39 @@ describe('tokens.authenticateRequest(options)', () => {
19171917
mockRequest({ authorization: `Bearer ${m2mJwt}`, 'cf-connecting-ip': ip }),
19181918
mockOptions({ acceptsToken: 'm2m_token' }),
19191919
);
1920-
expect(result.reason).not.toBe(AuthErrorReason.MachineTokenRateLimit);
1920+
expect(result.reason).not.toBe(AuthErrorReason.OAuthTokenRateLimit);
19211921
vi.useRealTimers();
19221922
});
1923+
1924+
test('opaque api_key tokens bypass the rate limiter', async () => {
1925+
server.use(
1926+
http.post(mockMachineAuthResponses.api_key.endpoint, () =>
1927+
HttpResponse.json(mockVerificationResults.api_key),
1928+
),
1929+
);
1930+
const ip = '203.0.113.4';
1931+
exhaustBucket(ip);
1932+
const result = await authenticateRequest(
1933+
mockRequest({ authorization: `Bearer ${mockTokens.api_key}`, 'cf-connecting-ip': ip }),
1934+
mockOptions({ acceptsToken: 'api_key' }),
1935+
);
1936+
expect(result.reason).not.toBe(AuthErrorReason.OAuthTokenRateLimit);
1937+
});
1938+
1939+
test('opaque m2m tokens bypass the rate limiter', async () => {
1940+
server.use(
1941+
http.post(mockMachineAuthResponses.m2m_token.endpoint, () =>
1942+
HttpResponse.json(mockVerificationResults.m2m_token),
1943+
),
1944+
);
1945+
const ip = '203.0.113.5';
1946+
exhaustBucket(ip);
1947+
const result = await authenticateRequest(
1948+
mockRequest({ authorization: `Bearer ${mockTokens.m2m_token}`, 'cf-connecting-ip': ip }),
1949+
mockOptions({ acceptsToken: 'm2m_token' }),
1950+
);
1951+
expect(result.reason).not.toBe(AuthErrorReason.OAuthTokenRateLimit);
1952+
});
19231953
});
19241954

19251955
describe('Token Location Validation', () => {

packages/backend/src/tokens/authStatus.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export const AuthErrorReason = {
118118
SessionTokenWithoutClientUAT: 'session-token-but-no-client-uat',
119119
ActiveOrganizationMismatch: 'active-organization-mismatch',
120120
TokenTypeMismatch: 'token-type-mismatch',
121-
MachineTokenRateLimit: 'machine-token-rate-limit',
121+
OAuthTokenRateLimit: 'oauth-token-rate-limit',
122122
UnexpectedError: 'unexpected-error',
123123
} as const;
124124

packages/backend/src/tokens/machine.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ export function isMachineTokenByPrefix(token: string): boolean {
9090
return MACHINE_TOKEN_PREFIXES.some(prefix => token.startsWith(prefix));
9191
}
9292

93+
export function isOAuthTokenByPrefix(token: string): boolean {
94+
return token.startsWith(OAUTH_TOKEN_PREFIX);
95+
}
96+
9397
/**
9498
* Checks if a token is a machine token by looking at its prefix or if it's an OAuth/M2M JWT.
9599
*

packages/backend/src/tokens/machineTokenRateLimiter.ts renamed to packages/backend/src/tokens/oauthTokenRateLimiter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const MAX_BUCKETS = 10_000;
55
type Bucket = { tokens: number; lastRefill: number };
66
const buckets = new Map<string, Bucket>();
77

8-
export function checkMachineTokenRateLimit(ip: string): boolean {
8+
export function checkOAuthTokenRateLimit(ip: string): boolean {
99
if (buckets.size >= MAX_BUCKETS) {
1010
// Evict the oldest entry rather than clearing all buckets to prevent an attacker
1111
// from neutralizing rate limits by forcing key churn across many distinct IPs.
@@ -27,6 +27,6 @@ export function checkMachineTokenRateLimit(ip: string): boolean {
2727
return true;
2828
}
2929

30-
export function resetMachineTokenRateLimiter(): void {
30+
export function resetOAuthTokenRateLimiter(): void {
3131
buckets.clear();
3232
}

packages/backend/src/tokens/request.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ import {
1818
getMachineTokenType,
1919
isMachineJwt,
2020
isMachineToken,
21-
isMachineTokenByPrefix,
21+
isOAuthTokenByPrefix,
2222
isTokenTypeAccepted,
2323
} from './machine';
24-
import { checkMachineTokenRateLimit } from './machineTokenRateLimiter';
24+
import { checkOAuthTokenRateLimit } from './oauthTokenRateLimiter';
2525
import { OrganizationMatcher } from './organizationMatcher';
2626
import type { MachineTokenType, SessionTokenType } from './tokenTypes';
2727
import { TokenType } from './tokenTypes';
@@ -821,11 +821,11 @@ export const authenticateRequest: AuthenticateRequest = (async (
821821
return mismatchState;
822822
}
823823

824-
if (isMachineTokenByPrefix(tokenInHeader) && !checkMachineTokenRateLimit(extractCallerIp(request))) {
824+
if (isOAuthTokenByPrefix(tokenInHeader) && !checkOAuthTokenRateLimit(extractCallerIp(request))) {
825825
return signedOut({
826826
tokenType: parsedTokenType,
827827
authenticateContext,
828-
reason: AuthErrorReason.MachineTokenRateLimit,
828+
reason: AuthErrorReason.OAuthTokenRateLimit,
829829
message: '',
830830
});
831831
}
@@ -857,11 +857,11 @@ export const authenticateRequest: AuthenticateRequest = (async (
857857
return mismatchState;
858858
}
859859

860-
if (isMachineTokenByPrefix(tokenInHeader) && !checkMachineTokenRateLimit(extractCallerIp(request))) {
860+
if (isOAuthTokenByPrefix(tokenInHeader) && !checkOAuthTokenRateLimit(extractCallerIp(request))) {
861861
return signedOut({
862862
tokenType: parsedTokenType,
863863
authenticateContext,
864-
reason: AuthErrorReason.MachineTokenRateLimit,
864+
reason: AuthErrorReason.OAuthTokenRateLimit,
865865
message: '',
866866
});
867867
}

0 commit comments

Comments
 (0)