Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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,182 @@
import { SRPJwtBearerAuth } from './flow-srp';
import { AuthType, type AuthConfig } from './types';
import * as timeUtils from './utils/time';

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', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authenticate: (...args: any[]) => mockAuthenticate(...args),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authorizeOIDC: (...args: any[]) => mockAuthorizeOIDC(...args),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getNonce: (...args: any[]) => mockGetNonce(...args),
getUserProfileLineage: jest.fn(),
}));

describe('SRPJwtBearerAuth rate limit handling', () => {
const config: AuthConfig & { type: AuthType.SRP } = {
type: AuthType.SRP,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
env: 'test' as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
platform: 'extension' as any,
};

// Mock data constants
const MOCK_PROFILE = {
profileId: 'p1',
metametrics_id: 'm1',
identifier_id: 'i1',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;

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) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error: any = new Error('rate limited');
error.name = 'RateLimitedError';
error.status = 429;
if (retryAfterMs !== undefined) {
error.retryAfterMs = retryAfterMs;
}
return error;
};

const createAuth = (overrides?: { cooldownDefaultMs?: number }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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',
},
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