From 0396cc35271911bfea8af5e800f338f5de34964c Mon Sep 17 00:00:00 2001 From: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:44:04 +0200 Subject: [PATCH 1/3] feat(profile-sync-controller): add authentication selectors for cached session data --- packages/profile-sync-controller/CHANGELOG.md | 8 + .../src/controllers/authentication/index.ts | 1 + .../authentication/selectors.test.ts | 147 ++++++++++++++++++ .../controllers/authentication/selectors.ts | 70 +++++++++ 4 files changed, 226 insertions(+) create mode 100644 packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts create mode 100644 packages/profile-sync-controller/src/controllers/authentication/selectors.ts diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 94f88ea6a8d..617872b41c5 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -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 + - `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 diff --git a/packages/profile-sync-controller/src/controllers/authentication/index.ts b/packages/profile-sync-controller/src/controllers/authentication/index.ts index 9ac35a37056..c9b052f2729 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/index.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/index.ts @@ -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, diff --git a/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts b/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts new file mode 100644 index 00000000000..1883093f11a --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts @@ -0,0 +1,147 @@ +import type { LoginResponse } from '../../sdk'; +import type { AuthenticationControllerState } from './AuthenticationController'; +import { authenticationControllerSelectors } from './selectors'; + +const createLoginResponse = ( + overrides: Partial = {}, +): 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'); + }); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/authentication/selectors.ts b/packages/profile-sync-controller/src/controllers/authentication/selectors.ts new file mode 100644 index 00000000000..a3da679b345 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/authentication/selectors.ts @@ -0,0 +1,70 @@ +import type { LoginResponse } from '../../sdk'; +import type { AuthenticationControllerState } from './AuthenticationController'; + +/** + * 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. + return canonicalProfileId ? canonicalProfileId : undefined; +}; + +/** + * 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, +}; From d2832100a447b522622a24918e32b2e414492f56 Mon Sep 17 00:00:00 2001 From: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:51:01 +0200 Subject: [PATCH 2/3] chore(profile-sync-controller): link changelog entry to PR #9604 Co-authored-by: Cursor --- packages/profile-sync-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 617872b41c5..b9eea3814c9 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `authenticationControllerSelectors` for best-effort reads of cached session identity from `AuthenticationController` state +- 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 From 064aea96da50fc22b75c7fcec8fadad7f495d756 Mon Sep 17 00:00:00 2001 From: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:26:51 +0200 Subject: [PATCH 3/3] chore: fix lint --- .../src/controllers/authentication/selectors.test.ts | 6 +++--- .../src/controllers/authentication/selectors.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts b/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts index 1883093f11a..cb3d118354e 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/selectors.test.ts @@ -1,6 +1,6 @@ -import type { LoginResponse } from '../../sdk'; -import type { AuthenticationControllerState } from './AuthenticationController'; -import { authenticationControllerSelectors } from './selectors'; +import type { LoginResponse } from '../../sdk/index.js'; +import type { AuthenticationControllerState } from './AuthenticationController.js'; +import { authenticationControllerSelectors } from './selectors.js'; const createLoginResponse = ( overrides: Partial = {}, diff --git a/packages/profile-sync-controller/src/controllers/authentication/selectors.ts b/packages/profile-sync-controller/src/controllers/authentication/selectors.ts index a3da679b345..189cd1337f1 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/selectors.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/selectors.ts @@ -1,5 +1,5 @@ -import type { LoginResponse } from '../../sdk'; -import type { AuthenticationControllerState } from './AuthenticationController'; +import type { LoginResponse } from '../../sdk/index.js'; +import type { AuthenticationControllerState } from './AuthenticationController.js'; /** * Selects the raw SRP session map from AuthenticationController state. @@ -55,7 +55,10 @@ const selectCanonicalProfileId = ( const canonicalProfileId = selectSessionData(state)?.profile?.canonicalProfileId; // `''` is used by `#invalidateSrpSession` to mark the session as invalid. - return canonicalProfileId ? canonicalProfileId : undefined; + if (!canonicalProfileId) { + return undefined; + } + return canonicalProfileId; }; /**