Skip to content
Closed
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
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
@@ -1,7 +1,8 @@
import { http, HttpResponse } from 'msw';

Check failure on line 1 in packages/backend/src/tokens/__tests__/request.test.ts

View workflow job for this annotation

GitHub Actions / Static analysis

Run autofix to sort these imports!
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('Machine authentication', () => {
afterEach(() => {
vi.clearAllMocks();
resetOAuthNegativeCache();
});

// Test each token type with parameterized tests
Expand Down Expand Up @@ -1762,6 +1764,108 @@
});
});

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: 'any' }),
);

// 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: 'any' }),
);
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();
}
27 changes: 26 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ import { AuthErrorReason, handshake, signedIn, signedOut, signedOutInvalidToken
import { createClerkRequest } from './clerkRequest';
import { getCookieName, getCookieValue } from './cookie';
import { HandshakeService } from './handshake';
import { getMachineTokenType, isMachineJwt, isMachineToken, isTokenTypeAccepted } from './machine';
import {
getMachineTokenType,
isMachineJwt,
isMachineToken,
isOAuthTokenByPrefix,
isTokenTypeAccepted,
} from './machine';
import {
isOAuthTokenCachedAsInvalid,
makeCachedInvalidOAuthTokenError,
maybeCacheOAuthTokenAsInvalid,
} from './oauthNegativeCache';
import { OrganizationMatcher } from './organizationMatcher';
import type { MachineTokenType, SessionTokenType } from './tokenTypes';
import { TokenType } from './tokenTypes';
Expand Down Expand Up @@ -795,8 +806,15 @@ export const authenticateRequest: AuthenticateRequest = (async (
return mismatchState;
}

if (isOAuthTokenByPrefix(tokenInHeader) && isOAuthTokenCachedAsInvalid(tokenInHeader)) {
return handleMachineError(parsedTokenType, makeCachedInvalidOAuthTokenError());
}

const { data, tokenType, errors } = await verifyMachineAuthToken(tokenInHeader, authenticateContext);
if (errors) {
if (isOAuthTokenByPrefix(tokenInHeader)) {
maybeCacheOAuthTokenAsInvalid(errors[0], tokenInHeader);
}
return handleMachineError(tokenType, errors[0]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return signedIn({
Expand All @@ -822,8 +840,15 @@ export const authenticateRequest: AuthenticateRequest = (async (
return mismatchState;
}

if (isOAuthTokenByPrefix(tokenInHeader) && isOAuthTokenCachedAsInvalid(tokenInHeader)) {
return handleMachineError(parsedTokenType, makeCachedInvalidOAuthTokenError());
}

const { data, tokenType, errors } = await verifyMachineAuthToken(tokenInHeader, authenticateContext);
if (errors) {
if (isOAuthTokenByPrefix(tokenInHeader)) {
maybeCacheOAuthTokenAsInvalid(errors[0], tokenInHeader);
}
return handleMachineError(tokenType, errors[0]);
}

Expand Down
Loading