Skip to content
Open
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
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 }) => {
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 { auth } = createAuth({ cooldownDefaultMs: 20 });

let first = true;
mockAuthenticate.mockImplementation(async () => {
// eslint-disable-next-line jest/no-conditional-in-test
if (first) {
first = false;
throw createRateLimitError(20);
}
return 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(2);
// Should apply cooldown delay
expect(mockDelay).toHaveBeenCalledWith(20);
});

it('throws 429 after exhausting one retry', async () => {
const { auth } = createAuth({ cooldownDefaultMs: 20 });

mockAuthenticate.mockRejectedValue(createRateLimitError(20));

await expect(auth.getAccessToken()).rejects.toThrow('rate limited');

// Should attempt initial + one retry = 2 attempts
expect(mockAuthenticate).toHaveBeenCalledTimes(2);
// Should apply cooldown delay once
expect(mockDelay).toHaveBeenCalledTimes(1);
});

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,9 @@ 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
};
};

const getDefaultEIP6963Provider = async () => {
Expand Down Expand Up @@ -64,13 +68,19 @@ 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;

#customProvider?: Eip1193Provider;

constructor(
Expand All @@ -89,6 +99,10 @@ 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;
}

setCustomProvider(provider: Eip1193Provider) {
Expand Down Expand Up @@ -225,7 +239,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 +254,35 @@ export class SRPJwtBearerAuth implements IBaseAuth {
}
}

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

// Wait for Retry-After or default cooldown
const waitMs =
(e as RateLimitedError).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
Loading