From f5a5e387c7ac923b085d5b21cdae2bbefcde5aa9 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Wed, 25 Jun 2025 18:39:03 +0200 Subject: [PATCH 01/16] feat: add new method to ingest the OAuth token from social login and try to use the token for automatic pairing after SRP login --- .../AuthenticationController.ts | 63 +++++++++++++++++- .../sdk/authentication-jwt-bearer/flow-srp.ts | 65 ++++++++++++++++++- .../sdk/authentication-jwt-bearer/services.ts | 3 + .../src/sdk/authentication.ts | 5 ++ 4 files changed, 132 insertions(+), 4 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index a8ff2f2a756..be9757d3780 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -13,8 +13,8 @@ import type { import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import { - createSnapPublicKeyRequest, createSnapAllPublicKeysRequest, + createSnapPublicKeyRequest, createSnapSignMessageRequest, } from './auth-snap-requests'; import type { @@ -37,6 +37,8 @@ const controllerName = 'AuthenticationController'; export type AuthenticationControllerState = { isSignedIn: boolean; srpSessionData?: Record; + socialPairingToken?: string; + socialPairingDone?: boolean; }; export const defaultState: AuthenticationControllerState = { isSignedIn: false, @@ -50,6 +52,14 @@ const metadata: StateMetadata = { persist: true, anonymous: false, }, + socialPairingToken: { + persist: true, + anonymous: true, + }, + socialPairingDone: { + persist: true, + anonymous: true, + }, }; type ControllerConfig = { @@ -70,6 +80,7 @@ type ActionsObj = CreateActionsObj< | 'getSessionProfile' | 'getUserProfileMetaMetrics' | 'isSignedIn' + | 'ingestSocialLoginToken' >; export type Actions = | ActionsObj[keyof ActionsObj] @@ -298,13 +309,18 @@ export default class AuthenticationController extends BaseController< const allPublicKeys = await this.#snapGetAllPublicKeys(); const accessTokens = []; - // We iterate sequentially in order to be sure that the first entry + // We iterate sequentially to be sure that the first entry // is the primary SRP LoginResponse. for (const [entropySourceId] of allPublicKeys) { const accessToken = await this.#auth.getAccessToken(entropySourceId); accessTokens.push(accessToken); } + // don't await for the pairing to finish + this.#tryPairingWithSocialToken().catch((_) => { + // don't care + }); + return accessTokens; } @@ -312,6 +328,8 @@ export default class AuthenticationController extends BaseController< this.update((state) => { state.isSignedIn = false; state.srpSessionData = undefined; + state.socialPairingToken = undefined; + state.socialPairingDone = false; }); } @@ -351,6 +369,47 @@ export default class AuthenticationController extends BaseController< return this.state.isSignedIn; } + /** + * Stores a social login JWT token in controller state temporarily + * until it can be used for pairing. + * This token will automatically be removed from state after + * successful pairing or during a sign-out request. + * + * @param token - The JWT token from seedless onboarding OAuth flow + */ + public ingestSocialLoginToken(token: string) { + this.update((state) => { + state.socialPairingToken = token; + state.socialPairingDone = false; + }); + } + + async #tryPairingWithSocialToken(): Promise { + console.log(`GIGEL: trying to pair with seedless token`); + const { socialPairingToken, socialPairingDone } = this.state; + if (socialPairingDone || !socialPairingToken) { + console.log(`GIGEL: pairing conditions not met`); + return; + } + + try { + console.log(`GIGEL: pairing with seedless token ${socialPairingToken}`); + if (await this.#auth.pairSocialIdentifier(socialPairingToken)) { + console.log(`GIGEL: successfully paired with seedless onboarding token`); + this.update((state) => { + state.socialPairingDone = true; + state.socialPairingToken = undefined; + }); + } else { + console.log(`GIGEL: pairing with seedless token failed`); + // ignore the error + } + } catch (error) { + console.error('GIGEL: Failed to pair identifiers:', error); + // ignore the error + } + } + /** * Returns the auth snap public key. * 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 5156462a20f..e3e3c824a11 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 @@ -5,19 +5,21 @@ import { authorizeOIDC, getNonce, getUserProfileMetaMetrics, + PAIR_SOCIAL_IDENTIFIER, } from './services'; import type { AuthConfig, AuthSigningOptions, AuthStorageOptions, - AuthType, + ErrorMessage, IBaseAuth, LoginResponse, UserProfile, UserProfileMetaMetrics, } from './types'; +import { AuthType } from './types'; import type { MetaMetricsAuth } from '../../shared/types/services'; -import { ValidationError } from '../errors'; +import { PairError, ValidationError } from '../errors'; import { getMetaMaskProviderEIP6963 } from '../utils/eip-6963-metamask-provider'; import { MESSAGE_SIGNING_SNAP, @@ -212,4 +214,63 @@ export class SRPJwtBearerAuth implements IBaseAuth { ): `metamask:${string}:${string}` { return `metamask:${nonce}:${publicKey}` as const; } + + async pairSocialIdentifier(jwt: string): Promise { + console.log( + `GIGEL: pairing primary SRP with social token ${jwt}`, + ); + + const { env, platform } = this.#config; + + // Exchange the social token with an access token + console.log(`GIGEL: exchanging social token for access token`); + const tokenResponse = await authorizeOIDC(jwt, env, platform); + console.log(`GIGEL: obtained access token ${tokenResponse.accessToken}`); + + // Prepare the SRP signature + const identifier = await this.getIdentifier(); + const profile = await this.getUserProfile(); + const n = await getNonce(profile.profileId, env); + console.log( + `GIGEL: pairing social token with profile ${profile.profileId} with nonce ${n.nonce}`, + ); + const raw = `metamask:${n.nonce}:${identifier}`; + const sig = await this.signMessage(raw); + const primaryIdentifierSignature = { + signature: sig, + raw_message: raw, + identifier_type: AuthType.SRP, + encrypted_storage_key: '', // Not yet part of this flow, so we leave it empty + }; + + const pairUrl = new URL(PAIR_SOCIAL_IDENTIFIER(env)); + + try { + const response = await fetch(pairUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${tokenResponse.accessToken}`, + }, + body: JSON.stringify({ + nonce: n.nonce, + login: primaryIdentifierSignature, + }), + }); + + if (!response.ok) { + const responseBody = (await response.json()) as ErrorMessage; + throw new Error( + `HTTP error message: ${responseBody.message}, error: ${responseBody.error}`, + ); + } + } catch (e) { + /* istanbul ignore next */ + const errorMessage = + e instanceof Error ? e.message : JSON.stringify(e ?? ''); + throw new PairError(`unable to pair identifiers: ${errorMessage}`); + } + + return false; + } } 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 0b199886a4c..8f069a77099 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 @@ -21,6 +21,9 @@ export const NONCE_URL = (env: Env) => export const PAIR_IDENTIFIERS = (env: Env) => `${getEnvUrls(env).authApiUrl}/api/v2/identifiers/pair`; +export const PAIR_SOCIAL_IDENTIFIER = (env: Env) => + `${getEnvUrls(env).authApiUrl}/api/v2/identifiers/pair/social`; + export const OIDC_TOKEN_URL = (env: Env) => `${getEnvUrls(env).oidcApiUrl}/oauth2/token`; diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index fd1a196ee7c..8b3c0cd1da3 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -117,6 +117,11 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { await pairIdentifiers(n.nonce, logins, accessToken, this.#env); } + async pairSocialIdentifier(jwt: string): Promise { + this.#assertSRP(this.#type, this.#sdk); + return await this.#sdk.pairSocialIdentifier(jwt); + } + prepare(signer: { address: string; chainId: number; From 6a0e97aae499b662bbe9c510b061cf3ced34dd4a Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 30 Jun 2025 09:32:56 +0200 Subject: [PATCH 02/16] chore: guard pairing calls from concurrent execution --- .../authentication/AuthenticationController.ts | 16 ++++++++++++++-- .../sdk/authentication-jwt-bearer/flow-srp.ts | 7 ++++++- .../sdk/authentication-jwt-bearer/services.ts | 5 +++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index be9757d3780..38f92603e67 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -39,6 +39,7 @@ export type AuthenticationControllerState = { srpSessionData?: Record; socialPairingToken?: string; socialPairingDone?: boolean; + pairingInProgress?: boolean; }; export const defaultState: AuthenticationControllerState = { isSignedIn: false, @@ -60,6 +61,10 @@ const metadata: StateMetadata = { persist: true, anonymous: true, }, + pairingInProgress: { + persist: false, + anonymous: true, + } }; type ControllerConfig = { @@ -386,13 +391,16 @@ export default class AuthenticationController extends BaseController< async #tryPairingWithSocialToken(): Promise { console.log(`GIGEL: trying to pair with seedless token`); - const { socialPairingToken, socialPairingDone } = this.state; - if (socialPairingDone || !socialPairingToken) { + const { socialPairingToken, socialPairingDone, pairingInProgress } = this.state; + if (socialPairingDone || !socialPairingToken || pairingInProgress) { console.log(`GIGEL: pairing conditions not met`); return; } try { + this.update((state) => { + state.pairingInProgress = true; + }); console.log(`GIGEL: pairing with seedless token ${socialPairingToken}`); if (await this.#auth.pairSocialIdentifier(socialPairingToken)) { console.log(`GIGEL: successfully paired with seedless onboarding token`); @@ -407,6 +415,10 @@ export default class AuthenticationController extends BaseController< } catch (error) { console.error('GIGEL: Failed to pair identifiers:', error); // ignore the error + } finally { + this.update((state) => { + state.pairingInProgress = false; + }); } } 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 e3e3c824a11..e806b05da68 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 @@ -28,6 +28,7 @@ import { isSnapConnected, } from '../utils/messaging-signing-snap-requests'; import { validateLoginResponse } from '../utils/validate-login-response'; +import { Env } from '../../shared/env'; type JwtBearerAuth_SRP_Options = { storage: AuthStorageOptions; @@ -220,7 +221,10 @@ export class SRPJwtBearerAuth implements IBaseAuth { `GIGEL: pairing primary SRP with social token ${jwt}`, ); - const { env, platform } = this.#config; + // TODO: need to hardcode the env as web3auth prod is not available. + // const { env, platform } = this.#config; + const { platform } = this.#config; + const env = Env.DEV; // Exchange the social token with an access token console.log(`GIGEL: exchanging social token for access token`); @@ -246,6 +250,7 @@ export class SRPJwtBearerAuth implements IBaseAuth { const pairUrl = new URL(PAIR_SOCIAL_IDENTIFIER(env)); try { + // TODO: this will FAIL as long as the ENV don't match. const response = await fetch(pairUrl, { method: 'POST', headers: { 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 8f069a77099..57029ef310e 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 @@ -164,30 +164,35 @@ export async function authorizeOIDC( urlEncodedBody.append('client_id', getOidcClientId(env, platform)); urlEncodedBody.append('assertion', jwtToken); + console.log(`GIGEL [identity auth] requesting OIDC token with grant_type: ${grantType}, client_id: ${getOidcClientId(env, platform)} from ${OIDC_TOKEN_URL(env)} using jwtToken: ${jwtToken}`); try { const response = await fetch(OIDC_TOKEN_URL(env), { method: 'POST', headers, body: urlEncodedBody.toString(), }); + console.log(`GIGEL [identity auth] OIDC token response status: ${response.status}`); if (!response.ok) { const responseBody = (await response.json()) as { error_description: string; error: string; }; + console.error(`GIGEL [identity auth] OIDC token error response: ${JSON.stringify(responseBody)}`); throw new Error( `HTTP error: ${responseBody.error_description}, error code: ${responseBody.error}`, ); } const accessTokenResponse = await response.json(); + console.log(`GIGEL [identity auth] OIDC token response: ${JSON.stringify(accessTokenResponse)}`); return { accessToken: accessTokenResponse.access_token, expiresIn: accessTokenResponse.expires_in, obtainedAt: Date.now(), }; } catch (e) { + console.error(`GIGEL [identity auth] OIDC token request failed: ${e as Error}`); /* istanbul ignore next */ const errorMessage = e instanceof Error ? e.message : JSON.stringify(e ?? ''); From 6da255379ce9f46030104bd1ab7954e63ec83be6 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 15 Jul 2025 15:18:11 +0200 Subject: [PATCH 03/16] fix: use the SeedlessOnboardingController state to get the accessToken for pairing BREAKING CHANGE: this adds @metamask/seedless-onboarding-controller as a peer dependency and requires clients to change their initialization of the controllers to allow `SeedlessOnboardingControllerGetStateAction` as well as match the build type to the `config.env` in the controller constructors. --- packages/profile-sync-controller/package.json | 1 + .../AuthenticationController.ts | 50 +++++-------------- .../user-storage/UserStorageController.ts | 2 +- .../sdk/authentication-jwt-bearer/flow-srp.ts | 33 +++--------- .../sdk/authentication-jwt-bearer/services.ts | 5 -- .../tsconfig.build.json | 1 + yarn.lock | 1 + 7 files changed, 23 insertions(+), 70 deletions(-) diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index ac88c6f9917..f70a6c948ae 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -135,6 +135,7 @@ "@metamask/accounts-controller": "^31.0.0", "@metamask/keyring-controller": "^22.0.0", "@metamask/providers": "^22.0.0", + "@metamask/seedless-onboarding-controller": "^2.1.0", "@metamask/snaps-controllers": "^14.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 38f92603e67..0adabd10d1c 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -10,6 +10,7 @@ import type { KeyringControllerLockEvent, KeyringControllerUnlockEvent, } from '@metamask/keyring-controller'; +import type { SeedlessOnboardingControllerGetStateAction } from '@metamask/seedless-onboarding-controller'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import { @@ -37,7 +38,6 @@ const controllerName = 'AuthenticationController'; export type AuthenticationControllerState = { isSignedIn: boolean; srpSessionData?: Record; - socialPairingToken?: string; socialPairingDone?: boolean; pairingInProgress?: boolean; }; @@ -53,10 +53,6 @@ const metadata: StateMetadata = { persist: true, anonymous: false, }, - socialPairingToken: { - persist: true, - anonymous: true, - }, socialPairingDone: { persist: true, anonymous: true, @@ -64,7 +60,7 @@ const metadata: StateMetadata = { pairingInProgress: { persist: false, anonymous: true, - } + }, }; type ControllerConfig = { @@ -85,7 +81,6 @@ type ActionsObj = CreateActionsObj< | 'getSessionProfile' | 'getUserProfileMetaMetrics' | 'isSignedIn' - | 'ingestSocialLoginToken' >; export type Actions = | ActionsObj[keyof ActionsObj] @@ -116,7 +111,8 @@ export type Events = AuthenticationControllerStateChangeEvent; // Allowed Actions export type AllowedActions = | HandleSnapRequest - | KeyringControllerGetStateAction; + | KeyringControllerGetStateAction + | SeedlessOnboardingControllerGetStateAction; export type AllowedEvents = | KeyringControllerLockEvent @@ -333,7 +329,6 @@ export default class AuthenticationController extends BaseController< this.update((state) => { state.isSignedIn = false; state.srpSessionData = undefined; - state.socialPairingToken = undefined; state.socialPairingDone = false; }); } @@ -374,48 +369,29 @@ export default class AuthenticationController extends BaseController< return this.state.isSignedIn; } - /** - * Stores a social login JWT token in controller state temporarily - * until it can be used for pairing. - * This token will automatically be removed from state after - * successful pairing or during a sign-out request. - * - * @param token - The JWT token from seedless onboarding OAuth flow - */ - public ingestSocialLoginToken(token: string) { - this.update((state) => { - state.socialPairingToken = token; - state.socialPairingDone = false; - }); - } - async #tryPairingWithSocialToken(): Promise { - console.log(`GIGEL: trying to pair with seedless token`); - const { socialPairingToken, socialPairingDone, pairingInProgress } = this.state; + const { accessToken: socialPairingToken } = this.messagingSystem.call( + 'SeedlessOnboardingController:getState', + ); + const { socialPairingDone, pairingInProgress } = this.state; if (socialPairingDone || !socialPairingToken || pairingInProgress) { - console.log(`GIGEL: pairing conditions not met`); return; } try { + console.log(`starting to pair with social token`); this.update((state) => { state.pairingInProgress = true; }); - console.log(`GIGEL: pairing with seedless token ${socialPairingToken}`); - if (await this.#auth.pairSocialIdentifier(socialPairingToken)) { - console.log(`GIGEL: successfully paired with seedless onboarding token`); + const paired = await this.#auth.pairSocialIdentifier(socialPairingToken); + console.log(`pairing with social token success=${paired}`); + if (paired) { this.update((state) => { state.socialPairingDone = true; - state.socialPairingToken = undefined; }); - } else { - console.log(`GIGEL: pairing with seedless token failed`); - // ignore the error } - } catch (error) { - console.error('GIGEL: Failed to pair identifiers:', error); - // ignore the error } finally { + console.log(`pairing attempt done`); this.update((state) => { state.pairingInProgress = false; }); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index 27f44d30b76..dab552543f6 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -288,7 +288,7 @@ export type UserStorageControllerMessenger = RestrictedMessenger< * * NOTE: * - data stored on UserStorage is FULLY encrypted, with the only keys stored/managed on the client. - * - No one can access this data unless they are have the SRP and are able to run the signing snap. + * - No one can access this data unless they have the SRP and are able to run the signing snap. */ export default class UserStorageController extends BaseController< typeof controllerName, 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 e806b05da68..65c2b7ef7c3 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 @@ -217,27 +217,16 @@ export class SRPJwtBearerAuth implements IBaseAuth { } async pairSocialIdentifier(jwt: string): Promise { - console.log( - `GIGEL: pairing primary SRP with social token ${jwt}`, - ); - - // TODO: need to hardcode the env as web3auth prod is not available. - // const { env, platform } = this.#config; - const { platform } = this.#config; - const env = Env.DEV; + // TODO: We need to sync the ENV this library is using with the build type of the client, otherwise the pairing will fail because the clients will use the auth server corresponding to the build type but the library hardcodes to PRD. + const { env, platform } = this.#config; // Exchange the social token with an access token - console.log(`GIGEL: exchanging social token for access token`); const tokenResponse = await authorizeOIDC(jwt, env, platform); - console.log(`GIGEL: obtained access token ${tokenResponse.accessToken}`); // Prepare the SRP signature const identifier = await this.getIdentifier(); - const profile = await this.getUserProfile(); - const n = await getNonce(profile.profileId, env); - console.log( - `GIGEL: pairing social token with profile ${profile.profileId} with nonce ${n.nonce}`, - ); + // TODO: should we get the nonce for the profileID instead of for the identifier? + const n = await getNonce(identifier, env); const raw = `metamask:${n.nonce}:${identifier}`; const sig = await this.signMessage(raw); const primaryIdentifierSignature = { @@ -257,25 +246,15 @@ export class SRPJwtBearerAuth implements IBaseAuth { 'Content-Type': 'application/json', Authorization: `Bearer ${tokenResponse.accessToken}`, }, - body: JSON.stringify({ - nonce: n.nonce, - login: primaryIdentifierSignature, - }), + body: JSON.stringify({ login: primaryIdentifierSignature }), }); - if (!response.ok) { - const responseBody = (await response.json()) as ErrorMessage; - throw new Error( - `HTTP error message: ${responseBody.message}, error: ${responseBody.error}`, - ); - } + return response.ok; } catch (e) { /* istanbul ignore next */ const errorMessage = e instanceof Error ? e.message : JSON.stringify(e ?? ''); throw new PairError(`unable to pair identifiers: ${errorMessage}`); } - - return false; } } 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 57029ef310e..8f069a77099 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 @@ -164,35 +164,30 @@ export async function authorizeOIDC( urlEncodedBody.append('client_id', getOidcClientId(env, platform)); urlEncodedBody.append('assertion', jwtToken); - console.log(`GIGEL [identity auth] requesting OIDC token with grant_type: ${grantType}, client_id: ${getOidcClientId(env, platform)} from ${OIDC_TOKEN_URL(env)} using jwtToken: ${jwtToken}`); try { const response = await fetch(OIDC_TOKEN_URL(env), { method: 'POST', headers, body: urlEncodedBody.toString(), }); - console.log(`GIGEL [identity auth] OIDC token response status: ${response.status}`); if (!response.ok) { const responseBody = (await response.json()) as { error_description: string; error: string; }; - console.error(`GIGEL [identity auth] OIDC token error response: ${JSON.stringify(responseBody)}`); throw new Error( `HTTP error: ${responseBody.error_description}, error code: ${responseBody.error}`, ); } const accessTokenResponse = await response.json(); - console.log(`GIGEL [identity auth] OIDC token response: ${JSON.stringify(accessTokenResponse)}`); return { accessToken: accessTokenResponse.access_token, expiresIn: accessTokenResponse.expires_in, obtainedAt: Date.now(), }; } catch (e) { - console.error(`GIGEL [identity auth] OIDC token request failed: ${e as Error}`); /* istanbul ignore next */ const errorMessage = e instanceof Error ? e.message : JSON.stringify(e ?? ''); diff --git a/packages/profile-sync-controller/tsconfig.build.json b/packages/profile-sync-controller/tsconfig.build.json index a80d95226b7..005f6415539 100644 --- a/packages/profile-sync-controller/tsconfig.build.json +++ b/packages/profile-sync-controller/tsconfig.build.json @@ -9,6 +9,7 @@ "references": [ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../seedless-onboarding-controller/tsconfig.build.json" }, { "path": "../accounts-controller/tsconfig.build.json" }, { "path": "../address-book-controller/tsconfig.build.json" } ], diff --git a/yarn.lock b/yarn.lock index 0725bbac92d..9a2a7c8ca9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4221,6 +4221,7 @@ __metadata: "@metamask/accounts-controller": ^31.0.0 "@metamask/keyring-controller": ^22.0.0 "@metamask/providers": ^22.0.0 + "@metamask/seedless-onboarding-controller": ^2.1.0 "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown From dd76bad2510f882ff510f33e43b3b3a225496080 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 15 Jul 2025 15:23:12 +0200 Subject: [PATCH 04/16] docs(profile-sync-controller): update the changelog --- packages/profile-sync-controller/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index afdda630926..d6ad54ab734 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING** Automatically pair the SeedlessOnboarding profileID with the SRP based profileID ([#6048](https://github.com/MetaMask/core/pull/6048)) + - this adds `@metamask/seedless-onboarding-controller` as a peer dependency and requires clients to change their initialization of the controllers to allow `SeedlessOnboardingControllerGetStateAction` as well as forward the build type to the `config.env` in the controller constructors. + ## [21.0.0] ### Added From 4dd71551ad503546063e063caa2d664c7da0a8be Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 15 Jul 2025 15:32:18 +0200 Subject: [PATCH 05/16] chore(profile-sync-controller): fix new linter warnings --- .../src/sdk/authentication-jwt-bearer/flow-srp.ts | 2 -- 1 file changed, 2 deletions(-) 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 65c2b7ef7c3..2a2f175f6d6 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 @@ -11,7 +11,6 @@ import type { AuthConfig, AuthSigningOptions, AuthStorageOptions, - ErrorMessage, IBaseAuth, LoginResponse, UserProfile, @@ -28,7 +27,6 @@ import { isSnapConnected, } from '../utils/messaging-signing-snap-requests'; import { validateLoginResponse } from '../utils/validate-login-response'; -import { Env } from '../../shared/env'; type JwtBearerAuth_SRP_Options = { storage: AuthStorageOptions; From b65d75c3a210fdbf37be64f1e5b73cdeb70edff9 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 15 Jul 2025 15:41:00 +0200 Subject: [PATCH 06/16] chore(profile-sync-controller): fix yarn constraints --- packages/profile-sync-controller/package.json | 1 + yarn.lock | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index f70a6c948ae..4cb42864b17 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -118,6 +118,7 @@ "@metamask/keyring-controller": "^22.1.0", "@metamask/keyring-internal-api": "^7.0.0", "@metamask/providers": "^22.1.0", + "@metamask/seedless-onboarding-controller": "^2.1.0", "@metamask/snaps-controllers": "^14.0.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/yarn.lock b/yarn.lock index 9a2a7c8ca9a..aee2c6466cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4198,6 +4198,7 @@ __metadata: "@metamask/keyring-controller": "npm:^22.1.0" "@metamask/keyring-internal-api": "npm:^7.0.0" "@metamask/providers": "npm:^22.1.0" + "@metamask/seedless-onboarding-controller": "npm:^2.1.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" @@ -4336,7 +4337,7 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": +"@metamask/seedless-onboarding-controller@npm:^2.1.0, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" dependencies: From bdf76dc88c39317249b21457be23e9b9b46b4e71 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 15 Jul 2025 16:47:49 +0200 Subject: [PATCH 07/16] test(profile-sync-controller): test new pairing functionality --- .../AuthenticationController.test.ts | 182 +++++++++++++++++- .../src/sdk/__fixtures__/auth.ts | 16 ++ .../src/sdk/authentication.test.ts | 55 ++++++ .../src/sdk/mocks/auth.ts | 2 + 4 files changed, 249 insertions(+), 6 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts index 1c25ce90a41..8d955b3518d 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -1,15 +1,12 @@ import { Messenger } from '@metamask/base-controller'; -import AuthenticationController from './AuthenticationController'; import type { AllowedActions, AllowedEvents, AuthenticationControllerState, } from './AuthenticationController'; -import { - MOCK_LOGIN_RESPONSE, - MOCK_OATH_TOKEN_RESPONSE, -} from './mocks/mockResponses'; +import AuthenticationController from './AuthenticationController'; +import { MOCK_LOGIN_RESPONSE, MOCK_OATH_TOKEN_RESPONSE } from './mocks'; import type { LoginResponse } from '../../sdk'; import { Platform } from '../../sdk'; import { arrangeAuthAPIs } from '../../sdk/__fixtures__/auth'; @@ -54,6 +51,8 @@ describe('authentication/authentication-controller - constructor() tests', () => expect(controller.state.isSignedIn).toBe(false); expect(controller.state.srpSessionData).toBeUndefined(); + expect(controller.state.socialPairingDone).toBeUndefined(); + expect(controller.state.pairingInProgress).toBeUndefined(); }); it('should initialize with override state', () => { @@ -204,19 +203,181 @@ describe('authentication/authentication-controller - performSignIn() tests', () } }); +describe('authentication/authentication-controller - performSignIn() with pairing functionality', () => { + it('triggers social pairing when social token is available', async () => { + const metametrics = createMockAuthMetaMetrics(); + const mockEndpoints = arrangeAuthAPIs(); + const { messenger, mockSeedlessOnboardingGetState } = + createMockAuthenticationMessenger(); + + // Mock social token is available + mockSeedlessOnboardingGetState.mockReturnValue({ + accessToken: 'MOCK_SOCIAL_TOKEN', + }); + + const controller = new AuthenticationController({ messenger, metametrics }); + + const result = await controller.performSignIn(); + + // Verify sign-in still works normally + expect(result).toStrictEqual([ + MOCK_OATH_TOKEN_RESPONSE.access_token, + MOCK_OATH_TOKEN_RESPONSE.access_token, + ]); + + // Verify SeedlessOnboardingController was called + expect(mockSeedlessOnboardingGetState).toHaveBeenCalled(); + + // Note: Pairing happens asynchronously, so we need to wait longer + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Should have attempted pairing + expect(controller.state.isSignedIn).toBe(true); + expect(controller.state.socialPairingDone).toBe(true); + expect(controller.state.pairingInProgress).toBe(false); + mockEndpoints.mockNonceUrl.done(); + mockEndpoints.mockSrpLoginUrl.done(); + mockEndpoints.mockOAuth2TokenUrl.done(); + mockEndpoints.mockPairSocialIdentifierUrl.done(); + }); + + it('does not attempt pairing when social token is unavailable', async () => { + const mockEndpoints = arrangeAuthAPIs(); + const { messenger, mockSeedlessOnboardingGetState } = + createMockAuthenticationMessenger(); + + mockSeedlessOnboardingGetState.mockReturnValue({ + accessToken: null, + }); + + const controller = new AuthenticationController({ + messenger, + metametrics: createMockAuthMetaMetrics(), + }); + + await controller.performSignIn(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(controller.state.socialPairingDone).toBeUndefined(); + expect(controller.state.pairingInProgress).toBeUndefined(); + expect(mockEndpoints.mockPairSocialIdentifierUrl.isDone()).toBe(false); + mockEndpoints.mockNonceUrl.done(); + mockEndpoints.mockSrpLoginUrl.done(); + mockEndpoints.mockOAuth2TokenUrl.done(); + }); + + it('does not attempt pairing when already done', async () => { + const mockEndpoints = arrangeAuthAPIs(); + const { messenger, mockSeedlessOnboardingGetState } = + createMockAuthenticationMessenger(); + + mockSeedlessOnboardingGetState.mockReturnValue({ + accessToken: 'MOCK_SOCIAL_TOKEN', + }); + + const controller = new AuthenticationController({ + messenger, + metametrics: createMockAuthMetaMetrics(), + state: { + isSignedIn: false, + socialPairingDone: true, + }, + }); + + await controller.performSignIn(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should not update state since pairing was already done + expect(controller.state.socialPairingDone).toBe(true); + expect(controller.state.pairingInProgress).toBeUndefined(); + expect(mockEndpoints.mockPairSocialIdentifierUrl.isDone()).toBe(false); + mockEndpoints.mockNonceUrl.done(); + mockEndpoints.mockSrpLoginUrl.done(); + mockEndpoints.mockOAuth2TokenUrl.done(); + }); + + it('does not attempt pairing when pairing is already in progress', async () => { + const mockEndpoints = arrangeAuthAPIs(); + const { messenger, mockSeedlessOnboardingGetState } = + createMockAuthenticationMessenger(); + + mockSeedlessOnboardingGetState.mockReturnValue({ + accessToken: 'MOCK_SOCIAL_TOKEN', + }); + + const controller = new AuthenticationController({ + messenger, + metametrics: createMockAuthMetaMetrics(), + state: { + isSignedIn: false, + pairingInProgress: true, + }, + }); + + await controller.performSignIn(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should not change pairing state since it was already in progress + expect(controller.state.pairingInProgress).toBe(true); + expect(mockEndpoints.mockPairSocialIdentifierUrl.isDone()).toBe(false); + mockEndpoints.mockNonceUrl.done(); + mockEndpoints.mockSrpLoginUrl.done(); + mockEndpoints.mockOAuth2TokenUrl.done(); + }); + + it('handles pairing failures gracefully', async () => { + const metametrics = createMockAuthMetaMetrics(); + const mockEndpoints = arrangeAuthAPIs({ + mockPairSocialIdentifier: { status: 400 }, + }); + const { messenger, mockSeedlessOnboardingGetState } = + createMockAuthenticationMessenger(); + + mockSeedlessOnboardingGetState.mockReturnValue({ + accessToken: 'MOCK_SOCIAL_TOKEN', + }); + + const controller = new AuthenticationController({ messenger, metametrics }); + + const result = await controller.performSignIn(); + + // Sign-in should still succeed even if pairing fails + expect(result).toStrictEqual([ + MOCK_OATH_TOKEN_RESPONSE.access_token, + MOCK_OATH_TOKEN_RESPONSE.access_token, + ]); + expect(controller.state.isSignedIn).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Pairing should have been attempted but failed + expect(controller.state.socialPairingDone).toBeUndefined(); + expect(controller.state.pairingInProgress).toBe(false); + mockEndpoints.mockNonceUrl.done(); + mockEndpoints.mockSrpLoginUrl.done(); + mockEndpoints.mockOAuth2TokenUrl.done(); + mockEndpoints.mockPairSocialIdentifierUrl.done(); + }); +}); + describe('authentication/authentication-controller - performSignOut() tests', () => { it('should remove signed in user and any access tokens', () => { const metametrics = createMockAuthMetaMetrics(); const { messenger } = createMockAuthenticationMessenger(); const controller = new AuthenticationController({ messenger, - state: mockSignedInState(), + state: { + ...mockSignedInState(), + socialPairingDone: true, + pairingInProgress: false, + }, metametrics, }); controller.performSignOut(); expect(controller.state.isSignedIn).toBe(false); expect(controller.state.srpSessionData).toBeUndefined(); + expect(controller.state.socialPairingDone).toBe(false); }); }); @@ -555,6 +716,10 @@ function createMockAuthenticationMessenger() { .fn() .mockReturnValue({ isUnlocked: true }); + const mockSeedlessOnboardingGetState = jest.fn().mockReturnValue({ + accessToken: 'MOCK_SOCIAL_TOKEN', + }); + mockCall.mockImplementation((...args) => { const [actionType, params] = args; if (actionType === 'SnapController:handleRequest') { @@ -581,6 +746,10 @@ function createMockAuthenticationMessenger() { return mockKeyringControllerGetState(); } + if (actionType === 'SeedlessOnboardingController:getState') { + return mockSeedlessOnboardingGetState(); + } + throw new Error( `MOCK_FAIL - unsupported messenger call: ${actionType as string}`, ); @@ -593,6 +762,7 @@ function createMockAuthenticationMessenger() { mockSnapGetAllPublicKeys, mockSnapSignMessage, mockKeyringControllerGetState, + mockSeedlessOnboardingGetState, }; } diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts index 12d1a90c68d..7b7e6360d69 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts @@ -6,6 +6,7 @@ import { MOCK_OIDC_TOKEN_RESPONSE, MOCK_OIDC_TOKEN_URL, MOCK_PAIR_IDENTIFIERS_URL, + MOCK_PAIR_SOCIAL_IDENTIFIER_URL, MOCK_PROFILE_METAMETRICS_URL, MOCK_SIWE_LOGIN_RESPONSE, MOCK_SIWE_LOGIN_URL, @@ -51,6 +52,16 @@ export const handleMockPairIdentifiers = (mockReply?: MockReply) => { return mockPairIdentifiersEndpoint; }; +export const handleMockPairSocialIdentifier = (mockReply?: MockReply) => { + const reply = mockReply ?? { status: 200 }; + const mockPairSocialIdentifierEndpoint = nock(MOCK_PAIR_SOCIAL_IDENTIFIER_URL) + .persist() + .post('') + .reply(reply.status, reply.body); + + return mockPairSocialIdentifierEndpoint; +}; + export const handleMockSrpLogin = (mockReply?: MockReply) => { const reply = mockReply ?? { status: 200, body: MOCK_SRP_LOGIN_RESPONSE }; const mockLoginEndpoint = nock(MOCK_SRP_LOGIN_URL) @@ -91,6 +102,7 @@ export const arrangeAuthAPIs = (options?: { mockSrpLoginUrl?: MockReply; mockSiweLoginUrl?: MockReply; mockPairIdentifiers?: MockReply; + mockPairSocialIdentifier?: MockReply; mockUserProfileMetaMetrics?: MockReply; }) => { const mockNonceUrl = handleMockNonce(options?.mockNonceUrl); @@ -100,6 +112,9 @@ export const arrangeAuthAPIs = (options?: { const mockPairIdentifiersUrl = handleMockPairIdentifiers( options?.mockPairIdentifiers, ); + const mockPairSocialIdentifierUrl = handleMockPairSocialIdentifier( + options?.mockPairSocialIdentifier, + ); const mockUserProfileMetaMetricsUrl = handleMockUserProfileMetaMetrics( options?.mockUserProfileMetaMetrics, ); @@ -110,6 +125,7 @@ export const arrangeAuthAPIs = (options?: { mockSrpLoginUrl, mockSiweLoginUrl, mockPairIdentifiersUrl, + mockPairSocialIdentifierUrl, mockUserProfileMetaMetricsUrl, }; }; diff --git a/packages/profile-sync-controller/src/sdk/authentication.test.ts b/packages/profile-sync-controller/src/sdk/authentication.test.ts index c067e174cb6..fd5df907a21 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.test.ts @@ -129,6 +129,61 @@ describe('Identifier Pairing', () => { }); }); +describe('Social Identifier Pairing', () => { + it('successfully pairs with social identifier', async () => { + const { auth } = arrangeAuth('SRP', MOCK_SRP); + const { mockNonceUrl, mockOAuth2TokenUrl, mockPairSocialIdentifierUrl } = + arrangeAuthAPIs(); + + const result = await auth.pairSocialIdentifier('MOCK_JWT_TOKEN'); + + expect(result).toBe(true); + expect(mockNonceUrl.isDone()).toBe(true); + expect(mockOAuth2TokenUrl.isDone()).toBe(true); + expect(mockPairSocialIdentifierUrl.isDone()).toBe(true); + }); + + it('handles social pairing failures', async () => { + const { auth } = arrangeAuth('SRP', MOCK_SRP); + const { mockNonceUrl, mockOAuth2TokenUrl, mockPairSocialIdentifierUrl } = + arrangeAuthAPIs({ + mockPairSocialIdentifier: { status: 400 }, + }); + + const result = await auth.pairSocialIdentifier('INVALID_TOKEN'); + + expect(result).toBe(false); + expect(mockNonceUrl.isDone()).toBe(true); + expect(mockOAuth2TokenUrl.isDone()).toBe(true); + expect(mockPairSocialIdentifierUrl.isDone()).toBe(true); + }); + + it('handles social pairing authentication failures', async () => { + const { auth } = arrangeAuth('SRP', MOCK_SRP); + const { mockOAuth2TokenUrl } = arrangeAuthAPIs({ + mockOAuth2TokenUrl: { status: 401 }, + }); + + await expect(auth.pairSocialIdentifier('INVALID_TOKEN')).rejects.toThrow( + /unable to get access token.*/u, + ); + expect(mockOAuth2TokenUrl.isDone()).toBe(true); + }); + + it('handles social pairing nonce failures', async () => { + const { auth } = arrangeAuth('SRP', MOCK_SRP); + const { mockNonceUrl, mockOAuth2TokenUrl } = arrangeAuthAPIs({ + mockNonceUrl: { status: 400 }, + }); + + await expect(auth.pairSocialIdentifier('MOCK_JWT_TOKEN')).rejects.toThrow( + /failed to generate nonce.*/u, + ); + expect(mockOAuth2TokenUrl.isDone()).toBe(true); + expect(mockNonceUrl.isDone()).toBe(true); + }); +}); + describe('Authentication - constructor()', () => { it('errors on invalid auth type', async () => { expect(() => { diff --git a/packages/profile-sync-controller/src/sdk/mocks/auth.ts b/packages/profile-sync-controller/src/sdk/mocks/auth.ts index db3c1211626..6807a0d60b5 100644 --- a/packages/profile-sync-controller/src/sdk/mocks/auth.ts +++ b/packages/profile-sync-controller/src/sdk/mocks/auth.ts @@ -5,6 +5,7 @@ import { SRP_LOGIN_URL, OIDC_TOKEN_URL, PAIR_IDENTIFIERS, + PAIR_SOCIAL_IDENTIFIER, PROFILE_METAMETRICS_URL, } from '../authentication-jwt-bearer/services'; @@ -13,6 +14,7 @@ export const MOCK_SRP_LOGIN_URL = SRP_LOGIN_URL(Env.PRD); export const MOCK_OIDC_TOKEN_URL = OIDC_TOKEN_URL(Env.PRD); export const MOCK_SIWE_LOGIN_URL = SIWE_LOGIN_URL(Env.PRD); export const MOCK_PAIR_IDENTIFIERS_URL = PAIR_IDENTIFIERS(Env.PRD); +export const MOCK_PAIR_SOCIAL_IDENTIFIER_URL = PAIR_SOCIAL_IDENTIFIER(Env.PRD); export const MOCK_PROFILE_METAMETRICS_URL = PROFILE_METAMETRICS_URL(Env.PRD); export const MOCK_JWT = From 4ba8ebf17a9eb91d3d0e7883afeb87380d76f616 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Wed, 16 Jul 2025 12:23:13 +0200 Subject: [PATCH 08/16] chore(profile-sync-controller): remove some of the TODOs --- .../controllers/authentication/AuthenticationController.ts | 4 ++-- .../src/sdk/authentication-jwt-bearer/flow-srp.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 0adabd10d1c..045b556e53a 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -318,8 +318,8 @@ export default class AuthenticationController extends BaseController< } // don't await for the pairing to finish - this.#tryPairingWithSocialToken().catch((_) => { - // don't care + this.#tryPairingWithSocialToken().catch(() => { + // no-op. failures must not interfere with the sign-in flow }); return accessTokens; 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 2a2f175f6d6..bf50cacf685 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 @@ -215,7 +215,8 @@ export class SRPJwtBearerAuth implements IBaseAuth { } async pairSocialIdentifier(jwt: string): Promise { - // TODO: We need to sync the ENV this library is using with the build type of the client, otherwise the pairing will fail because the clients will use the auth server corresponding to the build type but the library hardcodes to PRD. + // The ENV this library uses must be in sync with the build type of the + // client since that dictates which web3auth auth server is in use. const { env, platform } = this.#config; // Exchange the social token with an access token @@ -223,7 +224,6 @@ export class SRPJwtBearerAuth implements IBaseAuth { // Prepare the SRP signature const identifier = await this.getIdentifier(); - // TODO: should we get the nonce for the profileID instead of for the identifier? const n = await getNonce(identifier, env); const raw = `metamask:${n.nonce}:${identifier}`; const sig = await this.signMessage(raw); @@ -237,7 +237,6 @@ export class SRPJwtBearerAuth implements IBaseAuth { const pairUrl = new URL(PAIR_SOCIAL_IDENTIFIER(env)); try { - // TODO: this will FAIL as long as the ENV don't match. const response = await fetch(pairUrl, { method: 'POST', headers: { From cf633b87439c61d811e0082018141f3610102c89 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Wed, 16 Jul 2025 12:46:39 +0200 Subject: [PATCH 09/16] chore(profile-sync-controller): refactor pairing logic to prevent race condition --- .../AuthenticationController.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 045b556e53a..1290235a594 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -373,16 +373,28 @@ export default class AuthenticationController extends BaseController< const { accessToken: socialPairingToken } = this.messagingSystem.call( 'SeedlessOnboardingController:getState', ); - const { socialPairingDone, pairingInProgress } = this.state; - if (socialPairingDone || !socialPairingToken || pairingInProgress) { + + // Early return if no social pairing token + if (!socialPairingToken) { + return; + } + + // Atomically check and set pairingInProgress to prevent race conditions + let conditionsMet = false; + this.update((state) => { + if (state.socialPairingDone || state.pairingInProgress) { + return; + } + state.pairingInProgress = true; + conditionsMet = true; + }); + + if (!conditionsMet) { return; } try { console.log(`starting to pair with social token`); - this.update((state) => { - state.pairingInProgress = true; - }); const paired = await this.#auth.pairSocialIdentifier(socialPairingToken); console.log(`pairing with social token success=${paired}`); if (paired) { From df0a2fe27fa3b1faa78b0a41d31a227aabb28694 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Thu, 17 Jul 2025 11:34:11 +0200 Subject: [PATCH 10/16] test(profile-sync-controller): use waitFor instead of timeouts in tests --- .../AuthenticationController.test.ts | 61 ++++++++++++------- .../AuthenticationController.ts | 4 ++ .../profile-sync-controller/tsconfig.json | 3 +- 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts index 8d955b3518d..3317fa63027 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -11,6 +11,7 @@ import type { LoginResponse } from '../../sdk'; import { Platform } from '../../sdk'; import { arrangeAuthAPIs } from '../../sdk/__fixtures__/auth'; import { MOCK_USER_PROFILE_METAMETRICS_RESPONSE } from '../../sdk/mocks/auth'; +import { waitFor } from '../user-storage/__fixtures__/test-utils'; const MOCK_ENTROPY_SOURCE_IDS = [ 'MOCK_ENTROPY_SOURCE_ID', @@ -228,13 +229,12 @@ describe('authentication/authentication-controller - performSignIn() with pairin // Verify SeedlessOnboardingController was called expect(mockSeedlessOnboardingGetState).toHaveBeenCalled(); - // Note: Pairing happens asynchronously, so we need to wait longer - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Should have attempted pairing - expect(controller.state.isSignedIn).toBe(true); - expect(controller.state.socialPairingDone).toBe(true); - expect(controller.state.pairingInProgress).toBe(false); + // Wait for pairing to complete asynchronously + await waitFor(() => { + expect(controller.state.isSignedIn).toBe(true); + expect(controller.state.socialPairingDone).toBe(true); + expect(controller.state.pairingInProgress).toBe(false); + }); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); mockEndpoints.mockOAuth2TokenUrl.done(); @@ -256,10 +256,16 @@ describe('authentication/authentication-controller - performSignIn() with pairin }); await controller.performSignIn(); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(controller.state.socialPairingDone).toBeUndefined(); - expect(controller.state.pairingInProgress).toBeUndefined(); + // Wait a moment to ensure no pairing state changes occur + await waitFor( + () => { + expect(controller.state.isSignedIn).toBe(true); + expect(controller.state.socialPairingDone).toBe(false); + expect(controller.state.pairingInProgress).toBeUndefined(); + }, + { timeoutMs: 500 }, + ); expect(mockEndpoints.mockPairSocialIdentifierUrl.isDone()).toBe(false); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); @@ -285,11 +291,16 @@ describe('authentication/authentication-controller - performSignIn() with pairin }); await controller.performSignIn(); - await new Promise((resolve) => setTimeout(resolve, 50)); - // Should not update state since pairing was already done - expect(controller.state.socialPairingDone).toBe(true); - expect(controller.state.pairingInProgress).toBeUndefined(); + // Wait to ensure the state remains unchanged since pairing was already done + await waitFor( + () => { + expect(controller.state.isSignedIn).toBe(true); + expect(controller.state.socialPairingDone).toBe(true); + expect(controller.state.pairingInProgress).toBeUndefined(); + }, + { timeoutMs: 500 }, + ); expect(mockEndpoints.mockPairSocialIdentifierUrl.isDone()).toBe(false); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); @@ -315,10 +326,15 @@ describe('authentication/authentication-controller - performSignIn() with pairin }); await controller.performSignIn(); - await new Promise((resolve) => setTimeout(resolve, 50)); - // Should not change pairing state since it was already in progress - expect(controller.state.pairingInProgress).toBe(true); + // Wait to ensure pairing state remains unchanged since it was already in progress + await waitFor( + () => { + expect(controller.state.isSignedIn).toBe(true); + expect(controller.state.pairingInProgress).toBe(true); + }, + { timeoutMs: 500 }, + ); expect(mockEndpoints.mockPairSocialIdentifierUrl.isDone()).toBe(false); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); @@ -348,11 +364,12 @@ describe('authentication/authentication-controller - performSignIn() with pairin ]); expect(controller.state.isSignedIn).toBe(true); - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Pairing should have been attempted but failed - expect(controller.state.socialPairingDone).toBeUndefined(); - expect(controller.state.pairingInProgress).toBe(false); + // Wait for the pairing attempt to complete (and fail) + await waitFor(() => { + expect(controller.state.isSignedIn).toBe(true); + expect(controller.state.socialPairingDone).toBeUndefined(); + expect(controller.state.pairingInProgress).toBe(false); + }); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); mockEndpoints.mockOAuth2TokenUrl.done(); diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 1290235a594..d64b48e26a4 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -376,6 +376,10 @@ export default class AuthenticationController extends BaseController< // Early return if no social pairing token if (!socialPairingToken) { + this.update((state) => { + // set this to false when undefined to signal that an attempt was made. + state.socialPairingDone = state.socialPairingDone ?? false; + }); return; } diff --git a/packages/profile-sync-controller/tsconfig.json b/packages/profile-sync-controller/tsconfig.json index fa469473e1f..8b42c44a4bc 100644 --- a/packages/profile-sync-controller/tsconfig.json +++ b/packages/profile-sync-controller/tsconfig.json @@ -7,7 +7,8 @@ { "path": "../base-controller" }, { "path": "../keyring-controller" }, { "path": "../accounts-controller" }, - { "path": "../address-book-controller" } + { "path": "../address-book-controller" }, + { "path": "../seedless-onboarding-controller" } ], "include": ["../../types", "./src"] } From 3166dbfe62d6c008ece0bb56f8b75c61c0ebb61d Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Thu, 17 Jul 2025 12:16:37 +0200 Subject: [PATCH 11/16] test(profile-sync-controller): define mocked constants and enforce single call to pairing endpoint --- .../AuthenticationController.test.ts | 74 ++++++++++++++++--- .../src/sdk/__fixtures__/auth.ts | 1 - .../src/sdk/mocks/auth.ts | 9 ++- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts index 3317fa63027..b23c12c5a68 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -10,7 +10,12 @@ import { MOCK_LOGIN_RESPONSE, MOCK_OATH_TOKEN_RESPONSE } from './mocks'; import type { LoginResponse } from '../../sdk'; import { Platform } from '../../sdk'; import { arrangeAuthAPIs } from '../../sdk/__fixtures__/auth'; -import { MOCK_USER_PROFILE_METAMETRICS_RESPONSE } from '../../sdk/mocks/auth'; +import { + MOCK_PUBLIC_KEY, + MOCK_SIGNED_MESSAGE, + MOCK_SOCIAL_TOKEN, + MOCK_USER_PROFILE_METAMETRICS_RESPONSE, +} from '../../sdk/mocks/auth'; import { waitFor } from '../user-storage/__fixtures__/test-utils'; const MOCK_ENTROPY_SOURCE_IDS = [ @@ -213,7 +218,7 @@ describe('authentication/authentication-controller - performSignIn() with pairin // Mock social token is available mockSeedlessOnboardingGetState.mockReturnValue({ - accessToken: 'MOCK_SOCIAL_TOKEN', + accessToken: MOCK_SOCIAL_TOKEN, }); const controller = new AuthenticationController({ messenger, metametrics }); @@ -278,7 +283,7 @@ describe('authentication/authentication-controller - performSignIn() with pairin createMockAuthenticationMessenger(); mockSeedlessOnboardingGetState.mockReturnValue({ - accessToken: 'MOCK_SOCIAL_TOKEN', + accessToken: MOCK_SOCIAL_TOKEN, }); const controller = new AuthenticationController({ @@ -313,7 +318,7 @@ describe('authentication/authentication-controller - performSignIn() with pairin createMockAuthenticationMessenger(); mockSeedlessOnboardingGetState.mockReturnValue({ - accessToken: 'MOCK_SOCIAL_TOKEN', + accessToken: MOCK_SOCIAL_TOKEN, }); const controller = new AuthenticationController({ @@ -350,7 +355,7 @@ describe('authentication/authentication-controller - performSignIn() with pairin createMockAuthenticationMessenger(); mockSeedlessOnboardingGetState.mockReturnValue({ - accessToken: 'MOCK_SOCIAL_TOKEN', + accessToken: MOCK_SOCIAL_TOKEN, }); const controller = new AuthenticationController({ messenger, metametrics }); @@ -375,6 +380,53 @@ describe('authentication/authentication-controller - performSignIn() with pairin mockEndpoints.mockOAuth2TokenUrl.done(); mockEndpoints.mockPairSocialIdentifierUrl.done(); }); + + it('calls pairing endpoint only once when performSignIn is called 10 times in parallel', async () => { + const metametrics = createMockAuthMetaMetrics(); + const mockEndpoints = arrangeAuthAPIs(); + const { messenger, mockSeedlessOnboardingGetState } = + createMockAuthenticationMessenger(); + + // Mock social token is available + mockSeedlessOnboardingGetState.mockReturnValue({ + accessToken: MOCK_SOCIAL_TOKEN, + }); + + const controller = new AuthenticationController({ messenger, metametrics }); + const requestCounter = jest.fn(); + mockEndpoints.mockPairSocialIdentifierUrl.on('request', requestCounter) + + // Call performSignIn 10 times in parallel + const signInPromises = Array.from({ length: 10 }, () => + controller.performSignIn(), + ); + + const results = await Promise.all(signInPromises); + + // Verify all sign-ins succeeded + results.forEach((result) => { + expect(result).toStrictEqual([ + MOCK_OATH_TOKEN_RESPONSE.access_token, + MOCK_OATH_TOKEN_RESPONSE.access_token, + ]); + }); + + // Wait for pairing to complete + await waitFor(() => { + expect(controller.state.isSignedIn).toBe(true); + expect(controller.state.socialPairingDone).toBe(true); + expect(controller.state.pairingInProgress).toBe(false); + }); + + // Verify pairing endpoint was called exactly once + expect(mockEndpoints.mockPairSocialIdentifierUrl.isDone()).toBe(true); + expect(requestCounter).toHaveBeenCalledTimes(1); + + // Clean up other endpoints (they could have been called multiple times) + mockEndpoints.mockNonceUrl.done(); + mockEndpoints.mockSrpLoginUrl.done(); + mockEndpoints.mockOAuth2TokenUrl.done(); + }); }); describe('authentication/authentication-controller - performSignOut() tests', () => { @@ -574,7 +626,7 @@ describe('authentication/authentication-controller - getSessionProfile() tests', }); // If the state is invalid, we need to re-login. - // But as wallet is locked, we will not be able to call the snap + // But as the wallet is locked, we will not be able to call the snap it('should throw error if wallet is locked', async () => { const metametrics = createMockAuthMetaMetrics(); const { messenger, mockKeyringControllerGetState } = @@ -719,22 +771,20 @@ function createMockAuthenticationMessenger() { const { baseMessenger, messenger } = createAuthenticationMessenger(); const mockCall = jest.spyOn(messenger, 'call'); - const mockSnapGetPublicKey = jest.fn().mockResolvedValue('MOCK_PUBLIC_KEY'); + const mockSnapGetPublicKey = jest.fn().mockResolvedValue(MOCK_PUBLIC_KEY); const mockSnapGetAllPublicKeys = jest .fn() .mockResolvedValue( - MOCK_ENTROPY_SOURCE_IDS.map((id) => [id, 'MOCK_PUBLIC_KEY']), + MOCK_ENTROPY_SOURCE_IDS.map((id) => [id, MOCK_PUBLIC_KEY]), ); - const mockSnapSignMessage = jest - .fn() - .mockResolvedValue('MOCK_SIGNED_MESSAGE'); + const mockSnapSignMessage = jest.fn().mockResolvedValue(MOCK_SIGNED_MESSAGE); const mockKeyringControllerGetState = jest .fn() .mockReturnValue({ isUnlocked: true }); const mockSeedlessOnboardingGetState = jest.fn().mockReturnValue({ - accessToken: 'MOCK_SOCIAL_TOKEN', + accessToken: MOCK_SOCIAL_TOKEN, }); mockCall.mockImplementation((...args) => { diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts index 7b7e6360d69..243476cd9ae 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts @@ -55,7 +55,6 @@ export const handleMockPairIdentifiers = (mockReply?: MockReply) => { export const handleMockPairSocialIdentifier = (mockReply?: MockReply) => { const reply = mockReply ?? { status: 200 }; const mockPairSocialIdentifierEndpoint = nock(MOCK_PAIR_SOCIAL_IDENTIFIER_URL) - .persist() .post('') .reply(reply.status, reply.body); diff --git a/packages/profile-sync-controller/src/sdk/mocks/auth.ts b/packages/profile-sync-controller/src/sdk/mocks/auth.ts index 6807a0d60b5..06ce635310c 100644 --- a/packages/profile-sync-controller/src/sdk/mocks/auth.ts +++ b/packages/profile-sync-controller/src/sdk/mocks/auth.ts @@ -23,9 +23,14 @@ export const MOCK_JWT = export const MOCK_ACCESS_JWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; +export const MOCK_PUBLIC_KEY = 'MOCK_PUBLIC_KEY'; +export const MOCK_SOCIAL_TOKEN = 'MOCK_SOCIAL_TOKEN'; +export const MOCK_NONCE = 'xGMm9SoihEKeAEfV'; +export const MOCK_SIGNED_MESSAGE = `metamask:${MOCK_NONCE}:${MOCK_PUBLIC_KEY}`; + export const MOCK_NONCE_RESPONSE = { - nonce: 'xGMm9SoihEKeAEfV', - identifier: '0xd8641601Cb79a94FD872fE42d5b4a067A44a7e88', + nonce: MOCK_NONCE, + identifier: MOCK_PUBLIC_KEY, expires_in: 300, }; From 6a168f59e473597605f6bccb69a81e8b0a499ce3 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Thu, 17 Jul 2025 12:56:06 +0200 Subject: [PATCH 12/16] test(profile-sync-controller): prevent sign-out race condition --- .../AuthenticationController.test.ts | 44 ++++++++++++++++++- .../AuthenticationController.ts | 5 ++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts index b23c12c5a68..b45caa11d58 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -393,8 +393,8 @@ describe('authentication/authentication-controller - performSignIn() with pairin }); const controller = new AuthenticationController({ messenger, metametrics }); - const requestCounter = jest.fn(); - mockEndpoints.mockPairSocialIdentifierUrl.on('request', requestCounter) + const requestCounter = jest.fn(); + mockEndpoints.mockPairSocialIdentifierUrl.on('request', requestCounter); // Call performSignIn 10 times in parallel const signInPromises = Array.from({ length: 10 }, () => @@ -448,6 +448,46 @@ describe('authentication/authentication-controller - performSignOut() tests', () expect(controller.state.srpSessionData).toBeUndefined(); expect(controller.state.socialPairingDone).toBe(false); }); + + it('prevents race condition where async pairing could set socialPairingDone to true after sign-out', async () => { + const metametrics = createMockAuthMetaMetrics(); + const mockEndpoints = arrangeAuthAPIs(); + const { messenger, mockSeedlessOnboardingGetState } = + createMockAuthenticationMessenger(); + + // Ensure social token is available for pairing + mockSeedlessOnboardingGetState.mockReturnValue({ + accessToken: MOCK_SOCIAL_TOKEN, + }); + + const controller = new AuthenticationController({ messenger, metametrics }); + + // Start sign-in which triggers async pairing + await controller.performSignIn(); + // Immediately sign out before async pairing completes + controller.performSignOut(); + + // Verify initial sign-out state + expect(controller.state.isSignedIn).toBe(false); + expect(controller.state.socialPairingDone).toBe(false); + expect(controller.state.srpSessionData).toBeUndefined(); + + // Wait a bit for the async pairing operation to complete + await waitFor(() => { + expect(controller.state.pairingInProgress).toBe(false); + }); + + // Verify that socialPairingDone remains false after sign-out + expect(controller.state.isSignedIn).toBe(false); + expect(controller.state.socialPairingDone).toBe(false); + expect(controller.state.srpSessionData).toBeUndefined(); + expect(controller.state.pairingInProgress).toBe(false); + + mockEndpoints.mockNonceUrl.done(); + mockEndpoints.mockSrpLoginUrl.done(); + mockEndpoints.mockOAuth2TokenUrl.done(); + mockEndpoints.mockPairSocialIdentifierUrl.done(); + }); }); describe('authentication/authentication-controller - getBearerToken() tests', () => { diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index d64b48e26a4..59121027da9 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -403,7 +403,10 @@ export default class AuthenticationController extends BaseController< console.log(`pairing with social token success=${paired}`); if (paired) { this.update((state) => { - state.socialPairingDone = true; + // Prevents a race condition when sign-out is performed before pairing completes + if (state.isSignedIn) { + state.socialPairingDone = true; + } }); } } finally { From 3713f9f909e00daf729cf0bb5d5dbd900bb149bd Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 18 Jul 2025 13:18:40 +0200 Subject: [PATCH 13/16] chore(profile-sync-controller): remove console.log from social pairing flow --- .../src/controllers/authentication/AuthenticationController.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 59121027da9..93f36e4c649 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -398,9 +398,7 @@ export default class AuthenticationController extends BaseController< } try { - console.log(`starting to pair with social token`); const paired = await this.#auth.pairSocialIdentifier(socialPairingToken); - console.log(`pairing with social token success=${paired}`); if (paired) { this.update((state) => { // Prevents a race condition when sign-out is performed before pairing completes @@ -410,7 +408,6 @@ export default class AuthenticationController extends BaseController< }); } } finally { - console.log(`pairing attempt done`); this.update((state) => { state.pairingInProgress = false; }); From 91d59c30a37e2416a328775e9e3a56e28bcfd6ce Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 18 Jul 2025 13:24:22 +0200 Subject: [PATCH 14/16] chore(profile-sync-controller): bump devdeps --- packages/profile-sync-controller/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 4cb42864b17..0be189306bf 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -118,7 +118,7 @@ "@metamask/keyring-controller": "^22.1.0", "@metamask/keyring-internal-api": "^7.0.0", "@metamask/providers": "^22.1.0", - "@metamask/seedless-onboarding-controller": "^2.1.0", + "@metamask/seedless-onboarding-controller": "^2.3.0", "@metamask/snaps-controllers": "^14.0.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/yarn.lock b/yarn.lock index aee2c6466cd..dddd46301fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4198,7 +4198,7 @@ __metadata: "@metamask/keyring-controller": "npm:^22.1.0" "@metamask/keyring-internal-api": "npm:^7.0.0" "@metamask/providers": "npm:^22.1.0" - "@metamask/seedless-onboarding-controller": "npm:^2.1.0" + "@metamask/seedless-onboarding-controller": "npm:^2.3.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" @@ -4337,7 +4337,7 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@npm:^2.1.0, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": +"@metamask/seedless-onboarding-controller@npm:^2.3.0, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" dependencies: From 0e69bd0789c5fc771dcaa1eab3079f6d935959ba Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 21 Jul 2025 13:34:49 +0200 Subject: [PATCH 15/16] test(profile-sync-controller): allow Env customization for exported mocks --- .../authentication/mocks/mockResponses.ts | 40 +++++++++++++------ .../user-storage/mocks/mockResponses.ts | 5 ++- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/mocks/mockResponses.ts b/packages/profile-sync-controller/src/controllers/authentication/mocks/mockResponses.ts index 080f89d725a..3e3cffa3301 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/mocks/mockResponses.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/mocks/mockResponses.ts @@ -1,26 +1,31 @@ import { - MOCK_NONCE_RESPONSE as SDK_MOCK_NONCE_RESPONSE, + NONCE_URL, + OIDC_TOKEN_URL, + PAIR_SOCIAL_IDENTIFIER, + SRP_LOGIN_URL, +} from '../../../sdk/authentication-jwt-bearer/services'; +import { MOCK_JWT as SDK_MOCK_JWT, - MOCK_SRP_LOGIN_RESPONSE as SDK_MOCK_SRP_LOGIN_RESPONSE, + MOCK_NONCE_RESPONSE as SDK_MOCK_NONCE_RESPONSE, MOCK_OIDC_TOKEN_RESPONSE as SDK_MOCK_OIDC_TOKEN_RESPONSE, - MOCK_NONCE_URL, - MOCK_SRP_LOGIN_URL, - MOCK_OIDC_TOKEN_URL, + MOCK_SRP_LOGIN_RESPONSE as SDK_MOCK_SRP_LOGIN_RESPONSE, } from '../../../sdk/mocks/auth'; +import { Env } from '../../../shared/env'; type MockResponse = { url: string; requestMethod: 'GET' | 'POST' | 'PUT'; response: unknown; + statusCode?: number; }; export const MOCK_NONCE_RESPONSE = SDK_MOCK_NONCE_RESPONSE; export const MOCK_NONCE = MOCK_NONCE_RESPONSE.nonce; export const MOCK_JWT = SDK_MOCK_JWT; -export const getMockAuthNonceResponse = () => { +export const getMockAuthNonceResponse = (env: Env = Env.PRD) => { return { - url: MOCK_NONCE_URL, + url: NONCE_URL(env), requestMethod: 'GET', response: ( _?: unknown, @@ -34,7 +39,7 @@ export const getMockAuthNonceResponse = () => { return { ...MOCK_NONCE_RESPONSE, - nonce: e2eIdentifier ?? MOCK_NONCE_RESPONSE.nonce, + nonce: e2eIdentifier ?? MOCK_NONCE, identifier: MOCK_NONCE_RESPONSE.identifier, }; }, @@ -43,9 +48,9 @@ export const getMockAuthNonceResponse = () => { export const MOCK_LOGIN_RESPONSE = SDK_MOCK_SRP_LOGIN_RESPONSE; -export const getMockAuthLoginResponse = () => { +export const getMockAuthLoginResponse = (env: Env = Env.PRD) => { return { - url: MOCK_SRP_LOGIN_URL, + url: SRP_LOGIN_URL(env), requestMethod: 'POST', // In case this mock is used in an E2E test, we populate token, profile_id and identifier_id with the e2eIdentifier // to make it easier to segregate data in the test environment. @@ -69,9 +74,9 @@ export const getMockAuthLoginResponse = () => { export const MOCK_OATH_TOKEN_RESPONSE = SDK_MOCK_OIDC_TOKEN_RESPONSE; -export const getMockAuthAccessTokenResponse = () => { +export const getMockAuthAccessTokenResponse = (env: Env = Env.PRD) => { return { - url: MOCK_OIDC_TOKEN_URL, + url: OIDC_TOKEN_URL(env), requestMethod: 'POST', response: (requestJsonBody?: string) => { // We end up setting the access token to the e2eIdentifier in the test environment @@ -88,3 +93,14 @@ export const getMockAuthAccessTokenResponse = () => { }, } satisfies MockResponse; }; + +export const getMockPairSocialTokenResponse = (env: Env = Env.PRD) => { + return { + url: PAIR_SOCIAL_IDENTIFIER(env), + requestMethod: 'POST', + response: () => { + return 'OK'; + }, + statusCode: 204, + } satisfies MockResponse; +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/mocks/mockResponses.ts b/packages/profile-sync-controller/src/controllers/user-storage/mocks/mockResponses.ts index 239bff047b8..d7a93027f56 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/mocks/mockResponses.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/mocks/mockResponses.ts @@ -27,12 +27,13 @@ export const getMockUserStorageEndpoint = ( path: | UserStorageGenericPathWithFeatureAndKey | UserStorageGenericPathWithFeatureOnly, + env: Env = Env.PRD, ) => { if (path.split('.').length === 1) { - return `${getEnvUrls(Env.PRD).userStorageApiUrl}/api/v1/userstorage/${path}`; + return `${getEnvUrls(env).userStorageApiUrl}/api/v1/userstorage/${path}`; } - return `${getEnvUrls(Env.PRD).userStorageApiUrl}/api/v1/userstorage/${createEntryPath( + return `${getEnvUrls(env).userStorageApiUrl}/api/v1/userstorage/${createEntryPath( path as UserStorageGenericPathWithFeatureAndKey, MOCK_STORAGE_KEY, )}`; From 849f6838ea0133f87bdb346c52662d495bb79c37 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 21 Jul 2025 14:26:57 +0200 Subject: [PATCH 16/16] chore(profile-sync-controller): bump peer deps --- packages/profile-sync-controller/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 0be189306bf..5a581048e02 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -136,7 +136,7 @@ "@metamask/accounts-controller": "^31.0.0", "@metamask/keyring-controller": "^22.0.0", "@metamask/providers": "^22.0.0", - "@metamask/seedless-onboarding-controller": "^2.1.0", + "@metamask/seedless-onboarding-controller": "^2.3.0", "@metamask/snaps-controllers": "^14.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, diff --git a/yarn.lock b/yarn.lock index dddd46301fc..f4f60996dff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4222,7 +4222,7 @@ __metadata: "@metamask/accounts-controller": ^31.0.0 "@metamask/keyring-controller": ^22.0.0 "@metamask/providers": ^22.0.0 - "@metamask/seedless-onboarding-controller": ^2.1.0 + "@metamask/seedless-onboarding-controller": ^2.3.0 "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown