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 diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index ac88c6f9917..5a581048e02 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.3.0", "@metamask/snaps-controllers": "^14.0.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -135,6 +136,7 @@ "@metamask/accounts-controller": "^31.0.0", "@metamask/keyring-controller": "^22.0.0", "@metamask/providers": "^22.0.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/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts index 1c25ce90a41..b45caa11d58 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -1,19 +1,22 @@ 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'; -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 = [ 'MOCK_ENTROPY_SOURCE_ID', @@ -54,6 +57,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 +209,284 @@ 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(); + + // 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(); + 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(); + + // 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(); + 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(); + + // 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(); + 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(); + + // 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(); + 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); + + // 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(); + 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', () => { 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); + }); + + 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(); }); }); @@ -396,7 +666,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 } = @@ -541,20 +811,22 @@ 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, + }); + mockCall.mockImplementation((...args) => { const [actionType, params] = args; if (actionType === 'SnapController:handleRequest') { @@ -581,6 +853,10 @@ function createMockAuthenticationMessenger() { return mockKeyringControllerGetState(); } + if (actionType === 'SeedlessOnboardingController:getState') { + return mockSeedlessOnboardingGetState(); + } + throw new Error( `MOCK_FAIL - unsupported messenger call: ${actionType as string}`, ); @@ -593,6 +869,7 @@ function createMockAuthenticationMessenger() { mockSnapGetAllPublicKeys, mockSnapSignMessage, mockKeyringControllerGetState, + mockSeedlessOnboardingGetState, }; } diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index a8ff2f2a756..93f36e4c649 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -10,11 +10,12 @@ import type { KeyringControllerLockEvent, KeyringControllerUnlockEvent, } from '@metamask/keyring-controller'; +import type { SeedlessOnboardingControllerGetStateAction } from '@metamask/seedless-onboarding-controller'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import { - createSnapPublicKeyRequest, createSnapAllPublicKeysRequest, + createSnapPublicKeyRequest, createSnapSignMessageRequest, } from './auth-snap-requests'; import type { @@ -37,6 +38,8 @@ const controllerName = 'AuthenticationController'; export type AuthenticationControllerState = { isSignedIn: boolean; srpSessionData?: Record; + socialPairingDone?: boolean; + pairingInProgress?: boolean; }; export const defaultState: AuthenticationControllerState = { isSignedIn: false, @@ -50,6 +53,14 @@ const metadata: StateMetadata = { persist: true, anonymous: false, }, + socialPairingDone: { + persist: true, + anonymous: true, + }, + pairingInProgress: { + persist: false, + anonymous: true, + }, }; type ControllerConfig = { @@ -100,7 +111,8 @@ export type Events = AuthenticationControllerStateChangeEvent; // Allowed Actions export type AllowedActions = | HandleSnapRequest - | KeyringControllerGetStateAction; + | KeyringControllerGetStateAction + | SeedlessOnboardingControllerGetStateAction; export type AllowedEvents = | KeyringControllerLockEvent @@ -298,13 +310,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(() => { + // no-op. failures must not interfere with the sign-in flow + }); + return accessTokens; } @@ -312,6 +329,7 @@ export default class AuthenticationController extends BaseController< this.update((state) => { state.isSignedIn = false; state.srpSessionData = undefined; + state.socialPairingDone = false; }); } @@ -351,6 +369,51 @@ export default class AuthenticationController extends BaseController< return this.state.isSignedIn; } + async #tryPairingWithSocialToken(): Promise { + const { accessToken: socialPairingToken } = this.messagingSystem.call( + 'SeedlessOnboardingController:getState', + ); + + // 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; + } + + // 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 { + const paired = await this.#auth.pairSocialIdentifier(socialPairingToken); + if (paired) { + this.update((state) => { + // Prevents a race condition when sign-out is performed before pairing completes + if (state.isSignedIn) { + state.socialPairingDone = true; + } + }); + } + } finally { + this.update((state) => { + state.pairingInProgress = false; + }); + } + } + /** * Returns the auth snap public key. * 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/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/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, )}`; diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts index 12d1a90c68d..243476cd9ae 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,15 @@ 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) + .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 +101,7 @@ export const arrangeAuthAPIs = (options?: { mockSrpLoginUrl?: MockReply; mockSiweLoginUrl?: MockReply; mockPairIdentifiers?: MockReply; + mockPairSocialIdentifier?: MockReply; mockUserProfileMetaMetrics?: MockReply; }) => { const mockNonceUrl = handleMockNonce(options?.mockNonceUrl); @@ -100,6 +111,9 @@ export const arrangeAuthAPIs = (options?: { const mockPairIdentifiersUrl = handleMockPairIdentifiers( options?.mockPairIdentifiers, ); + const mockPairSocialIdentifierUrl = handleMockPairSocialIdentifier( + options?.mockPairSocialIdentifier, + ); const mockUserProfileMetaMetricsUrl = handleMockUserProfileMetaMetrics( options?.mockUserProfileMetaMetrics, ); @@ -110,6 +124,7 @@ export const arrangeAuthAPIs = (options?: { mockSrpLoginUrl, mockSiweLoginUrl, mockPairIdentifiersUrl, + mockPairSocialIdentifierUrl, mockUserProfileMetaMetricsUrl, }; }; 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..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 @@ -5,19 +5,20 @@ import { authorizeOIDC, getNonce, getUserProfileMetaMetrics, + PAIR_SOCIAL_IDENTIFIER, } from './services'; import type { AuthConfig, AuthSigningOptions, AuthStorageOptions, - AuthType, 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 +213,45 @@ export class SRPJwtBearerAuth implements IBaseAuth { ): `metamask:${string}:${string}` { return `metamask:${nonce}:${publicKey}` as const; } + + async pairSocialIdentifier(jwt: string): Promise { + // 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 + const tokenResponse = await authorizeOIDC(jwt, env, platform); + + // Prepare the SRP signature + const identifier = await this.getIdentifier(); + const n = await getNonce(identifier, env); + 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({ login: primaryIdentifierSignature }), + }); + + 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}`); + } + } } 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.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/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; diff --git a/packages/profile-sync-controller/src/sdk/mocks/auth.ts b/packages/profile-sync-controller/src/sdk/mocks/auth.ts index db3c1211626..06ce635310c 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 = @@ -21,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, }; 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/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"] } diff --git a/yarn.lock b/yarn.lock index 0725bbac92d..f4f60996dff 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.3.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" @@ -4221,6 +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.3.0 "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown @@ -4335,7 +4337,7 @@ __metadata: languageName: node linkType: hard -"@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: