diff --git a/packages/backend/src/tokens/__tests__/oauthNegativeCache.test.ts b/packages/backend/src/tokens/__tests__/oauthNegativeCache.test.ts new file mode 100644 index 00000000000..0d57dd92392 --- /dev/null +++ b/packages/backend/src/tokens/__tests__/oauthNegativeCache.test.ts @@ -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); + }); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index e9b5fa6bfda..48b926d3a44 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -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, @@ -1452,6 +1453,7 @@ describe('tokens.authenticateRequest(options)', () => { describe('Machine authentication', () => { afterEach(() => { vi.clearAllMocks(); + resetOAuthNegativeCache(); }); // Test each token type with parameterized tests @@ -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: '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]; diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index cfc055e96d3..70e67ba8e14 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -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. * diff --git a/packages/backend/src/tokens/oauthNegativeCache.ts b/packages/backend/src/tokens/oauthNegativeCache.ts new file mode 100644 index 00000000000..b986c0b547c --- /dev/null +++ b/packages/backend/src/tokens/oauthNegativeCache.ts @@ -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(); + +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(); +} diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 415f1e4e0b6..642cc781ada 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -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'; @@ -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]); } return signedIn({ @@ -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]); }