From 93a640c9688625e2dfcd58c41ab8de18f694e35b Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 17 Apr 2026 12:26:46 +0200 Subject: [PATCH 1/9] feat: add profile pairing & canonical profile id management --- .../AuthenticationController.test.ts | 242 ++++++++++++++++++ .../AuthenticationController.ts | 93 ++++++- .../src/sdk/__fixtures__/auth.ts | 20 ++ .../flow-srp.test.ts | 210 +++++++++++++++ .../sdk/authentication-jwt-bearer/flow-srp.ts | 35 ++- .../services.test.ts | 207 +++++++++++++++ .../sdk/authentication-jwt-bearer/services.ts | 83 ++++++ .../sdk/authentication-jwt-bearer/types.ts | 19 +- .../utils/identifier.test.ts | 65 +++++ .../utils/identifier.ts | 25 ++ .../src/sdk/authentication.test.ts | 1 + .../src/sdk/authentication.ts | 9 + .../src/sdk/mocks/auth.ts | 12 + .../sdk/utils/validate-login-response.test.ts | 7 +- 14 files changed, 1019 insertions(+), 9 deletions(-) create mode 100644 packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.test.ts create mode 100644 packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.ts 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 cb2f4983dce..044e73d1b17 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -14,6 +14,7 @@ import { AuthenticationController } from './AuthenticationController'; import type { AuthenticationControllerMessenger, AuthenticationControllerState, + ProfileSignInInfo, } from './AuthenticationController'; import { MOCK_LOGIN_RESPONSE, @@ -47,6 +48,7 @@ const mockSignedInState = ({ profile: { identifierId: MOCK_LOGIN_RESPONSE.profile.identifier_id, profileId: MOCK_LOGIN_RESPONSE.profile.profile_id, + canonicalProfileId: MOCK_LOGIN_RESPONSE.profile.profile_id, metaMetricsId: MOCK_LOGIN_RESPONSE.profile.metametrics_id, }, }; @@ -193,6 +195,240 @@ describe('AuthenticationController', () => { ]); }); + it('calls pairProfiles when 2+ SRPs exist', async () => { + const metametrics = createMockAuthMetaMetrics(); + const mockEndpoints = arrangeAuthAPIs({ + mockPairProfiles: { + status: 200, + body: { + profile: { + identifier_id: 'id-1', + metametrics_id: 'mm-1', + profile_id: 'canonical-1', + }, + profile_aliases: [ + { + alias_profile_id: 'p1', + canonical_profile_id: 'canonical-1', + identifier_ids: [{ id: 'h1', type: 'SRP' }], + }, + { + alias_profile_id: 'p2', + canonical_profile_id: 'canonical-1', + identifier_ids: [{ id: 'h2', type: 'SRP' }], + }, + ], + }, + }, + }); + const { messenger } = createMockAuthenticationMessenger(); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + mockEndpoints.mockPairProfilesUrl.done(); + }); + + it('does not call pairProfiles when only 1 SRP exists', async () => { + const metametrics = createMockAuthMetaMetrics(); + arrangeAuthAPIs(); + const { messenger, mockSnapGetAllPublicKeys } = + createMockAuthenticationMessenger(); + + mockSnapGetAllPublicKeys.mockResolvedValue([ + ['SINGLE_ENTROPY_SOURCE_ID', 'MOCK_PUBLIC_KEY'], + ]); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + expect(controller.state.isSignedIn).toBe(true); + }); + + it('propagates canonical profileId to all srpSessionData entries', async () => { + const metametrics = createMockAuthMetaMetrics(); + arrangeAuthAPIs({ + mockPairProfiles: { + status: 200, + body: { + profile: { + identifier_id: 'id-1', + metametrics_id: 'mm-1', + profile_id: 'new-canonical', + }, + profile_aliases: [ + { + alias_profile_id: 'p1', + canonical_profile_id: 'new-canonical', + identifier_ids: [{ id: 'h1', type: 'SRP' }], + }, + { + alias_profile_id: 'p2', + canonical_profile_id: 'new-canonical', + identifier_ids: [{ id: 'h2', type: 'SRP' }], + }, + ], + }, + }, + }); + const { messenger } = createMockAuthenticationMessenger(); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + for (const id of MOCK_ENTROPY_SOURCE_IDS) { + expect( + controller.state.srpSessionData?.[id]?.profile.canonicalProfileId, + ).toBe('new-canonical'); + } + }); + + it('emits profileSignIn event when pairing produces aliases', async () => { + const metametrics = createMockAuthMetaMetrics(); + arrangeAuthAPIs({ + mockPairProfiles: { + status: 200, + body: { + profile: { + identifier_id: 'id-1', + metametrics_id: 'mm-1', + profile_id: 'canonical-1', + }, + profile_aliases: [ + { + alias_profile_id: 'p1', + canonical_profile_id: 'canonical-1', + identifier_ids: [{ id: 'h1', type: 'SRP' }], + }, + ], + }, + }, + }); + const { messenger, baseMessenger } = createMockAuthenticationMessenger(); + + const eventPayloads: ProfileSignInInfo[] = []; + baseMessenger.subscribe( + 'AuthenticationController:profileSignIn', + (info: ProfileSignInInfo) => { + eventPayloads.push(info); + }, + ); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + expect(eventPayloads).toHaveLength(1); + expect(eventPayloads[0].profileAliases).toHaveLength(1); + expect(eventPayloads[0].profileId).toBeDefined(); + }); + + it('does not emit profileSignIn event when no pairing and no canonical change', async () => { + const metametrics = createMockAuthMetaMetrics(); + arrangeAuthAPIs(); + const { messenger, baseMessenger, mockSnapGetAllPublicKeys } = + createMockAuthenticationMessenger(); + + mockSnapGetAllPublicKeys.mockResolvedValue([ + ['SINGLE_ENTROPY_SOURCE_ID', 'MOCK_PUBLIC_KEY'], + ]); + + const eventPayloads: ProfileSignInInfo[] = []; + baseMessenger.subscribe( + 'AuthenticationController:profileSignIn', + (info: ProfileSignInInfo) => { + eventPayloads.push(info); + }, + ); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + expect(eventPayloads).toHaveLength(0); + }); + + it('does not break sign-in when pairProfiles throws', async () => { + const metametrics = createMockAuthMetaMetrics(); + arrangeAuthAPIs({ + mockPairProfiles: { status: 500 }, + }); + const { messenger } = createMockAuthenticationMessenger(); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + const result = await controller.performSignIn(); + + expect(result).toStrictEqual([ + MOCK_OATH_TOKEN_RESPONSE.access_token, + MOCK_OATH_TOKEN_RESPONSE.access_token, + ]); + expect(controller.state.isSignedIn).toBe(true); + }); + + it('preserves original profileId in srpSessionData after pairing', async () => { + const metametrics = createMockAuthMetaMetrics(); + arrangeAuthAPIs({ + mockPairProfiles: { + status: 200, + body: { + profile: { + identifier_id: 'id-1', + metametrics_id: 'mm-1', + profile_id: 'canonical-id', + }, + profile_aliases: [ + { + alias_profile_id: 'original-1', + canonical_profile_id: 'canonical-id', + identifier_ids: [{ id: 'h1', type: 'SRP' }], + }, + { + alias_profile_id: 'original-2', + canonical_profile_id: 'canonical-id', + identifier_ids: [{ id: 'h2', type: 'SRP' }], + }, + ], + }, + }, + }); + const { messenger } = createMockAuthenticationMessenger(); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + for (const id of MOCK_ENTROPY_SOURCE_IDS) { + expect(controller.state.srpSessionData?.[id]?.profile.profileId).toBe( + MOCK_LOGIN_RESPONSE.profile.profile_id, + ); + } + }); + /** * Jest Test & Assert Utility - for testing and asserting endpoint failures * @@ -680,6 +916,7 @@ describe('metadata', () => { "srpSessionData": { "MOCK_ENTROPY_SOURCE_ID": { "profile": { + "canonicalProfileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", @@ -691,6 +928,7 @@ describe('metadata', () => { }, "MOCK_ENTROPY_SOURCE_ID2": { "profile": { + "canonicalProfileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", @@ -741,6 +979,7 @@ describe('metadata', () => { "srpSessionData": { "MOCK_ENTROPY_SOURCE_ID": { "profile": { + "canonicalProfileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", @@ -753,6 +992,7 @@ describe('metadata', () => { }, "MOCK_ENTROPY_SOURCE_ID2": { "profile": { + "canonicalProfileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", @@ -788,6 +1028,7 @@ describe('metadata', () => { "srpSessionData": { "MOCK_ENTROPY_SOURCE_ID": { "profile": { + "canonicalProfileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", @@ -800,6 +1041,7 @@ describe('metadata', () => { }, "MOCK_ENTROPY_SOURCE_ID2": { "profile": { + "canonicalProfileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 092c8040a62..ebc2842255e 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -15,6 +15,7 @@ import type { Json } from '@metamask/utils'; import type { LoginResponse, + ProfileAlias, SRPInterface, UserProfile, UserProfileLineage, @@ -108,7 +109,20 @@ export type AuthenticationControllerStateChangeEvent = AuthenticationControllerState >; -export type Events = AuthenticationControllerStateChangeEvent; +export type ProfileSignInInfo = { + profileId: string; + profileAliases: ProfileAlias[]; + profileIdChanged: boolean; +}; + +export type AuthenticationControllerProfileSignInEvent = { + type: `${typeof controllerName}:profileSignIn`; + payload: [ProfileSignInInfo]; +}; + +export type Events = + | AuthenticationControllerStateChangeEvent + | AuthenticationControllerProfileSignInEvent; // Allowed Actions type AllowedActions = @@ -286,7 +300,7 @@ export class AuthenticationController extends BaseController< this.#assertIsUnlocked('performSignIn'); const allPublicKeys = await this.#snapGetAllPublicKeys(); - const accessTokens = []; + const accessTokens: string[] = []; // We iterate sequentially in order to be sure that the first entry // is the primary SRP LoginResponse. @@ -295,9 +309,72 @@ export class AuthenticationController extends BaseController< accessTokens.push(accessToken); } + // Pair SRP profiles (idempotent — no-op if already paired) + if (accessTokens.length >= 2) { + const previousCanonical = this.#getCanonicalProfileId(); + + try { + const profileAliases = await this.#pairSrpProfiles(accessTokens); + + const newCanonical = this.#getCanonicalProfileId(); + const profileIdChanged = previousCanonical !== newCanonical; + const shouldEmitProfileSignInEvent = + profileIdChanged || profileAliases.length > 0; + + if (shouldEmitProfileSignInEvent) { + this.messenger.publish('AuthenticationController:profileSignIn', { + profileId: newCanonical ?? '', + profileAliases, + profileIdChanged, + }); + } + } catch { + // Pairing failure is non-fatal — retry on next performSignIn + } + } + return accessTokens; } + async #pairSrpProfiles(accessTokens: string[]): Promise { + if (accessTokens.length < 2) { + return []; + } + const { + profileAliases, + profile: { canonicalProfileId }, + } = await this.#auth.pairSrpProfiles(accessTokens, accessTokens[0]); + this.#propagateCanonical(canonicalProfileId); + return profileAliases; + } + + #propagateCanonical(canonicalProfileId: string): void { + const { srpSessionData } = this.state; + if (!srpSessionData) { + return; + } + + this.update((state) => { + for (const key of Object.keys(state.srpSessionData ?? {})) { + const entry = state.srpSessionData?.[key]; + if (entry?.profile) { + entry.profile.canonicalProfileId = canonicalProfileId; + } + } + }); + } + + #getCanonicalProfileId(): string | null { + const { srpSessionData } = this.state; + if (!srpSessionData) { + return null; + } + const firstKey = Object.keys(srpSessionData)[0]; + return firstKey + ? (srpSessionData[firstKey]?.profile?.canonicalProfileId ?? null) + : null; + } + public performSignOut(): void { this.#cachedPrimaryEntropySourceId = undefined; this.update((state) => { @@ -307,12 +384,16 @@ export class AuthenticationController extends BaseController< } /** - * Will return a bearer token. - * Logs a user in if a user is not logged in. + * Returns a bearer token for the specified SRP, logging in if needed. * - * @returns profile for the session. + * When called without `entropySourceId`, returns the primary (first) SRP's + * access token, which is effectively the canonical + * profile's token that can be used by alias-aware consumers for cross-SRP + * operations. + * + * @param entropySourceId - The entropy source ID. Omit for the primary SRP. + * @returns The OIDC access token. */ - public async getBearerToken(entropySourceId?: string): Promise { this.#assertIsUnlocked('getBearerToken'); const resolvedId = diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts index df3f9e16031..7c988b4b431 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts @@ -6,6 +6,8 @@ import { MOCK_OIDC_TOKEN_RESPONSE, MOCK_OIDC_TOKEN_URL, MOCK_PAIR_IDENTIFIERS_URL, + MOCK_PAIR_PROFILES_RESPONSE, + MOCK_PAIR_PROFILES_URL, MOCK_PROFILE_LINEAGE_URL, MOCK_SIWE_LOGIN_RESPONSE, MOCK_SIWE_LOGIN_URL, @@ -51,6 +53,19 @@ export const handleMockPairIdentifiers = (mockReply?: MockReply) => { return mockPairIdentifiersEndpoint; }; +export const handleMockPairProfiles = (mockReply?: MockReply) => { + const reply = mockReply ?? { + status: 200, + body: MOCK_PAIR_PROFILES_RESPONSE, + }; + const mockPairProfilesEndpoint = nock(MOCK_PAIR_PROFILES_URL) + .persist() + .post('') + .reply(reply.status, reply.body); + + return mockPairProfilesEndpoint; +}; + export const handleMockSrpLogin = (mockReply?: MockReply) => { const reply = mockReply ?? { status: 200, body: MOCK_SRP_LOGIN_RESPONSE }; const mockLoginEndpoint = nock(MOCK_SRP_LOGIN_URL) @@ -91,6 +106,7 @@ export const arrangeAuthAPIs = (options?: { mockSrpLoginUrl?: MockReply; mockSiweLoginUrl?: MockReply; mockPairIdentifiers?: MockReply; + mockPairProfiles?: MockReply; mockUserProfileLineageUrl?: MockReply; }) => { const mockNonceUrl = handleMockNonce(options?.mockNonceUrl); @@ -100,6 +116,9 @@ export const arrangeAuthAPIs = (options?: { const mockPairIdentifiersUrl = handleMockPairIdentifiers( options?.mockPairIdentifiers, ); + const mockPairProfilesUrl = handleMockPairProfiles( + options?.mockPairProfiles, + ); const mockUserProfileLineageUrl = handleMockUserProfileLineage( options?.mockUserProfileLineageUrl, ); @@ -110,6 +129,7 @@ export const arrangeAuthAPIs = (options?: { mockSrpLoginUrl, mockSiweLoginUrl, mockPairIdentifiersUrl, + mockPairProfilesUrl, mockUserProfileLineageUrl, }; }; diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts index a266b4c9875..e392eafed08 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts @@ -28,6 +28,12 @@ jest.mock('./services', () => ({ getUserProfileLineage: jest.fn(), })); +// Mock computeIdentifierId +const mockComputeIdentifierId = jest.fn(); +jest.mock('./utils/identifier', () => ({ + computeIdentifierId: (...args: unknown[]) => mockComputeIdentifierId(...args), +})); + describe('SRPJwtBearerAuth rate limit handling', () => { const config: AuthConfig & { type: AuthType.SRP } = { type: AuthType.SRP, @@ -38,6 +44,7 @@ describe('SRPJwtBearerAuth rate limit handling', () => { // Mock data constants const MOCK_PROFILE: UserProfile = { profileId: 'p1', + canonicalProfileId: 'p1', metaMetricsId: 'm1', identifierId: 'i1', }; @@ -210,3 +217,206 @@ describe('SRPJwtBearerAuth rate limit handling', () => { expect(mockGetNonce).not.toHaveBeenCalled(); }); }); + +describe('SRPJwtBearerAuth profileId resolution', () => { + const config: AuthConfig & { type: AuthType.SRP } = { + type: AuthType.SRP, + env: Env.DEV, + platform: Platform.EXTENSION, + }; + + const MOCK_NONCE_RESPONSE = { + nonce: 'nonce-1', + identifier: 'identifier-1', + expiresIn: 60, + }; + + const MOCK_OIDC_RESPONSE = { + accessToken: 'access-token', + expiresIn: 60, + obtainedAt: Date.now(), + }; + + const createAuth = () => { + const store: { value: LoginResponse | null } = { value: null }; + + const auth = new SRPJwtBearerAuth(config, { + storage: { + getLoginResponse: async () => store.value, + setLoginResponse: async (val) => { + store.value = val; + }, + }, + signing: { + getIdentifier: async () => 'MOCK_PUBLIC_KEY', + signMessage: async () => 'signature-1', + }, + }); + + return { auth, store }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetNonce.mockResolvedValue(MOCK_NONCE_RESPONSE); + mockAuthorizeOIDC.mockResolvedValue(MOCK_OIDC_RESPONSE); + mockComputeIdentifierId.mockReturnValue('computed-identifier-hash'); + }); + + it('resolves original profileId from aliases when paired', async () => { + mockAuthenticate.mockResolvedValue({ + token: 'jwt-token', + expiresIn: 60, + profile: { + identifierId: 'id-1', + metaMetricsId: 'mm-1', + profileId: 'canonical-profile-id', + canonicalProfileId: 'canonical-profile-id', + }, + profileAliases: [ + { + aliasProfileId: 'original-profile-id', + canonicalProfileId: 'canonical-profile-id', + identifierIds: [{ id: 'computed-identifier-hash', type: 'SRP' }], + }, + { + aliasProfileId: 'other-original-id', + canonicalProfileId: 'canonical-profile-id', + identifierIds: [{ id: 'other-hash', type: 'SRP' }], + }, + ], + }); + + const { auth } = createAuth(); + const profile = await auth.getUserProfile(); + + expect(profile.profileId).toBe('original-profile-id'); + expect(profile.canonicalProfileId).toBe('canonical-profile-id'); + expect(mockComputeIdentifierId).toHaveBeenCalledWith( + 'MOCK_PUBLIC_KEY', + Env.DEV, + ); + }); + + it('keeps canonical as profileId when no alias matches', async () => { + mockAuthenticate.mockResolvedValue({ + token: 'jwt-token', + expiresIn: 60, + profile: { + identifierId: 'id-1', + metaMetricsId: 'mm-1', + profileId: 'canonical-profile-id', + canonicalProfileId: 'canonical-profile-id', + }, + profileAliases: [ + { + aliasProfileId: 'other-original-id', + canonicalProfileId: 'canonical-profile-id', + identifierIds: [{ id: 'non-matching-hash', type: 'SRP' }], + }, + ], + }); + + const { auth } = createAuth(); + const profile = await auth.getUserProfile(); + + expect(profile.profileId).toBe('canonical-profile-id'); + expect(profile.canonicalProfileId).toBe('canonical-profile-id'); + }); + + it('stores profileId as-is when no aliases are returned (unpaired)', async () => { + mockAuthenticate.mockResolvedValue({ + token: 'jwt-token', + expiresIn: 60, + profile: { + identifierId: 'id-1', + metaMetricsId: 'mm-1', + profileId: 'solo-profile-id', + canonicalProfileId: 'solo-profile-id', + }, + profileAliases: [], + }); + + const { auth } = createAuth(); + const profile = await auth.getUserProfile(); + + expect(profile.profileId).toBe('solo-profile-id'); + expect(profile.canonicalProfileId).toBe('solo-profile-id'); + expect(mockComputeIdentifierId).not.toHaveBeenCalled(); + }); + + it('does not call computeIdentifierId when profileAliases is undefined', async () => { + mockAuthenticate.mockResolvedValue({ + token: 'jwt-token', + expiresIn: 60, + profile: { + identifierId: 'id-1', + metaMetricsId: 'mm-1', + profileId: 'solo-profile-id', + canonicalProfileId: 'solo-profile-id', + }, + }); + + const { auth } = createAuth(); + const profile = await auth.getUserProfile(); + + expect(profile.profileId).toBe('solo-profile-id'); + expect(mockComputeIdentifierId).not.toHaveBeenCalled(); + }); + + it('sets canonicalProfileId to the login response profileId', async () => { + mockAuthenticate.mockResolvedValue({ + token: 'jwt-token', + expiresIn: 60, + profile: { + identifierId: 'id-1', + metaMetricsId: 'mm-1', + profileId: 'canonical-from-server', + canonicalProfileId: 'canonical-from-server', + }, + profileAliases: [ + { + aliasProfileId: 'my-original-id', + canonicalProfileId: 'canonical-from-server', + identifierIds: [{ id: 'computed-identifier-hash', type: 'SRP' }], + }, + ], + }); + + const { auth, store } = createAuth(); + await auth.getUserProfile(); + + expect(store.value?.profile.profileId).toBe('my-original-id'); + expect(store.value?.profile.canonicalProfileId).toBe( + 'canonical-from-server', + ); + }); + + it('persists resolved profile to storage', async () => { + mockAuthenticate.mockResolvedValue({ + token: 'jwt-token', + expiresIn: 60, + profile: { + identifierId: 'id-1', + metaMetricsId: 'mm-1', + profileId: 'canonical-id', + canonicalProfileId: 'canonical-id', + }, + profileAliases: [ + { + aliasProfileId: 'original-id', + canonicalProfileId: 'canonical-id', + identifierIds: [{ id: 'computed-identifier-hash', type: 'SRP' }], + }, + ], + }); + + const { auth, store } = createAuth(); + await auth.getAccessToken(); + + expect(store.value).not.toBeNull(); + expect(store.value?.profile.profileId).toBe('original-id'); + expect(store.value?.profile.canonicalProfileId).toBe('canonical-id'); + expect(store.value?.token.accessToken).toBe('access-token'); + }); +}); 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 a2a975d277c..ee439800f93 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 @@ -1,6 +1,7 @@ import type { Eip1193Provider } from 'ethers'; import type { MetaMetricsAuth } from '../../shared/types/services'; +import { computeIdentifierId } from './utils/identifier'; import { ValidationError, RateLimitedError } from '../errors'; import { getMetaMaskProviderEIP6963 } from '../utils/eip-6963-metamask-provider'; import { @@ -15,7 +16,9 @@ import { authorizeOIDC, getNonce, getUserProfileLineage, + pairProfiles, } from './services'; +import type { PairProfilesResponse } from './services'; import type { AuthConfig, AuthSigningOptions, @@ -150,6 +153,13 @@ export class SRPJwtBearerAuth implements IBaseAuth { return await getUserProfileLineage(this.#config.env, accessToken); } + async pairSrpProfiles( + accessTokens: string[], + authAccessToken: string, + ): Promise { + return await pairProfiles(accessTokens, authAccessToken, this.#config.env); + } + async signMessage( message: string, entropySourceId?: string, @@ -220,6 +230,29 @@ export class SRPJwtBearerAuth implements IBaseAuth { this.#metametrics, ); + // Resolve original profileId from aliases. + // This is done mainly to preserve the original profileId for storage key derivation + // until we migrate to the canonical profileId storage system. + const canonicalProfileId = authResponse.profile.profileId; + const profile = { ...authResponse.profile }; + + if (authResponse.profileAliases?.length > 0) { + const targetIdentifierId = computeIdentifierId( + publicKey, + this.#config.env, + ); + + const targetAlias = authResponse.profileAliases.find((alias) => + alias.identifierIds.some((id) => id.id === targetIdentifierId), + ); + + if (targetAlias) { + profile.profileId = targetAlias.aliasProfileId; + } + } + + profile.canonicalProfileId = canonicalProfileId; + // Authorize const tokenResponse = await authorizeOIDC( authResponse.token, @@ -229,7 +262,7 @@ export class SRPJwtBearerAuth implements IBaseAuth { // Save const result: LoginResponse = { - profile: authResponse.profile, + profile, token: tokenResponse, }; diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.test.ts index 357903877cb..b3f58fb68a1 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.test.ts @@ -10,12 +10,14 @@ import { authenticate, authorizeOIDC, pairIdentifiers, + pairProfiles, getUserProfileLineage, NONCE_URL, OIDC_TOKEN_URL, SRP_LOGIN_URL, SIWE_LOGIN_URL, PAIR_IDENTIFIERS, + PAIR_PROFILES_URL, PROFILE_LINEAGE_URL, } from './services'; import { AuthType } from './types'; @@ -126,6 +128,12 @@ describe('services', () => { 'https://authentication.dev-api.cx.metamask.io/api/v2/profile/lineage', ); }); + + it('should build correct PAIR_PROFILES_URL', () => { + expect(PAIR_PROFILES_URL(Env.DEV)).toBe( + 'https://authentication.dev-api.cx.metamask.io/api/v2/profile/pair', + ); + }); }); describe('getNonce', () => { @@ -349,7 +357,9 @@ describe('services', () => { identifierId: 'id-1', metaMetricsId: 'mm-1', profileId: 'profile-1', + canonicalProfileId: 'profile-1', }, + profileAliases: [], }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/srp/login'), @@ -363,6 +373,74 @@ describe('services', () => { ); }); + it('should send X-MetaMask-Profile-Pairing header', async () => { + const mockResponse = createMockResponse(mockAuthResponse); + mockFetch.mockResolvedValue(mockResponse); + + await authenticate('raw-message', 'signature', AuthType.SRP, Env.DEV); + + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs[1].headers).toStrictEqual( + expect.objectContaining({ + 'X-MetaMask-Profile-Pairing': 'enabled', + }), + ); + }); + + it('should parse profile_aliases from response', async () => { + const responseWithAliases = { + ...mockAuthResponse, + profile_aliases: [ + { + alias_profile_id: 'alias-1', + canonical_profile_id: 'canonical-1', + identifier_ids: [{ id: 'hash-1', type: 'SRP' }], + }, + { + alias_profile_id: 'alias-2', + canonical_profile_id: 'canonical-1', + identifier_ids: [{ id: 'hash-2', type: 'SRP' }], + }, + ], + }; + const mockResponse = createMockResponse(responseWithAliases); + mockFetch.mockResolvedValue(mockResponse); + + const result = await authenticate( + 'raw-message', + 'signature', + AuthType.SRP, + Env.DEV, + ); + + expect(result.profileAliases).toStrictEqual([ + { + aliasProfileId: 'alias-1', + canonicalProfileId: 'canonical-1', + identifierIds: [{ id: 'hash-1', type: 'SRP' }], + }, + { + aliasProfileId: 'alias-2', + canonicalProfileId: 'canonical-1', + identifierIds: [{ id: 'hash-2', type: 'SRP' }], + }, + ]); + }); + + it('should return empty profileAliases when none in response', async () => { + const mockResponse = createMockResponse(mockAuthResponse); + mockFetch.mockResolvedValue(mockResponse); + + const result = await authenticate( + 'raw-message', + 'signature', + AuthType.SRP, + Env.DEV, + ); + + expect(result.profileAliases).toStrictEqual([]); + }); + it('should return authentication data on success with SiWE', async () => { const mockResponse = createMockResponse(mockAuthResponse); mockFetch.mockResolvedValue(mockResponse); @@ -626,6 +704,135 @@ describe('services', () => { }); }); + describe('pairProfiles', () => { + const mockPairApiResponse = { + profile: { + identifier_id: 'id-canonical', + metametrics_id: 'mm-canonical', + profile_id: 'canonical-1', + }, + profile_aliases: [ + { + alias_profile_id: 'p1', + canonical_profile_id: 'canonical-1', + identifier_ids: [{ id: 'h1', type: 'SRP' }], + }, + ], + }; + + it('should send correct request with JWT array', async () => { + const mockResponse = createMockResponse(mockPairApiResponse); + mockFetch.mockResolvedValue(mockResponse); + + await pairProfiles( + ['token-1', 'token-2', 'token-3'], + 'auth-access-token', + Env.DEV, + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer auth-access-token', + }, + body: JSON.stringify({ + jwts: ['token-1', 'token-2', 'token-3'], + }), + }), + ); + }); + + it('should return parsed profile and aliases from response', async () => { + const pairApiResponse = { + profile: { + identifier_id: 'id-canonical', + metametrics_id: 'mm-canonical', + profile_id: 'canonical-1', + }, + profile_aliases: [ + { + alias_profile_id: 'p1', + canonical_profile_id: 'canonical-1', + identifier_ids: [{ id: 'h1', type: 'SRP' }], + }, + { + alias_profile_id: 'p2', + canonical_profile_id: 'canonical-1', + identifier_ids: [{ id: 'h2', type: 'SRP' }], + }, + ], + }; + const mockResponse = createMockResponse(pairApiResponse); + mockFetch.mockResolvedValue(mockResponse); + + const result = await pairProfiles( + ['token-1', 'token-2'], + 'auth-access-token', + Env.DEV, + ); + + expect(result.profile.profileId).toBe('canonical-1'); + expect(result.profile.identifierId).toBe('id-canonical'); + expect(result.profileAliases).toHaveLength(2); + expect(result.profileAliases[0]).toStrictEqual({ + aliasProfileId: 'p1', + canonicalProfileId: 'canonical-1', + identifierIds: [{ id: 'h1', type: 'SRP' }], + }); + }); + + it('should throw PairError on network failure', async () => { + mockFetch.mockRejectedValue(new Error('Connection refused')); + + await expect( + pairProfiles(['token-1'], 'auth-token', Env.DEV), + ).rejects.toThrow(PairError); + }); + + it('should throw PairError on error response', async () => { + const mockResponse = createMockResponse( + { message: 'Invalid tokens', error: 'invalid_request' }, + { ok: false, status: 400 }, + ); + mockFetch.mockResolvedValue(mockResponse); + + await expect( + pairProfiles(['token-1'], 'auth-token', Env.DEV), + ).rejects.toThrow(PairError); + }); + + it('should throw RateLimitedError on 429 response', async () => { + const mockResponse = createMockResponse( + { message: 'Rate limited', error: 'too_many_requests' }, + { ok: false, status: 429, headers: { 'Retry-After': '10' } }, + ); + mockFetch.mockResolvedValue(mockResponse); + + const error = await pairProfiles( + ['token-1'], + 'auth-token', + Env.DEV, + ).catch((caughtError) => caughtError); + + expect(error).toBeInstanceOf(RateLimitedError); + expect(error.retryAfterMs).toBe(10000); + }); + + it('should call correct URL for environment', async () => { + const mockResponse = createMockResponse(mockPairApiResponse); + mockFetch.mockResolvedValue(mockResponse); + + await pairProfiles(['token-1'], 'auth-token', Env.PRD); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl.toString()).toContain('api.cx.metamask.io'); + expect(calledUrl.toString()).toContain('/profile/pair'); + }); + }); + describe('getUserProfileLineage', () => { const mockLineageResponse = { profile_id: 'profile-123', 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 674db0b8c1a..acc31af6be2 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 @@ -12,6 +12,7 @@ import { import type { AccessToken, ErrorMessage, + ProfileAlias, UserProfile, UserProfileLineage, } from './types'; @@ -150,6 +151,9 @@ export const SRP_LOGIN_URL = (env: Env): string => export const SIWE_LOGIN_URL = (env: Env): string => `${getEnvUrls(env).authApiUrl}/api/v2/siwe/login`; +export const PAIR_PROFILES_URL = (env: Env): string => + `${getEnvUrls(env).authApiUrl}/api/v2/profile/pair`; + export const PROFILE_LINEAGE_URL = (env: Env): string => `${getEnvUrls(env).authApiUrl}/api/v2/profile/lineage`; @@ -173,6 +177,23 @@ type NonceResponse = { expiresIn: number; }; +type RawProfileAlias = { + // eslint-disable-next-line @typescript-eslint/naming-convention + alias_profile_id: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + canonical_profile_id: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + identifier_ids: { id: string; type: string }[]; +}; + +const parseProfileAliases = (raw: RawProfileAlias[]): ProfileAlias[] => { + return raw.map((alias) => ({ + aliasProfileId: alias.alias_profile_id, + canonicalProfileId: alias.canonical_profile_id, + identifierIds: alias.identifier_ids ?? [], + })); +}; + type PairRequest = { signature: string; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -230,6 +251,63 @@ export async function pairIdentifiers( } } +export type PairProfilesResponse = { + profile: UserProfile; + profileAliases: ProfileAlias[]; +}; + +/** + * Pair multiple profiles using their OIDC access tokens. + * Idempotent — calling with already-paired tokens is a no-op. + * + * @param accessTokens - Two or more OIDC access tokens to pair + * @param authAccessToken - A valid access token for the Authorization header + * @param env - server environment + * @returns The pair response containing the canonical profile and aliases + */ +export async function pairProfiles( + accessTokens: string[], + authAccessToken: string, + env: Env, +): Promise { + const pairUrl = new URL(PAIR_PROFILES_URL(env)); + + try { + const response = await fetch(pairUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authAccessToken}`, + }, + body: JSON.stringify({ + jwts: accessTokens, + }), + }); + + if (!response.ok) { + return await throwServiceError( + response, + 'Failed to pair profiles', + PairError, + ); + } + + const pairResponse = await response.json(); + + return { + profile: { + identifierId: pairResponse.profile.identifier_id, + metaMetricsId: pairResponse.profile.metametrics_id ?? '', + profileId: pairResponse.profile.profile_id, + canonicalProfileId: pairResponse.profile.profile_id, + }, + profileAliases: parseProfileAliases(pairResponse.profile_aliases ?? []), + }; + } catch (error) { + return await throwServiceError(error, 'Failed to pair profiles', PairError); + } +} + /** * Service to Get Nonce for JWT Bearer Flow * @@ -323,6 +401,7 @@ type Authentication = { token: string; expiresIn: number; profile: UserProfile; + profileAliases: ProfileAlias[]; }; /** * Service to Authenticate/Login a user via SIWE or SRP derived key. @@ -348,6 +427,7 @@ export async function authenticate( method: 'POST', headers: { 'Content-Type': 'application/json', + 'X-MetaMask-Profile-Pairing': 'enabled', }, body: JSON.stringify({ signature, @@ -372,6 +452,7 @@ export async function authenticate( } const loginResponse = await response.json(); + return { token: loginResponse.token, expiresIn: loginResponse.expires_in, @@ -379,7 +460,9 @@ export async function authenticate( identifierId: loginResponse.profile.identifier_id, metaMetricsId: loginResponse.profile.metametrics_id, profileId: loginResponse.profile.profile_id, + canonicalProfileId: loginResponse.profile.profile_id, }, + profileAliases: parseProfileAliases(loginResponse.profile_aliases ?? []), }; } catch (error) { return await throwServiceError( diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/types.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/types.ts index 8dc7c7595cd..2dae8fa502a 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/types.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/types.ts @@ -36,15 +36,32 @@ export type UserProfile = { */ identifierId: string; /** - * The Unique profile for a logged in user. A Profile can be logged in via multiple Identifiers + * The original per-SRP profile ID. Immutable after first login. + * Used for user storage key derivation — MUST NOT be replaced with the canonical. */ profileId: string; + /** + * The unified canonical profile ID across all paired SRPs. + * Set from the server response and updated after pairing via canonical propagation. + * For pre-upgrade state, defaults to profileId. + */ + canonicalProfileId: string; /** * Server MetaMetrics ID. Allows grouping of user events cross platform. */ metaMetricsId: string; }; +/** + * Represents a profile alias returned by the server in profile_aliases. + * Transient — this is not persisted in LoginResponse or srpSessionData. + */ +export type ProfileAlias = { + aliasProfileId: string; + canonicalProfileId: string; + identifierIds: { id: string; type: string }[]; +}; + export type LoginResponse = { token: AccessToken; profile: UserProfile; diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.test.ts new file mode 100644 index 00000000000..c40e67c43bc --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.test.ts @@ -0,0 +1,65 @@ +import { createSHA256Hash } from '../../../shared/encryption'; +import { Env } from '../../../shared/env'; +import { computeIdentifierId, IDENTIFIER_SALT } from './identifier'; + +describe('computeIdentifierId', () => { + const MOCK_PUBLIC_KEY = + '0x02acabf4ecab2f8f559596c51c758ee97823f97c6c6feac031cdacb77eae071b5c'; + + it('produces SHA256(publicKey + salt) for dev environment', () => { + const result = computeIdentifierId(MOCK_PUBLIC_KEY, Env.DEV); + const expected = createSHA256Hash( + MOCK_PUBLIC_KEY + IDENTIFIER_SALT[Env.DEV], + ); + expect(result).toBe(expected); + }); + + it('produces SHA256(publicKey + salt) for uat environment', () => { + const result = computeIdentifierId(MOCK_PUBLIC_KEY, Env.UAT); + const expected = createSHA256Hash( + MOCK_PUBLIC_KEY + IDENTIFIER_SALT[Env.UAT], + ); + expect(result).toBe(expected); + }); + + it('produces SHA256(publicKey + salt) for prd environment', () => { + const result = computeIdentifierId(MOCK_PUBLIC_KEY, Env.PRD); + const expected = createSHA256Hash( + MOCK_PUBLIC_KEY + IDENTIFIER_SALT[Env.PRD], + ); + expect(result).toBe(expected); + }); + + it('produces different hashes for different environments', () => { + const devHash = computeIdentifierId(MOCK_PUBLIC_KEY, Env.DEV); + const uatHash = computeIdentifierId(MOCK_PUBLIC_KEY, Env.UAT); + const prdHash = computeIdentifierId(MOCK_PUBLIC_KEY, Env.PRD); + + expect(devHash).not.toBe(uatHash); + expect(devHash).not.toBe(prdHash); + expect(uatHash).not.toBe(prdHash); + }); + + it('produces different hashes for different public keys', () => { + const hash1 = computeIdentifierId('key-1', Env.PRD); + const hash2 = computeIdentifierId('key-2', Env.PRD); + expect(hash1).not.toBe(hash2); + }); + + it('is deterministic', () => { + const hash1 = computeIdentifierId(MOCK_PUBLIC_KEY, Env.PRD); + const hash2 = computeIdentifierId(MOCK_PUBLIC_KEY, Env.PRD); + expect(hash1).toBe(hash2); + }); + + it('returns a hex string', () => { + const result = computeIdentifierId(MOCK_PUBLIC_KEY, Env.PRD); + expect(result).toMatch(/^[0-9a-f]{64}$/u); + }); + + it('throws for invalid environment', () => { + expect(() => + computeIdentifierId(MOCK_PUBLIC_KEY, 'invalid' as Env), + ).toThrow('Cannot compute identifier ID: invalid environment'); + }); +}); diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.ts new file mode 100644 index 00000000000..9d15aed81a3 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.ts @@ -0,0 +1,25 @@ +import { createSHA256Hash } from '../../../shared/encryption'; +import type { Env } from '../../../shared/env'; + +export const IDENTIFIER_SALT: Record = { + dev: 'Baiche1eu8Oa2een5ieReul0Phooph4e', + uat: 'wooG2Nahd4juviiw7cooxa7ekaeNgeik', + prd: 'oCheThi4lohv5choGhuosh1aiT2phioF', +}; + +/** + * Computes a deterministic identifier ID by hashing a public key with an + * environment-specific salt. Matches the server-side formula: + * SHA256(publicKey + salt). + * + * @param publicKey - The raw SRP public key + * @param env - The environment whose salt to use + * @returns The hex-encoded SHA256 hash used as identifier_id + */ +export function computeIdentifierId(publicKey: string, env: Env): string { + const salt = IDENTIFIER_SALT[env]; + if (!salt) { + throw new Error('Cannot compute identifier ID: invalid environment'); + } + return createSHA256Hash(publicKey + salt); +} diff --git a/packages/profile-sync-controller/src/sdk/authentication.test.ts b/packages/profile-sync-controller/src/sdk/authentication.test.ts index 2517392ec6e..f854e8ea776 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.test.ts @@ -697,6 +697,7 @@ function createMockStoredProfile(): LoginResponse { profile: { identifierId: MOCK_SRP_LOGIN_RESPONSE.profile.identifier_id, profileId: MOCK_SRP_LOGIN_RESPONSE.profile.profile_id, + canonicalProfileId: MOCK_SRP_LOGIN_RESPONSE.profile.profile_id, metaMetricsId: MOCK_SRP_LOGIN_RESPONSE.profile.metametrics_id, }, }; diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index 9cef8f85777..5eb1e2d3bda 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -7,6 +7,7 @@ import { getNonce, pairIdentifiers, } from './authentication-jwt-bearer/services'; +import type { PairProfilesResponse } from './authentication-jwt-bearer/services'; import type { UserProfile, Pair, @@ -82,6 +83,14 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { return await this.#sdk.getUserProfileLineage(entropySourceId); } + async pairSrpProfiles( + accessTokens: string[], + authAccessToken: string, + ): Promise { + this.#assertSRP(this.#type, this.#sdk); + return await this.#sdk.pairSrpProfiles(accessTokens, authAccessToken); + } + async signMessage( message: string, entropySourceId?: string, diff --git a/packages/profile-sync-controller/src/sdk/mocks/auth.ts b/packages/profile-sync-controller/src/sdk/mocks/auth.ts index 3062aacdf1a..52e2ce367ca 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_PROFILES_URL, PROFILE_LINEAGE_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_PROFILES_URL = PAIR_PROFILES_URL(Env.PRD); export const MOCK_PROFILE_LINEAGE_URL = PROFILE_LINEAGE_URL(Env.PRD); export const MOCK_JWT = @@ -51,6 +53,7 @@ export const MOCK_SRP_LOGIN_RESPONSE = { identifier_type: 'SRP', encrypted_storage_key: 'd2ddd8af8af905306f3e1456fb', }, + profile_aliases: [], }; export const MOCK_OIDC_TOKEN_RESPONSE = { @@ -58,6 +61,15 @@ export const MOCK_OIDC_TOKEN_RESPONSE = { expires_in: 3600, }; +export const MOCK_PAIR_PROFILES_RESPONSE = { + profile: { + identifier_id: MOCK_SRP_LOGIN_RESPONSE.profile.identifier_id, + metametrics_id: MOCK_SRP_LOGIN_RESPONSE.profile.metametrics_id, + profile_id: MOCK_SRP_LOGIN_RESPONSE.profile.profile_id, + }, + profile_aliases: [], +}; + export const MOCK_USER_PROFILE_LINEAGE_RESPONSE = { profile_id: 'f88227bd-b615-41a3-b0be-467dd781a4ad', created_at: '2025-10-01T12:00:00Z', diff --git a/packages/profile-sync-controller/src/sdk/utils/validate-login-response.test.ts b/packages/profile-sync-controller/src/sdk/utils/validate-login-response.test.ts index 2683462de29..18a7bd914dd 100644 --- a/packages/profile-sync-controller/src/sdk/utils/validate-login-response.test.ts +++ b/packages/profile-sync-controller/src/sdk/utils/validate-login-response.test.ts @@ -20,7 +20,12 @@ function createValidLoginResponse( }), ): LoginResponse { return { - profile: { identifierId: '', metaMetricsId: '', profileId: '' }, + profile: { + identifierId: '', + metaMetricsId: '', + profileId: '', + canonicalProfileId: '', + }, token: { accessToken, expiresIn: 3600, obtainedAt: Date.now() }, }; } From 37164ad0c5c04fd13229f5eb5f2097809d34ad88 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 17 Apr 2026 12:29:24 +0200 Subject: [PATCH 2/9] chore: update CHANGELOG --- packages/profile-sync-controller/CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 57bfa3474bb..f986be0a32b 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add SRP profile pairing support (Accounts ADR 0006) ([#8504](https://github.com/MetaMask/core/pull/8504)) + - `performSignIn` now automatically pairs all SRPs via `POST /profile/pair` when 2+ SRPs exist (idempotent) + - Add `canonicalProfileId` to `UserProfile` — the unified profile ID across paired SRPs + - Add `ProfileAlias` type for transient alias data returned by the pairing API + - Add `pairSrpProfiles` method to `SRPJwtBearerAuth` and `JwtBearerAuth` + - Add `ProfileSignInEvent` (`AuthenticationController:profileSignIn`) emitted after successful pairing + - Send `X-MetaMask-Profile-Pairing: enabled` header on all `/srp/login` requests + - Resolve original per-SRP `profileId` from `profile_aliases` using `computeIdentifierId` + - Propagate canonical profile ID to all `srpSessionData` entries after pairing + ### Changed - Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) From aeae125f7e4abf3f80d944f4bf16d4e1aa78b5d1 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 17 Apr 2026 14:31:08 +0200 Subject: [PATCH 3/9] fix: do not send new header for SIWE --- .../authentication-jwt-bearer/services.test.ts | 16 +++++++++++++++- .../sdk/authentication-jwt-bearer/services.ts | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.test.ts index b3f58fb68a1..d316292478d 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.test.ts @@ -373,7 +373,7 @@ describe('services', () => { ); }); - it('should send X-MetaMask-Profile-Pairing header', async () => { + it('should send X-MetaMask-Profile-Pairing header for SRP', async () => { const mockResponse = createMockResponse(mockAuthResponse); mockFetch.mockResolvedValue(mockResponse); @@ -387,6 +387,20 @@ describe('services', () => { ); }); + it('should not send X-MetaMask-Profile-Pairing header for SiWE', async () => { + const mockResponse = createMockResponse(mockAuthResponse); + mockFetch.mockResolvedValue(mockResponse); + + await authenticate('raw-message', 'signature', AuthType.SiWE, Env.DEV); + + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs[1].headers).not.toStrictEqual( + expect.objectContaining({ + 'X-MetaMask-Profile-Pairing': 'enabled', + }), + ); + }); + it('should parse profile_aliases from response', async () => { const responseWithAliases = { ...mockAuthResponse, 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 acc31af6be2..8abada03baa 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 @@ -427,7 +427,9 @@ export async function authenticate( method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-MetaMask-Profile-Pairing': 'enabled', + ...(authType === AuthType.SRP + ? { 'X-MetaMask-Profile-Pairing': 'enabled' } + : {}), }, body: JSON.stringify({ signature, From 1d56cc08e3661da6131f9017554033013e7dc7ae Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 17 Apr 2026 14:32:15 +0200 Subject: [PATCH 4/9] fix: formatting --- packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts index 7c988b4b431..d5fd681fcda 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts @@ -116,9 +116,7 @@ export const arrangeAuthAPIs = (options?: { const mockPairIdentifiersUrl = handleMockPairIdentifiers( options?.mockPairIdentifiers, ); - const mockPairProfilesUrl = handleMockPairProfiles( - options?.mockPairProfiles, - ); + const mockPairProfilesUrl = handleMockPairProfiles(options?.mockPairProfiles); const mockUserProfileLineageUrl = handleMockUserProfileLineage( options?.mockUserProfileLineageUrl, ); From a52b7f7fedb2ebd2ed3633384a3143ff0e05b256 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 17 Apr 2026 16:13:15 +0200 Subject: [PATCH 5/9] fix: lint issues --- .../src/sdk/authentication-jwt-bearer/flow-srp.ts | 2 +- .../sdk/authentication-jwt-bearer/utils/identifier.test.ts | 7 ++++--- .../src/sdk/authentication-jwt-bearer/utils/identifier.ts | 2 +- 3 files changed, 6 insertions(+), 5 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 ee439800f93..142f2996973 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 @@ -1,7 +1,6 @@ import type { Eip1193Provider } from 'ethers'; import type { MetaMetricsAuth } from '../../shared/types/services'; -import { computeIdentifierId } from './utils/identifier'; import { ValidationError, RateLimitedError } from '../errors'; import { getMetaMaskProviderEIP6963 } from '../utils/eip-6963-metamask-provider'; import { @@ -29,6 +28,7 @@ import type { UserProfile, UserProfileLineage, } from './types'; +import { computeIdentifierId } from './utils/identifier'; import * as timeUtils from './utils/time'; type JwtBearerAuth_SRP_Options = { diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.test.ts index c40e67c43bc..455af310896 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.test.ts @@ -58,8 +58,9 @@ describe('computeIdentifierId', () => { }); it('throws for invalid environment', () => { - expect(() => - computeIdentifierId(MOCK_PUBLIC_KEY, 'invalid' as Env), - ).toThrow('Cannot compute identifier ID: invalid environment'); + // @ts-expect-error: testing runtime guard with an invalid env value + expect(() => computeIdentifierId(MOCK_PUBLIC_KEY, 'invalid')).toThrow( + 'Cannot compute identifier ID: invalid environment', + ); }); }); diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.ts index 9d15aed81a3..93bfaaf515b 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/utils/identifier.ts @@ -1,7 +1,7 @@ import { createSHA256Hash } from '../../../shared/encryption'; import type { Env } from '../../../shared/env'; -export const IDENTIFIER_SALT: Record = { +export const IDENTIFIER_SALT: Record = { dev: 'Baiche1eu8Oa2een5ieReul0Phooph4e', uat: 'wooG2Nahd4juviiw7cooxa7ekaeNgeik', prd: 'oCheThi4lohv5choGhuosh1aiT2phioF', From c329ad803b50bfd4321a5164714ae30ab1f467ea Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 17 Apr 2026 16:16:48 +0200 Subject: [PATCH 6/9] fix: update messenger action types --- .../AuthenticationController-method-action-types.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController-method-action-types.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController-method-action-types.ts index 2a3a17ebc7a..5c09f6540d1 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController-method-action-types.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController-method-action-types.ts @@ -16,10 +16,15 @@ export type AuthenticationControllerPerformSignOutAction = { }; /** - * Will return a bearer token. - * Logs a user in if a user is not logged in. + * Returns a bearer token for the specified SRP, logging in if needed. * - * @returns profile for the session. + * When called without `entropySourceId`, returns the primary (first) SRP's + * access token, which is effectively the canonical + * profile's token that can be used by alias-aware consumers for cross-SRP + * operations. + * + * @param entropySourceId - The entropy source ID. Omit for the primary SRP. + * @returns The OIDC access token. */ export type AuthenticationControllerGetBearerTokenAction = { type: `AuthenticationController:getBearerToken`; From c58ff4ca218735f6a5ab901daa0fe65d8a92e9d0 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 17 Apr 2026 16:39:12 +0200 Subject: [PATCH 7/9] fix: lint --- .../controllers/authentication/AuthenticationController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 044e73d1b17..14d0b69afb1 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -230,7 +230,7 @@ describe('AuthenticationController', () => { await controller.performSignIn(); - mockEndpoints.mockPairProfilesUrl.done(); + expect(mockEndpoints.mockPairProfilesUrl.isDone()).toBe(true); }); it('does not call pairProfiles when only 1 SRP exists', async () => { From 1711ba4fd4c1132f38269c4363766caa8fea3e3f Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 17 Apr 2026 16:45:31 +0200 Subject: [PATCH 8/9] fix: eslint --- .../src/sdk/__fixtures__/auth.ts | 20 ++++++---- .../flow-srp.test.ts | 40 +++++++++++-------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts index d5fd681fcda..f720d3d0620 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts @@ -21,7 +21,7 @@ type MockReply = { body?: nock.Body; }; -export const handleMockNonce = (mockReply?: MockReply) => { +export const handleMockNonce = (mockReply?: MockReply): nock.Scope => { const reply = mockReply ?? { status: 200, body: MOCK_NONCE_RESPONSE }; const mockNonceEndpoint = nock(MOCK_NONCE_URL) @@ -33,7 +33,7 @@ export const handleMockNonce = (mockReply?: MockReply) => { return mockNonceEndpoint; }; -export const handleMockSiweLogin = (mockReply?: MockReply) => { +export const handleMockSiweLogin = (mockReply?: MockReply): nock.Scope => { const reply = mockReply ?? { status: 200, body: MOCK_SIWE_LOGIN_RESPONSE }; const mockLoginEndpoint = nock(MOCK_SIWE_LOGIN_URL) .persist() @@ -43,7 +43,9 @@ export const handleMockSiweLogin = (mockReply?: MockReply) => { return mockLoginEndpoint; }; -export const handleMockPairIdentifiers = (mockReply?: MockReply) => { +export const handleMockPairIdentifiers = ( + mockReply?: MockReply, +): nock.Scope => { const reply = mockReply ?? { status: 204 }; const mockPairIdentifiersEndpoint = nock(MOCK_PAIR_IDENTIFIERS_URL) .persist() @@ -53,7 +55,7 @@ export const handleMockPairIdentifiers = (mockReply?: MockReply) => { return mockPairIdentifiersEndpoint; }; -export const handleMockPairProfiles = (mockReply?: MockReply) => { +export const handleMockPairProfiles = (mockReply?: MockReply): nock.Scope => { const reply = mockReply ?? { status: 200, body: MOCK_PAIR_PROFILES_RESPONSE, @@ -66,7 +68,7 @@ export const handleMockPairProfiles = (mockReply?: MockReply) => { return mockPairProfilesEndpoint; }; -export const handleMockSrpLogin = (mockReply?: MockReply) => { +export const handleMockSrpLogin = (mockReply?: MockReply): nock.Scope => { const reply = mockReply ?? { status: 200, body: MOCK_SRP_LOGIN_RESPONSE }; const mockLoginEndpoint = nock(MOCK_SRP_LOGIN_URL) .persist() @@ -76,7 +78,7 @@ export const handleMockSrpLogin = (mockReply?: MockReply) => { return mockLoginEndpoint; }; -export const handleMockOAuth2Token = (mockReply?: MockReply) => { +export const handleMockOAuth2Token = (mockReply?: MockReply): nock.Scope => { const reply = mockReply ?? { status: 200, body: MOCK_OIDC_TOKEN_RESPONSE }; const mockTokenEndpoint = nock(MOCK_OIDC_TOKEN_URL) .persist() @@ -86,7 +88,9 @@ export const handleMockOAuth2Token = (mockReply?: MockReply) => { return mockTokenEndpoint; }; -export const handleMockUserProfileLineage = (mockReply?: MockReply) => { +export const handleMockUserProfileLineage = ( + mockReply?: MockReply, +): nock.Scope => { const reply = mockReply ?? { status: 200, body: MOCK_USER_PROFILE_LINEAGE_RESPONSE, @@ -108,7 +112,7 @@ export const arrangeAuthAPIs = (options?: { mockPairIdentifiers?: MockReply; mockPairProfiles?: MockReply; mockUserProfileLineageUrl?: MockReply; -}) => { +}): Record => { const mockNonceUrl = handleMockNonce(options?.mockNonceUrl); const mockOAuth2TokenUrl = handleMockOAuth2Token(options?.mockOAuth2TokenUrl); const mockSrpLoginUrl = handleMockSrpLogin(options?.mockSrpLoginUrl); diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts index e392eafed08..ee78125a91e 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts @@ -22,16 +22,17 @@ const mockAuthenticate = jest.fn(); const mockAuthorizeOIDC = jest.fn(); jest.mock('./services', () => ({ - authenticate: (...args: unknown[]) => mockAuthenticate(...args), - authorizeOIDC: (...args: unknown[]) => mockAuthorizeOIDC(...args), - getNonce: (...args: unknown[]) => mockGetNonce(...args), + authenticate: (...args: unknown[]): unknown => mockAuthenticate(...args), + authorizeOIDC: (...args: unknown[]): unknown => mockAuthorizeOIDC(...args), + getNonce: (...args: unknown[]): unknown => mockGetNonce(...args), getUserProfileLineage: jest.fn(), })); // Mock computeIdentifierId const mockComputeIdentifierId = jest.fn(); jest.mock('./utils/identifier', () => ({ - computeIdentifierId: (...args: unknown[]) => mockComputeIdentifierId(...args), + computeIdentifierId: (...args: unknown[]): unknown => + mockComputeIdentifierId(...args), })); describe('SRPJwtBearerAuth rate limit handling', () => { @@ -68,25 +69,26 @@ describe('SRPJwtBearerAuth rate limit handling', () => { }; // Helper to create a rate limit error - const createRateLimitError = (retryAfterMs?: number) => + const createRateLimitError = (retryAfterMs?: number): RateLimitedError => new RateLimitedError('rate limited', retryAfterMs); const createAuth = (overrides?: { cooldownDefaultMs?: number; maxLoginRetries?: number; - }) => { + }): { auth: SRPJwtBearerAuth; store: { value: LoginResponse | null } } => { const store: { value: LoginResponse | null } = { value: null }; const auth = new SRPJwtBearerAuth(config, { storage: { - getLoginResponse: async () => store.value, - setLoginResponse: async (val) => { + getLoginResponse: async (): Promise => + store.value, + setLoginResponse: async (val): Promise => { store.value = val; }, }, signing: { - getIdentifier: async () => 'identifier-1', - signMessage: async () => 'signature-1', + getIdentifier: async (): Promise => 'identifier-1', + signMessage: async (): Promise => 'signature-1', }, rateLimitRetry: overrides, }); @@ -94,7 +96,7 @@ describe('SRPJwtBearerAuth rate limit handling', () => { return { auth, store }; }; - beforeEach(() => { + beforeEach((): void => { jest.clearAllMocks(); mockGetNonce.mockResolvedValue(MOCK_NONCE_RESPONSE); mockAuthenticate.mockResolvedValue(MOCK_AUTH_RESPONSE); @@ -237,26 +239,30 @@ describe('SRPJwtBearerAuth profileId resolution', () => { obtainedAt: Date.now(), }; - const createAuth = () => { + const createAuth = (): { + auth: SRPJwtBearerAuth; + store: { value: LoginResponse | null }; + } => { const store: { value: LoginResponse | null } = { value: null }; const auth = new SRPJwtBearerAuth(config, { storage: { - getLoginResponse: async () => store.value, - setLoginResponse: async (val) => { + getLoginResponse: async (): Promise => + store.value, + setLoginResponse: async (val): Promise => { store.value = val; }, }, signing: { - getIdentifier: async () => 'MOCK_PUBLIC_KEY', - signMessage: async () => 'signature-1', + getIdentifier: async (): Promise => 'MOCK_PUBLIC_KEY', + signMessage: async (): Promise => 'signature-1', }, }); return { auth, store }; }; - beforeEach(() => { + beforeEach((): void => { jest.clearAllMocks(); mockGetNonce.mockResolvedValue(MOCK_NONCE_RESPONSE); mockAuthorizeOIDC.mockResolvedValue(MOCK_OIDC_RESPONSE); From eb46ce4de9dc150d93f1e9d30d08a4d0d6e3521c Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 17 Apr 2026 16:53:28 +0200 Subject: [PATCH 9/9] fix: update suppressions --- eslint-suppressions.json | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 97a623f347f..8b542b514b7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1850,11 +1850,6 @@ "count": 2 } }, - "packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 7 - } - }, "packages/profile-sync-controller/src/sdk/__fixtures__/test-utils.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 4 @@ -1876,11 +1871,6 @@ "count": 2 } }, - "packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 9 - } - }, "packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 2