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
8 changes: 8 additions & 0 deletions packages/profile-sync-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `authenticationControllerSelectors` for best-effort reads of cached session identity from `AuthenticationController` state ([#9604](https://github.com/MetaMask/core/pull/9604))
- `selectSrpSessionData` — raw `srpSessionData` map
- `selectSessionData` — primary (first) SRP session entry
- `selectCanonicalProfileId` — cached `canonicalProfileId`, treating `''` as missing
- Intended for sync consumers (e.g. Braze) that should not wait on `getSessionProfile()`. Same staleness as a valid cached session; use `refreshCanonicalProfileId()` when freshness is required.

## [28.3.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { AuthenticationController as Controller };
export default AuthenticationController;
export * from './AuthenticationController.js';
export * as Mocks from './mocks/index.js';
export { authenticationControllerSelectors } from './selectors.js';

export type {
AuthenticationControllerPerformSignInAction,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { LoginResponse } from '../../sdk/index.js';
import type { AuthenticationControllerState } from './AuthenticationController.js';
import { authenticationControllerSelectors } from './selectors.js';

const createLoginResponse = (
overrides: Partial<LoginResponse['profile']> = {},
): LoginResponse => ({
token: {
accessToken: 'access-token',
expiresIn: 3600,
obtainedAt: Date.now(),
},
profile: {
identifierId: 'identifier-id',
profileId: 'profile-id',
canonicalProfileId: 'canonical-profile-id',
metaMetricsId: 'metametrics-id',
...overrides,
},
});

describe('authenticationControllerSelectors', () => {
describe('selectSrpSessionData', () => {
it('returns undefined when srpSessionData is missing', () => {
const state: AuthenticationControllerState = {
isSignedIn: false,
};

expect(
authenticationControllerSelectors.selectSrpSessionData(state),
).toBeUndefined();
});

it('returns the srpSessionData map when present', () => {
const srpSessionData = {
'entropy-1': createLoginResponse(),
};
const state: AuthenticationControllerState = {
isSignedIn: true,
srpSessionData,
};

expect(
authenticationControllerSelectors.selectSrpSessionData(state),
).toBe(srpSessionData);
});
});

describe('selectSessionData', () => {
it('returns undefined when srpSessionData is missing', () => {
const state: AuthenticationControllerState = {
isSignedIn: false,
};

expect(
authenticationControllerSelectors.selectSessionData(state),
).toBeUndefined();
});

it('returns undefined when srpSessionData is empty', () => {
const state: AuthenticationControllerState = {
isSignedIn: true,
srpSessionData: {},
};

expect(
authenticationControllerSelectors.selectSessionData(state),
).toBeUndefined();
});

it('returns the first session entry', () => {
const primary = createLoginResponse({ profileId: 'primary-profile' });
const secondary = createLoginResponse({ profileId: 'secondary-profile' });
const state: AuthenticationControllerState = {
isSignedIn: true,
srpSessionData: {
'entropy-1': primary,
'entropy-2': secondary,
},
};

expect(authenticationControllerSelectors.selectSessionData(state)).toBe(
primary,
);
});
});

describe('selectCanonicalProfileId', () => {
it('returns undefined when srpSessionData is missing', () => {
const state: AuthenticationControllerState = {
isSignedIn: false,
};

expect(
authenticationControllerSelectors.selectCanonicalProfileId(state),
).toBeUndefined();
});

it('returns undefined when canonicalProfileId is an empty string', () => {
const state: AuthenticationControllerState = {
isSignedIn: true,
srpSessionData: {
'entropy-1': createLoginResponse({ canonicalProfileId: '' }),
},
};

expect(
authenticationControllerSelectors.selectCanonicalProfileId(state),
).toBeUndefined();
});

it('returns the canonical profile ID on the happy path', () => {
const state: AuthenticationControllerState = {
isSignedIn: true,
srpSessionData: {
'entropy-1': createLoginResponse({
canonicalProfileId: 'canonical-profile-id',
}),
},
};

expect(
authenticationControllerSelectors.selectCanonicalProfileId(state),
).toBe('canonical-profile-id');
});

it('returns the shared canonical when multiple SRP sessions are present after propagation', () => {
const state: AuthenticationControllerState = {
isSignedIn: true,
srpSessionData: {
'entropy-1': createLoginResponse({
profileId: 'original-1',
canonicalProfileId: 'shared-canonical',
}),
'entropy-2': createLoginResponse({
profileId: 'original-2',
canonicalProfileId: 'shared-canonical',
}),
},
};

expect(
authenticationControllerSelectors.selectCanonicalProfileId(state),
).toBe('shared-canonical');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { LoginResponse } from '../../sdk/index.js';
import type { AuthenticationControllerState } from './AuthenticationController.js';

/**
* Selects the raw SRP session map from AuthenticationController state.
*
* @param state - The AuthenticationController state.
* @returns The `srpSessionData` map, or `undefined` when not signed in / cleared.
*/
const selectSrpSessionData = (
state: AuthenticationControllerState,
): AuthenticationControllerState['srpSessionData'] => state.srpSessionData;

/**
* Selects the primary (first) SRP session entry from cached `srpSessionData`.
*
* Uses the first `Object.entries` value — the same "primary / first written"
* convention as extension `selectSessionData`. During `performSignIn`, sessions
* are written sequentially with the primary SRP first.
*
* @param state - The AuthenticationController state.
* @returns The primary session login response, or `undefined` if unavailable.
*/
const selectSessionData = (
state: AuthenticationControllerState,
): LoginResponse | undefined => {
const srpSessionData = selectSrpSessionData(state);
if (!srpSessionData) {
return undefined;
}
const firstEntry = Object.entries(srpSessionData)[0];
return firstEntry?.[1];
};

/**
* Selects the best-effort cached canonical profile ID.
*
* Reads from the primary SRP session in `srpSessionData` without triggering
* login or unlock checks. Suitable for non-security consumers.
*
* Empty string is treated as missing (`undefined`) because the controller sets
* `canonicalProfileId` to `''` when invalidating a session
* (`#invalidateSrpSession`).
*
* Staleness: same as a valid cached `getSessionProfile()` result. Cross-device
* pairing can change the canonical without updating this cache until the next
* login or pair. Use `refreshCanonicalProfileId()` when freshness is required.
*
* @param state - The AuthenticationController state.
* @returns The cached canonical profile ID, or `undefined` if unavailable.
*/
const selectCanonicalProfileId = (
state: AuthenticationControllerState,
): string | undefined => {
const canonicalProfileId =
selectSessionData(state)?.profile?.canonicalProfileId;
// `''` is used by `#invalidateSrpSession` to mark the session as invalid.
if (!canonicalProfileId) {
return undefined;
}
return canonicalProfileId;
};

/**
* Selectors for AuthenticationController state.
* These take controller state (not client `RootState`) and can be composed
* with Redux `createSelector` in clients.
*/
export const authenticationControllerSelectors = {
selectSrpSessionData,
selectSessionData,
selectCanonicalProfileId,
};