Skip to content
8 changes: 8 additions & 0 deletions packages/profile-sync-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Add rate limit (429) handling with automatic retry in authentication flow ([#6993](https://github.com/MetaMask/core/pull/6993))
- Update authentication services to throw `RateLimitedError` when encountering 429 responses.
- Improve Authentication errors by adding the HTTP code in error messages.
- Add rate limit retry logic to `SRPJwtBearerAuth` with configurable cooldown via `rateLimitRetry.cooldownDefaultMs` option (defaults to 10 seconds).
- Non-429 errors are thrown immediately without retry, delegating retry logic to consumers.

## [26.0.0]

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { SRPJwtBearerAuth } from './flow-srp';
import {
AuthType,
type AuthConfig,
type LoginResponse,
type UserProfile,
} from './types';
import * as timeUtils from './utils/time';
import { Env, Platform } from '../../shared/env';
import { RateLimitedError } from '../errors';

jest.setTimeout(15000);

// Mock the time utilities to avoid real delays in tests
jest.mock('./utils/time', () => ({
delay: jest.fn(),
}));

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: unknown[]) => mockAuthenticate(...args),
authorizeOIDC: (...args: unknown[]) => mockAuthorizeOIDC(...args),
getNonce: (...args: unknown[]) => mockGetNonce(...args),
getUserProfileLineage: jest.fn(),
}));

describe('SRPJwtBearerAuth rate limit handling', () => {
const config: AuthConfig & { type: AuthType.SRP } = {
type: AuthType.SRP,
env: Env.DEV,
platform: Platform.EXTENSION,
};

// Mock data constants
const MOCK_PROFILE: UserProfile = {
profileId: 'p1',
metaMetricsId: 'm1',
identifierId: 'i1',
};

const MOCK_NONCE_RESPONSE = {
nonce: 'nonce-1',
identifier: 'identifier-1',
expiresIn: 60,
};

const MOCK_AUTH_RESPONSE = {
token: 'jwt-token',
expiresIn: 60,
profile: MOCK_PROFILE,
};

const MOCK_OIDC_RESPONSE = {
accessToken: 'access',
expiresIn: 60,
obtainedAt: Date.now(),
};

// Helper to create a rate limit error
const createRateLimitError = (retryAfterMs?: number) =>
new RateLimitedError('rate limited', retryAfterMs);

const createAuth = (overrides?: {
cooldownDefaultMs?: number;
maxLoginRetries?: number;
}) => {
const store: { value: LoginResponse | null } = { value: null };

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

return { auth, store };
};

beforeEach(() => {
jest.clearAllMocks();
mockGetNonce.mockResolvedValue(MOCK_NONCE_RESPONSE);
mockAuthenticate.mockResolvedValue(MOCK_AUTH_RESPONSE);
mockAuthorizeOIDC.mockResolvedValue(MOCK_OIDC_RESPONSE);
});

it('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);
});

it('applies cooldown and retries once on 429 with Retry-After', async () => {
const cooldownDefaultMs = 20;
const maxLoginRetries = 1;
const { auth } = createAuth({ cooldownDefaultMs, maxLoginRetries });

mockAuthenticate
.mockRejectedValueOnce(createRateLimitError(cooldownDefaultMs))
.mockResolvedValueOnce(MOCK_AUTH_RESPONSE);

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(maxLoginRetries + 1);
// Should apply cooldown delay
expect(mockDelay).toHaveBeenCalledWith(cooldownDefaultMs);
});

it('throws 429 after exhausting all retries', async () => {
const cooldownDefaultMs = 20;
const maxLoginRetries = 1;
const { auth } = createAuth({ cooldownDefaultMs, maxLoginRetries });

mockAuthenticate.mockRejectedValue(createRateLimitError(cooldownDefaultMs));
await expect(auth.getAccessToken()).rejects.toThrow('rate limited');

// Should attempt initial + maxLoginRetries
expect(mockAuthenticate).toHaveBeenCalledTimes(1 + maxLoginRetries);
// Should apply cooldown delay
expect(mockDelay).toHaveBeenCalledTimes(maxLoginRetries);
});

it('throws transient errors immediately without retry', async () => {
const { auth, store } = createAuth();

// 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);
// Should NOT apply any delay
expect(mockDelay).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import type {
UserProfile,
UserProfileLineage,
} from './types';
import * as timeUtils from './utils/time';
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,
Expand All @@ -30,6 +31,10 @@ import { validateLoginResponse } from '../utils/validate-login-response';
type JwtBearerAuth_SRP_Options = {
storage: AuthStorageOptions;
signing?: AuthSigningOptions;
rateLimitRetry?: {
cooldownDefaultMs?: number; // default cooldown when 429 has no Retry-After
maxLoginRetries?: number; // maximum number of login retries on rate limit
};
};

const getDefaultEIP6963Provider = async () => {
Expand Down Expand Up @@ -64,13 +69,22 @@ const getDefaultEIP6963SigningOptions = (
export class SRPJwtBearerAuth implements IBaseAuth {
readonly #config: AuthConfig;

readonly #options: Required<JwtBearerAuth_SRP_Options>;
readonly #options: {
storage: AuthStorageOptions;
signing: AuthSigningOptions;
};

readonly #metametrics?: MetaMetricsAuth;

// Map to store ongoing login promises by entropySourceId
readonly #ongoingLogins = new Map<string, Promise<LoginResponse>>();

// Default cooldown when 429 has no Retry-After header
readonly #cooldownDefaultMs: number;

// Maximum number of login retries on rate limit errors
readonly #maxLoginRetries: number;

#customProvider?: Eip1193Provider;

constructor(
Expand All @@ -89,6 +103,11 @@ export class SRPJwtBearerAuth implements IBaseAuth {
getDefaultEIP6963SigningOptions(this.#customProvider),
};
this.#metametrics = options.metametrics;

// Apply rate limit retry config if provided
this.#cooldownDefaultMs =
options.rateLimitRetry?.cooldownDefaultMs ?? 10000;
this.#maxLoginRetries = options.rateLimitRetry?.maxLoginRetries ?? 1;
}

setCustomProvider(provider: Eip1193Provider) {
Expand Down Expand Up @@ -225,7 +244,7 @@ export class SRPJwtBearerAuth implements IBaseAuth {
}

// Create a new login promise
const loginPromise = this.#performLogin(entropySourceId);
const loginPromise = this.#loginWithRetry(entropySourceId);

// Store the promise in the map
this.#ongoingLogins.set(loginKey, loginPromise);
Expand All @@ -240,6 +259,34 @@ export class SRPJwtBearerAuth implements IBaseAuth {
}
}

async #loginWithRetry(entropySourceId?: string): Promise<LoginResponse> {
// Allow max attempts: initial + maxLoginRetries on 429
for (let attempt = 0; attempt < 1 + this.#maxLoginRetries; attempt += 1) {
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.#maxLoginRetries) {
throw e;
}

// Wait for Retry-After or default cooldown
const waitMs = e.retryAfterMs ?? this.#cooldownDefaultMs;
await timeUtils.delay(waitMs);

// Loop continues to retry
}
}

// Should never reach here due to loop logic, but TypeScript needs a return
throw new Error('Unexpected: login loop exhausted without result');
}

#createSrpLoginRawMessage(
nonce: string,
publicKey: string,
Expand Down
Loading