Skip to content
Draft
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
42 changes: 42 additions & 0 deletions integration/testUtils/machineAuthHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,48 @@ export const registerOAuthAuthTests = (adapter: MachineAuthTestAdapter): void =>
expect(res.status()).toBe(401);
});

test('consistently rejects the same invalid oat_ token across repeated requests', async ({ request }) => {
// After the first rejection the token is cached as invalid in-process.
// Subsequent requests with the same token must still return 401 (not be
// accidentally accepted due to cache state).
const url = new URL(adapter.oauth.verifyPath, app.serverUrl).toString();
const invalidToken = `oat_integration_test_invalid_${Date.now()}`;

for (let i = 0; i < 3; i++) {
const res = await request.get(url, { headers: { Authorization: `Bearer ${invalidToken}` } });
expect(res.status()).toBe(401);
}
});

test('valid OAuth token is accepted after invalid tokens have been cached', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const url = new URL(adapter.oauth.verifyPath, app.serverUrl).toString();

// Seed the negative cache with a few invalid tokens
for (let i = 0; i < 3; i++) {
await u.page.request.get(url, {
headers: { Authorization: `Bearer oat_integration_cache_seed_${i}` },
});
}

// A legitimate token obtained through the real OAuth flow must still work
const accessToken = await obtainOAuthAccessToken({
page: u.page,
oAuthApp: fakeOAuth.oAuthApp,
redirectUri: new URL(adapter.oauth.callbackPath, app.serverUrl).toString(),
fakeUser,
signIn: u.po.signIn,
});

const res = await u.page.request.get(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.status()).toBe(200);
const authData = await res.json();
expect(authData.userId).toBeDefined();
expect(authData.tokenType).toBe(TokenType.OAuthToken);
});

for (const [tokenType, token] of [
['API key', 'ak_test_mismatch'],
['M2M', 'mt_test_mismatch'],
Expand Down
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
147 changes: 147 additions & 0 deletions packages/backend/src/tokens/__tests__/oauthNegativeCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors';
import {
isOAuthTokenCachedAsInvalid,
makeCachedInvalidOAuthTokenError,
maybeCacheOAuthTokenAsInvalid,
resetOAuthNegativeCache,
} from '../oauthNegativeCache';

const TOKEN = 'oat_abc123';
const ANOTHER_TOKEN = 'oat_xyz789';

function makeTokenInvalidError() {
return new MachineTokenVerificationError({
message: 'OAuth token not found',
code: MachineTokenVerificationErrorCode.TokenInvalid,
status: 404,
});
}

function makeOtherError() {
return new MachineTokenVerificationError({
message: 'Invalid secret key',
code: MachineTokenVerificationErrorCode.InvalidSecretKey,
status: 401,
});
}

describe('oauthNegativeCache', () => {
beforeEach(() => {
resetOAuthNegativeCache();
vi.useFakeTimers();
});

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

describe('isOAuthTokenCachedAsInvalid', () => {
it('returns false for a token that has never been cached', () => {
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
});

it('returns true for a token cached as invalid', () => {
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);
});

it('returns false for a different token not in the cache', () => {
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
expect(isOAuthTokenCachedAsInvalid(ANOTHER_TOKEN)).toBe(false);
});

it('returns false and evicts the entry after TTL expires', () => {
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);

vi.advanceTimersByTime(30_001);

expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
});

it('returns true just before TTL expires', () => {
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
vi.advanceTimersByTime(29_999);
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);
});
});

describe('maybeCacheOAuthTokenAsInvalid', () => {
it('caches when error is TokenInvalid', () => {
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);
});

it('does not cache when error is a different MachineTokenVerificationError code', () => {
maybeCacheOAuthTokenAsInvalid(makeOtherError(), TOKEN);
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
});

it('does not cache when error is not a MachineTokenVerificationError', () => {
maybeCacheOAuthTokenAsInvalid(new Error('network failure'), TOKEN);
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
});

it('does not cache when error is null', () => {
maybeCacheOAuthTokenAsInvalid(null, TOKEN);
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
});

it('updates the expiry when caching the same token again', () => {
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);

vi.advanceTimersByTime(20_000);
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);

vi.advanceTimersByTime(20_000);
// 40s total since first cache, but only 20s since re-cache; should still be valid
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true);

vi.advanceTimersByTime(10_001);
// 30s since re-cache; should now expire
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
});
});

