Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/profile-sync-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { AuthenticationController } from './AuthenticationController';
import type {
AuthenticationControllerMessenger,
AuthenticationControllerState,
ProfileSignInInfo,
} from './AuthenticationController';
import {
MOCK_LOGIN_RESPONSE,
Expand Down Expand Up @@ -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,
},
};
Expand Down Expand Up @@ -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();

expect(mockEndpoints.mockPairProfilesUrl.isDone()).toBe(true);
});

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
*
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading