Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 13 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Expected 1 empty line after import statement not followed by another import

Check failure on line 13 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Import in body of module; reorder to top
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),

Check failure on line 24 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Unexpected any. Specify a different type
authorizeOIDC: (...args: any[]) => mockAuthorizeOIDC(...args),

Check failure on line 25 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Unexpected any. Specify a different type
getNonce: (...args: any[]) => mockGetNonce(...args),

Check failure on line 26 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Unexpected any. Specify a different type
getUserProfileLineage: jest.fn(),
}));

describe('SRPJwtBearerAuth burst protection', () => {
const config: AuthConfig & { type: AuthType.SRP } = {
type: AuthType.SRP,
env: 'test' as any,

Check failure on line 33 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Unexpected any. Specify a different type
platform: 'extension' as any,

Check failure on line 34 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Unexpected any. Specify a different type
};

const createAuth = (overrides?: {
minIntervalMs?: number;
cooldownDefaultMs?: number;
maxRetries?: number;
}) => {
const store: any = { value: null as any };

Check failure on line 42 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Unexpected any. Specify a different type

Check failure on line 42 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Unexpected any. Specify a different type

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,

Check failure on line 74 in packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.burst.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Unexpected any. Specify a different type
});
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -71,6 +72,12 @@ export class SRPJwtBearerAuth implements IBaseAuth {
// Map to store ongoing login promises by entropySourceId
readonly #ongoingLogins = new Map<string, Promise<LoginResponse>>();

// Per-entropySourceId throttling/cooldown schedule state
readonly #loginScheduleByKey = new Map<string, number>(); // 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(
Expand Down Expand Up @@ -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);
Expand All @@ -240,6 +247,50 @@ export class SRPJwtBearerAuth implements IBaseAuth {
}
}

async #loginWithRetry(
loginKey: string,
entropySourceId?: string,
): Promise<LoginResponse> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<never> {
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`;

Expand Down Expand Up @@ -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 */
Expand All @@ -118,10 +168,7 @@ export async function getNonce(id: string, env: Env): Promise<NonceResponse> {
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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
if (ms <= 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, ms));
}
Loading
Loading