describe('makeCachedInvalidOAuthTokenError', () => {
it('returns a MachineTokenVerificationError with TokenInvalid code', () => {
const err = makeCachedInvalidOAuthTokenError();
expect(err).toBeInstanceOf(MachineTokenVerificationError);
expect(err.code).toBe(MachineTokenVerificationErrorCode.TokenInvalid);
});

it('returns an error with status 404', () => {
const err = makeCachedInvalidOAuthTokenError();
expect(err.status).toBe(404);
});
});

describe('resetOAuthNegativeCache', () => {
it('clears all cached entries', () => {
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN);
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), ANOTHER_TOKEN);
resetOAuthNegativeCache();
expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false);
expect(isOAuthTokenCachedAsInvalid(ANOTHER_TOKEN)).toBe(false);
});
});

describe('capacity eviction', () => {
it('evicts the oldest entry when the cache reaches MAX_ENTRIES (10,000)', () => {
// Fill the cache to max capacity
for (let i = 0; i < 10_000; i++) {
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), `oat_token_${i}`);
}

expect(isOAuthTokenCachedAsInvalid('oat_token_0')).toBe(true);

// Adding one more should evict oat_token_0 (the oldest)
maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), 'oat_overflow');

expect(isOAuthTokenCachedAsInvalid('oat_token_0')).toBe(false);
expect(isOAuthTokenCachedAsInvalid('oat_overflow')).toBe(true);
});
});
});
104 changes: 104 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';

