diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts new file mode 100644 index 00000000000..a2d1412ab94 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts @@ -0,0 +1,174 @@ +import { SRPJwtBearerAuth } from './flow-srp'; +import type { AuthConfig } from './types'; +import { AuthType } from './types'; + +jest.setTimeout(15000); + +// Mock the time utilities to avoid real delays in tests +jest.mock('./utils/time', () => ({ + delay: jest.fn(), +})); + +// Import after mocking to get the mocked version +import * as timeUtils from './utils/time'; +const mockDelay = timeUtils.delay as jest.MockedFunction< + typeof timeUtils.delay +>; + +// Mock services +const mockGetNonce = jest.fn(); +const mockAuthenticate = jest.fn(); +const mockAuthorizeOIDC = jest.fn(); + +jest.mock('./services', () => ({ + authenticate: (...args: any[]) => mockAuthenticate(...args), + authorizeOIDC: (...args: any[]) => mockAuthorizeOIDC(...args), + getNonce: (...args: any[]) => mockGetNonce(...args), + getUserProfileLineage: jest.fn(), +})); + +describe('SRPJwtBearerAuth burst protection', () => { + const config: AuthConfig & { type: AuthType.SRP } = { + type: AuthType.SRP, + env: 'test' as any, + platform: 'extension' as any, + }; + + const createAuth = (overrides?: { + minIntervalMs?: number; + cooldownDefaultMs?: number; + maxRetries?: number; + }) => { + const store: any = { value: null as any }; + + const auth = new SRPJwtBearerAuth(config, { + storage: { + getLoginResponse: async () => store.value, + setLoginResponse: async (val) => { + store.value = val; + }, + }, + signing: { + getIdentifier: async () => 'identifier-1', + signMessage: async () => 'signature-1', + }, + }); + + return { auth, store }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetNonce.mockResolvedValue({ + nonce: 'nonce-1', + identifier: 'identifier-1', + expiresIn: 60, + }); + mockAuthenticate.mockResolvedValue({ + token: 'jwt-token', + expiresIn: 60, + profile: { + profileId: 'p1', + metametrics_id: 'm1', + identifier_id: 'i1', + } as any, + }); + mockAuthorizeOIDC.mockResolvedValue({ + accessToken: 'access', + expiresIn: 60, + obtainedAt: Date.now(), + }); + }); + + test('coalesces concurrent calls into a single login attempt', async () => { + const { auth } = createAuth(); + + const p1 = auth.getAccessToken(); + const p2 = auth.getAccessToken(); + const p3 = auth.getAccessToken(); + + const [t1, t2, t3] = await Promise.all([p1, p2, p3]); + + expect(t1).toBe('access'); + expect(t2).toBe('access'); + expect(t3).toBe('access'); + + // single sequence of service calls + expect(mockGetNonce).toHaveBeenCalledTimes(1); + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(mockAuthorizeOIDC).toHaveBeenCalledTimes(1); + }); + + test('throttles sequential login attempts within min interval', async () => { + const { auth, store } = createAuth(); + + await auth.getAccessToken(); + + // Clear the store to force a new login + store.value = null; + + await auth.getAccessToken(); + + expect(mockGetNonce).toHaveBeenCalledTimes(2); + expect(mockAuthenticate).toHaveBeenCalledTimes(2); + + // Verify that throttling delay was applied + expect(mockDelay).toHaveBeenCalled(); + }); + test('applies cooldown and retries once on 429 with Retry-After', async () => { + const { auth } = createAuth(); + + let first = true; + mockAuthenticate.mockImplementation(async () => { + if (first) { + first = false; + const e: any = new Error('rate limited'); + e.name = 'RateLimitedError'; + e.status = 429; + e.retryAfterMs = 20; + throw e; + } + return { + token: 'jwt-token', + expiresIn: 60, + profile: { + profileId: 'p1', + metametrics_id: 'm1', + identifier_id: 'i1', + } as any, + }; + }); + + const p1 = auth.getAccessToken(); + const p2 = auth.getAccessToken(); + + const [t1, t2] = await Promise.all([p1, p2]); + expect(t1).toBe('access'); + expect(t2).toBe('access'); + + // Should retry after rate limit error + expect(mockAuthenticate).toHaveBeenCalledTimes(2); + // Should apply cooldown delay + expect(mockDelay).toHaveBeenCalled(); + }); + + test('throws transient errors immediately without retry', async () => { + const { auth, store } = createAuth({ + maxRetries: 1, + minIntervalMs: 10, + }); + + // Force a login by clearing session + store.value = null; + + const transientError = new Error('transient network error'); + mockAuthenticate.mockRejectedValue(transientError); + + await expect(auth.getAccessToken()).rejects.toThrow( + 'transient network error', + ); + + // Should NOT retry on transient errors + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts index b8e7d68904b..f3d1f5bb8ef 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts @@ -17,7 +17,7 @@ import type { UserProfileLineage, } from './types'; import type { MetaMetricsAuth } from '../../shared/types/services'; -import { ValidationError } from '../errors'; +import { ValidationError, RateLimitedError } from '../errors'; import { getMetaMaskProviderEIP6963 } from '../utils/eip-6963-metamask-provider'; import { MESSAGE_SIGNING_SNAP, @@ -26,6 +26,7 @@ import { isSnapConnected, } from '../utils/messaging-signing-snap-requests'; import { validateLoginResponse } from '../utils/validate-login-response'; +import * as timeUtils from './utils/time'; type JwtBearerAuth_SRP_Options = { storage: AuthStorageOptions; @@ -71,6 +72,12 @@ export class SRPJwtBearerAuth implements IBaseAuth { // Map to store ongoing login promises by entropySourceId readonly #ongoingLogins = new Map>(); + // Per-entropySourceId throttling/cooldown schedule state + readonly #loginScheduleByKey = new Map(); // Maps loginKey -> nextAllowedAtTimestamp + readonly #minIntervalMs = 1000; // minimum spacing between attempts per entropy source + readonly #cooldownDefaultMs = 10000; // default cooldown when 429 has no Retry-After + readonly #maxRetries = 1; // total retries for rate-limit (429) errors + #customProvider?: Eip1193Provider; constructor( @@ -225,7 +232,7 @@ export class SRPJwtBearerAuth implements IBaseAuth { } // Create a new login promise - const loginPromise = this.#performLogin(entropySourceId); + const loginPromise = this.#loginWithRetry(loginKey, entropySourceId); // Store the promise in the map this.#ongoingLogins.set(loginKey, loginPromise); @@ -240,6 +247,50 @@ export class SRPJwtBearerAuth implements IBaseAuth { } } + async #loginWithRetry( + loginKey: string, + entropySourceId?: string, + ): Promise { + for (let attempt = 0; ; attempt++) { + // Wait if we need to throttle + const nextAllowedAt = this.#loginScheduleByKey.get(loginKey) ?? 0; + const now = Date.now(); + const waitMs = Math.max(0, nextAllowedAt - now); + if (waitMs > 0) { + await timeUtils.delay(waitMs); + } + + // Update next allowed time + this.#loginScheduleByKey.set(loginKey, Date.now() + this.#minIntervalMs); + + try { + return await this.#performLogin(entropySourceId); + } catch (e) { + // Only retry on rate-limit (429) errors + if (!RateLimitedError.isRateLimitError(e)) { + throw e; + } + + // If we've exhausted attempts, rethrow + if (attempt >= this.#maxRetries) { + throw e; + } + + // Add cooldown based on Retry-After header or default + const additionalBackoffMs = + (e as RateLimitedError).retryAfterMs ?? this.#cooldownDefaultMs; + + // Add backoff to the already-set next allowed time + const currentNextAllowed = this.#loginScheduleByKey.get(loginKey) ?? 0; + this.#loginScheduleByKey.set( + loginKey, + currentNextAllowed + additionalBackoffMs, + ); + // Loop will continue to retry + } + } + } + #createSrpLoginRawMessage( nonce: string, publicKey: string, diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts index 3bc4c91265f..70643b63e07 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts @@ -13,8 +13,61 @@ import { PairError, SignInError, ValidationError, + RateLimitedError, } from '../errors'; +/** + * Parse Retry-After header into milliseconds if possible. + * Supports seconds or HTTP-date formats. + */ +function parseRetryAfter(retryAfterHeader: string | null): number | null { + if (!retryAfterHeader) { + return null; + } + const seconds = Number(retryAfterHeader); + if (!Number.isNaN(seconds)) { + return seconds * 1000; + } + const date = Date.parse(retryAfterHeader); + if (!Number.isNaN(date)) { + const diff = date - Date.now(); + return diff > 0 ? diff : 0; + } + return null; +} + +/** + * Handle HTTP error responses with rate limiting support + */ +async function handleErrorResponse( + response: Response, + errorPrefix?: string, +): Promise { + const status = response.status; + const retryAfterHeader = response.headers.get('Retry-After'); + const retryAfterMs = parseRetryAfter(retryAfterHeader); + + const responseBody = (await response.json()) as + | ErrorMessage + | { error_description: string; error: string }; + + const message = + 'message' in responseBody + ? responseBody.message + : responseBody.error_description; + const error = responseBody.error; + + if (status === 429) { + throw new RateLimitedError( + `HTTP 429: ${message} (error: ${error})`, + retryAfterMs ?? undefined, + ); + } + + const prefix = errorPrefix ? `${errorPrefix} ` : ''; + throw new Error(`${prefix}HTTP ${status} error: ${message}, error: ${error}`); +} + export const NONCE_URL = (env: Env) => `${getEnvUrls(env).authApiUrl}/api/v2/nonce`; @@ -91,10 +144,7 @@ export async function pairIdentifiers( }); if (!response.ok) { - const responseBody = (await response.json()) as ErrorMessage; - throw new Error( - `HTTP error message: ${responseBody.message}, error: ${responseBody.error}`, - ); + await handleErrorResponse(response); } } catch (e) { /* istanbul ignore next */ @@ -118,10 +168,7 @@ export async function getNonce(id: string, env: Env): Promise { try { const nonceResponse = await fetch(nonceUrl.toString()); if (!nonceResponse.ok) { - const responseBody = (await nonceResponse.json()) as ErrorMessage; - throw new Error( - `HTTP error message: ${responseBody.message}, error: ${responseBody.error}`, - ); + await handleErrorResponse(nonceResponse); } const nonceJson = await nonceResponse.json(); @@ -169,13 +216,7 @@ export async function authorizeOIDC( }); if (!response.ok) { - const responseBody = (await response.json()) as { - error_description: string; - error: string; - }; - throw new Error( - `HTTP error: ${responseBody.error_description}, error code: ${responseBody.error}`, - ); + await handleErrorResponse(response); } const accessTokenResponse = await response.json(); @@ -237,10 +278,7 @@ export async function authenticate( }); if (!response.ok) { - const responseBody = (await response.json()) as ErrorMessage; - throw new Error( - `${authType} login HTTP error: ${responseBody.message}, error code: ${responseBody.error}`, - ); + await handleErrorResponse(response, `${authType} login`); } const loginResponse = await response.json(); diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/time.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/time.ts new file mode 100644 index 00000000000..fa836de4155 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/time.ts @@ -0,0 +1,11 @@ +/** + * Delays execution for the specified number of milliseconds. + * @param ms - Number of milliseconds to delay + * @returns Promise that resolves after the delay + */ +export async function delay(ms: number): Promise { + if (ms <= 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/profile-sync-controller/src/sdk/errors.ts b/packages/profile-sync-controller/src/sdk/errors.ts index 40ce5bc7788..115f79cee43 100644 --- a/packages/profile-sync-controller/src/sdk/errors.ts +++ b/packages/profile-sync-controller/src/sdk/errors.ts @@ -46,3 +46,24 @@ export class NotFoundError extends Error { this.name = 'NotFoundError'; } } + +export class RateLimitedError extends Error { + readonly status = 429; + readonly retryAfterMs?: number; + + constructor(message: string, retryAfterMs?: number) { + super(message); + this.name = 'RateLimitedError'; + this.retryAfterMs = retryAfterMs; + } + + /** + * Check if an unknown error is a rate limit error (429 status) + */ + static isRateLimitError(e: unknown): e is RateLimitedError { + return ( + e instanceof RateLimitedError || + (typeof e === 'object' && e !== null && (e as any)?.status === 429) + ); + } +}