import { MachineTokenVerificationErrorCode, TokenVerificationErrorReason } from '../../errors';
import { isOAuthTokenCachedAsInvalid, resetOAuthNegativeCache } from '../oauthNegativeCache';
import {
mockExpiredJwt,
mockInvalidSignatureJwt,
Expand Down Expand Up @@ -1452,6 +1453,7 @@ describe('tokens.authenticateRequest(options)', () => {
describe('Machine authentication', () => {
afterEach(() => {
vi.clearAllMocks();
resetOAuthNegativeCache();
});

// Test each token type with parameterized tests
Expand Down Expand Up @@ -1762,6 +1764,108 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

describe('OAuth negative cache', () => {
afterEach(() => {
resetOAuthNegativeCache();
});

test('rejects a previously invalid oat_ token from cache without calling BAPI again', async () => {
server.use(
http.post(mockMachineAuthResponses.oauth_token.endpoint, () => HttpResponse.json({}, { status: 404 })),
);
const token = 'oat_invalid_garbage_token';
await authenticateRequest(
mockRequest({ authorization: `Bearer ${token}` }),
mockOptions({ acceptsToken: 'oauth_token' }),
);

// BAPI now returns 200, but the token should still be rejected from cache
server.use(
http.post(mockMachineAuthResponses.oauth_token.endpoint, () =>
HttpResponse.json(mockVerificationResults.oauth_token),
),
);
const second = await authenticateRequest(
mockRequest({ authorization: `Bearer ${token}` }),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(second).toBeMachineUnauthenticated({
tokenType: 'oauth_token',
reason: MachineTokenVerificationErrorCode.TokenInvalid,
message: 'OAuth token not found (code=token-invalid, status=404)',
});
});

test('does not cache valid oat_ tokens', async () => {
server.use(
http.post(mockMachineAuthResponses.oauth_token.endpoint, () =>
HttpResponse.json(mockVerificationResults.oauth_token),
),
);
await authenticateRequest(
mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}` }),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(isOAuthTokenCachedAsInvalid(mockTokens.oauth_token)).toBe(false);
});

test('does not cache oat_ tokens that fail with non-TokenInvalid errors', async () => {
// 401 maps to InvalidSecretKey, not TokenInvalid
server.use(
http.post(mockMachineAuthResponses.oauth_token.endpoint, () => HttpResponse.json({}, { status: 401 })),
);
const token = 'oat_secret_key_error_token';
await authenticateRequest(
mockRequest({ authorization: `Bearer ${token}` }),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(isOAuthTokenCachedAsInvalid(token)).toBe(false);
});

test('re-verifies token after cache TTL expires', async () => {
vi.useFakeTimers();
server.use(
http.post(mockMachineAuthResponses.oauth_token.endpoint, () => HttpResponse.json({}, { status: 404 })),
);
const token = 'oat_will_expire_from_cache';
await authenticateRequest(
mockRequest({ authorization: `Bearer ${token}` }),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(isOAuthTokenCachedAsInvalid(token)).toBe(true);

vi.advanceTimersByTime(30_001);
expect(isOAuthTokenCachedAsInvalid(token)).toBe(false);
vi.useRealTimers();
});

test('ak_ tokens are not affected by the cache', async () => {
server.use(
http.post(mockMachineAuthResponses.api_key.endpoint, () =>
HttpResponse.json(mockVerificationResults.api_key),
),
);
const result = await authenticateRequest(
mockRequest({ authorization: `Bearer ${mockTokens.api_key}` }),
mockOptions({ acceptsToken: 'api_key' }),
);
expect(result).toBeMachineAuthenticated();
});

test('mt_ tokens are not affected by the cache', async () => {
server.use(
http.post(mockMachineAuthResponses.m2m_token.endpoint, () =>
HttpResponse.json(mockVerificationResults.m2m_token),
),
);
const result = await authenticateRequest(
mockRequest({ authorization: `Bearer ${mockTokens.m2m_token}` }),
mockOptions({ acceptsToken: 'm2m_token' }),
);
expect(result).toBeMachineAuthenticated();
});
});

describe('Token Location Validation', () => {
test.each(tokenTypes)('returns unauthenticated state when %s is in cookie instead of header', async tokenType => {
const mockToken = mockTokens[tokenType];
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/tokens/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export function isMachineTokenByPrefix(token: string): boolean {
return MACHINE_TOKEN_PREFIXES.some(prefix => token.startsWith(prefix));
}

export function isOAuthTokenByPrefix(token: string): boolean {
return token.startsWith(OAUTH_TOKEN_PREFIX);
}

/**
* Checks if a token is a machine token by looking at its prefix or if it's an OAuth/M2M JWT.
*
Expand Down
44 changes: 44 additions & 0 deletions packages/backend/src/tokens/oauthNegativeCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../errors';

const TTL_MS = 30_000;
const MAX_ENTRIES = 10_000;

type Entry = { expiresAt: number };
const cache = new Map<string, Entry>();

export function isOAuthTokenCachedAsInvalid(token: string): boolean {
const entry = cache.get(token);
if (!entry) {
return false;
}
if (Date.now() >= entry.expiresAt) {
cache.delete(token);
return false;
}
return true;
}

export function maybeCacheOAuthTokenAsInvalid(err: unknown, token: string): void {
if (!(err instanceof MachineTokenVerificationError) || err.code !== MachineTokenVerificationErrorCode.TokenInvalid) {
return;
}
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) {
cache.delete(oldest);
}
}
cache.set(token, { expiresAt: Date.now() + TTL_MS });
}

export function makeCachedInvalidOAuthTokenError(): MachineTokenVerificationError {
return new MachineTokenVerificationError({
message: 'OAuth token not found',
code: MachineTokenVerificationErrorCode.TokenInvalid,
status: 404,
});
}

export function resetOAuthNegativeCache(): void {
cache.clear();
}
Loading
Loading