diff --git a/README.md b/README.md index 43ca303c4eb..80b659b0a29 100644 --- a/README.md +++ b/README.md @@ -549,6 +549,7 @@ linkStyle default opacity:0.5 profile_sync_controller --> base_controller; profile_sync_controller --> keyring_controller; profile_sync_controller --> messenger; + profile_sync_controller --> seedless_onboarding_controller; ramps_controller --> base_controller; ramps_controller --> controller_utils; ramps_controller --> messenger; diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 94f88ea6a8d..9394571ee18 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Tag `/srp/login` with SRP slot and social provider metadata ([#9741](https://github.com/MetaMask/core/pull/9741)) + - Append `primary` | `secondary` to `raw_message` (`metamask:::`; `primary` for the first HD entropy source). Include `metametrics.identifier_type` (`SRP` | `GOOGLE` | `APPLE` | `TELEGRAM`; social vault primary maps from `SeedlessOnboardingController.state.authConnection`). + ## [28.3.0] ### Added diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 598d7605a85..9b84bf63e10 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -112,6 +112,7 @@ "@metamask/base-controller": "^9.1.0", "@metamask/keyring-controller": "^27.1.0", "@metamask/messenger": "^2.0.0", + "@metamask/seedless-onboarding-controller": "^10.1.0", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", "@metamask/snaps-utils": "^12.1.2", 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 26612ce3cc2..3a9c3e87471 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -29,6 +29,15 @@ const MOCK_ENTROPY_SOURCE_IDS = [ 'MOCK_ENTROPY_SOURCE_ID2', ]; +type SrpLoginRequestBody = { + metametrics?: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- API field + identifier_type?: string; + }; + // eslint-disable-next-line @typescript-eslint/naming-convention -- API field + raw_message?: string; +}; + /** * Return mock state for the scenario where a user is signed in. * @@ -126,7 +135,8 @@ describe('AuthenticationController', () => { // `#getCanonicalProfileId` → `#getPrimaryEntropySourceId` (cold cache). expect(mockSnapGetAllPublicKeys).toHaveBeenCalledTimes(2); expect(mockSnapGetPublicKey).toHaveBeenCalledTimes(2); - expect(mockSnapSignMessage).toHaveBeenCalledTimes(1); + // Primary and secondary tags produce distinct messages, so both are signed. + expect(mockSnapSignMessage).toHaveBeenCalledTimes(2); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); mockEndpoints.mockOAuth2TokenUrl.done(); @@ -155,7 +165,8 @@ describe('AuthenticationController', () => { await controller.performSignIn(); controller.performSignOut(); await controller.performSignIn(); - expect(mockSnapSignMessage).toHaveBeenCalledTimes(1); + // Both tagged login messages are cached across sign-out / sign-in. + expect(mockSnapSignMessage).toHaveBeenCalledTimes(2); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); mockEndpoints.mockOAuth2TokenUrl.done(); @@ -165,6 +176,225 @@ describe('AuthenticationController', () => { } }); + it('signs primary and secondary login tags for multi-SRP wallets', async () => { + const metametrics = createMockAuthMetaMetrics(); + arrangeAuthAPIs(); + const { messenger, mockSnapSignMessage } = + createMockAuthenticationMessenger(); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + const signedMessages = mockSnapSignMessage.mock.calls.map( + (call) => (call[0] as { message: string }).message, + ); + expect(signedMessages).toStrictEqual( + expect.arrayContaining([ + expect.stringMatching(/^metamask:[^:]+:[^:]+:primary$/u), + expect.stringMatching(/^metamask:[^:]+:[^:]+:secondary$/u), + ]), + ); + }); + + it('signs primary tag for the primary SRP even when a seedless vault exists', async () => { + const metametrics = createMockAuthMetaMetrics(); + arrangeAuthAPIs(); + const { + messenger, + mockSnapSignMessage, + mockSnapGetAllPublicKeys, + mockSeedlessOnboardingGetState, + } = createMockAuthenticationMessenger(); + mockSnapGetAllPublicKeys.mockResolvedValue([ + [MOCK_ENTROPY_SOURCE_IDS[0], 'MOCK_PUBLIC_KEY'], + ]); + mockSeedlessOnboardingGetState.mockReturnValue({ + vault: 'encrypted', + authConnection: 'google', + }); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + expect(mockSnapSignMessage).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching(/^metamask:[^:]+:[^:]+:primary$/u), + }), + ); + }); + + it('sends GOOGLE identifier_type for primary SRP on a social vault', async () => { + const metametrics = createMockAuthMetaMetrics(); + const loginBodies: SrpLoginRequestBody[] = []; + arrangeAuthAPIs({ + onSrpLoginBody: (body) => { + loginBodies.push( + (typeof body === 'string' + ? JSON.parse(body) + : body) as SrpLoginRequestBody, + ); + }, + }); + const { + messenger, + mockSnapGetAllPublicKeys, + mockSeedlessOnboardingGetState, + } = createMockAuthenticationMessenger(); + mockSnapGetAllPublicKeys.mockResolvedValue([ + [MOCK_ENTROPY_SOURCE_IDS[0], 'MOCK_PUBLIC_KEY'], + ]); + mockSeedlessOnboardingGetState.mockReturnValue({ + vault: 'encrypted', + authConnection: 'google', + }); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + expect(loginBodies).toHaveLength(1); + expect(loginBodies[0]?.metametrics?.identifier_type).toBe('GOOGLE'); + expect(loginBodies[0]?.raw_message).toMatch( + /^metamask:[^:]+:[^:]+:primary$/u, + ); + }); + + it('sends SRP identifier_type for secondary SRPs in a social vault', async () => { + const metametrics = createMockAuthMetaMetrics(); + const loginBodies: SrpLoginRequestBody[] = []; + arrangeAuthAPIs({ + onSrpLoginBody: (body) => { + loginBodies.push( + (typeof body === 'string' + ? JSON.parse(body) + : body) as SrpLoginRequestBody, + ); + }, + }); + const { messenger, mockSeedlessOnboardingGetState } = + createMockAuthenticationMessenger(); + mockSeedlessOnboardingGetState.mockReturnValue({ + vault: 'encrypted', + authConnection: 'telegram', + }); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + expect(loginBodies).toHaveLength(2); + const byTag = Object.fromEntries( + loginBodies.map((body) => { + const tag = body.raw_message?.split(':').at(-1); + return [tag, body.metametrics?.identifier_type]; + }), + ); + expect(byTag).toStrictEqual({ + primary: 'TELEGRAM', + secondary: 'SRP', + }); + }); + + it.each([ + { + name: 'APPLE for apple social vault', + seedlessState: { vault: 'encrypted', authConnection: 'apple' }, + expectedIdentifierType: 'APPLE', + }, + { + name: 'SRP when social vault has unrecognized authConnection', + seedlessState: { vault: 'encrypted', authConnection: 'unknown' }, + expectedIdentifierType: 'SRP', + }, + { + name: 'SRP when seedless vault is absent', + seedlessState: { vault: undefined, authConnection: 'google' }, + expectedIdentifierType: 'SRP', + }, + ])('sends $name', async ({ seedlessState, expectedIdentifierType }) => { + const metametrics = createMockAuthMetaMetrics(); + const loginBodies: SrpLoginRequestBody[] = []; + arrangeAuthAPIs({ + onSrpLoginBody: (body) => { + loginBodies.push( + (typeof body === 'string' + ? JSON.parse(body) + : body) as SrpLoginRequestBody, + ); + }, + }); + const { + messenger, + mockSnapGetAllPublicKeys, + mockSeedlessOnboardingGetState, + } = createMockAuthenticationMessenger(); + mockSnapGetAllPublicKeys.mockResolvedValue([ + [MOCK_ENTROPY_SOURCE_IDS[0], 'MOCK_PUBLIC_KEY'], + ]); + mockSeedlessOnboardingGetState.mockReturnValue(seedlessState); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + expect(loginBodies).toHaveLength(1); + expect(loginBodies[0]?.metametrics?.identifier_type).toBe( + expectedIdentifierType, + ); + }); + + it('sends SRP identifier_type when SeedlessOnboarding getState fails', async () => { + const metametrics = createMockAuthMetaMetrics(); + const loginBodies: SrpLoginRequestBody[] = []; + arrangeAuthAPIs({ + onSrpLoginBody: (body) => { + loginBodies.push( + (typeof body === 'string' + ? JSON.parse(body) + : body) as SrpLoginRequestBody, + ); + }, + }); + const { + messenger, + mockSnapGetAllPublicKeys, + mockSeedlessOnboardingGetState, + } = createMockAuthenticationMessenger(); + mockSnapGetAllPublicKeys.mockResolvedValue([ + [MOCK_ENTROPY_SOURCE_IDS[0], 'MOCK_PUBLIC_KEY'], + ]); + mockSeedlessOnboardingGetState.mockImplementation(() => { + throw new Error('SeedlessOnboardingController unavailable'); + }); + + const controller = new AuthenticationController({ + messenger, + metametrics, + }); + + await controller.performSignIn(); + + expect(loginBodies).toHaveLength(1); + expect(loginBodies[0]?.metametrics?.identifier_type).toBe('SRP'); + }); + it('should error when nonce endpoint fails', async () => { expect(true).toBe(true); await testAndAssertFailingEndpoints('nonce'); @@ -1399,7 +1629,11 @@ function createAuthenticationMessenger() { }); rootMessenger.delegate({ messenger, - actions: ['KeyringController:getState', 'SnapController:handleRequest'], + actions: [ + 'KeyringController:getState', + 'SnapController:handleRequest', + 'SeedlessOnboardingController:getState', + ], events: ['KeyringController:lock', 'KeyringController:unlock'], }); @@ -1429,6 +1663,10 @@ function createMockAuthenticationMessenger() { .fn() .mockReturnValue({ isUnlocked: true }); + const mockSeedlessOnboardingGetState = jest + .fn() + .mockReturnValue({ vault: null }); + mockCall.mockImplementation((...args) => { const [actionType, params] = args; if (actionType === 'SnapController:handleRequest') { @@ -1447,7 +1685,7 @@ function createMockAuthenticationMessenger() { } if (params?.request.method === 'signMessage') { - return mockSnapSignMessage(); + return mockSnapSignMessage(params.request.params); } throw new Error( @@ -1461,6 +1699,10 @@ function createMockAuthenticationMessenger() { return mockKeyringControllerGetState(); } + if (actionType === 'SeedlessOnboardingController:getState') { + return mockSeedlessOnboardingGetState(); + } + throw new Error( `MOCK_FAIL - unsupported messenger call: ${actionType as string}`, ); @@ -1473,6 +1715,7 @@ function createMockAuthenticationMessenger() { mockSnapGetAllPublicKeys, mockSnapSignMessage, mockKeyringControllerGetState, + mockSeedlessOnboardingGetState, }; } diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index e91fbb1ad1a..36701920fd4 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -10,13 +10,17 @@ import type { KeyringControllerUnlockEvent, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; +import type { SeedlessOnboardingControllerGetStateAction } from '@metamask/seedless-onboarding-controller'; +import { AuthConnection } from '@metamask/seedless-onboarding-controller'; import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; import type { Json } from '@metamask/utils'; import type { + LoginIdentifierType, LoginResponse, ProfileAlias, SRPInterface, + SrpLoginTag, UserProfile, UserProfileLineage, } from '../../sdk/index.js'; @@ -149,7 +153,8 @@ export type Events = // Allowed Actions type AllowedActions = | KeyringControllerGetStateAction - | SnapControllerHandleRequestAction; + | SnapControllerHandleRequestAction + | SeedlessOnboardingControllerGetStateAction; type AllowedEvents = KeyringControllerLockEvent | KeyringControllerUnlockEvent; @@ -249,6 +254,8 @@ export class AuthenticationController extends BaseController< getIdentifier: this.#snapGetPublicKey.bind(this), signMessage: this.#snapSignMessage.bind(this), }, + getLoginTag: this.#getLoginTag.bind(this), + getLoginIdentifierType: this.#getLoginIdentifierType.bind(this), metametrics: this.#metametrics, }, ); @@ -323,6 +330,87 @@ export class AuthenticationController extends BaseController< return this.#cachedPrimaryEntropySourceId; } + /** + * Resolves the SRP login tag for `raw_message`. + * + * - `primary` for the first HD entropy source + * - `secondary` for any other entropy source + * + * @param entropySourceId - Entropy source for this login attempt. + * @returns The login tag to append to the signed message. + */ + async #getLoginTag(entropySourceId?: string): Promise { + const primaryEntropySourceId = await this.#getPrimaryEntropySourceId(); + const resolvedId = entropySourceId ?? primaryEntropySourceId; + + return resolvedId === primaryEntropySourceId ? 'primary' : 'secondary'; + } + + /** + * Resolves metametrics `identifier_type` for `/srp/login` + * (`SRP` | `GOOGLE` | `APPLE` | `TELEGRAM`). + * + * This is the auth method for the entropy source, independent of + * {@link SrpLoginTag} (`primary` / `secondary`). + * + * SeedlessOnboarding currently exposes a single vault-level + * `authConnection`, which always backs the primary entropy source. Non- + * primary sources therefore return `SRP`. If social identities become + * per-entropy later, resolve from that metadata instead of assuming + * primary === social. + * + * Soft-fails to `SRP` when SeedlessOnboarding is not registered. + * + * @param entropySourceId - Entropy source for this login attempt. + * @returns The login identifier type. + */ + async #getLoginIdentifierType( + entropySourceId?: string, + ): Promise { + const primaryEntropySourceId = await this.#getPrimaryEntropySourceId(); + const resolvedId = entropySourceId ?? primaryEntropySourceId; + + // Social vault authConnection is only associated with the primary source. + if (resolvedId !== primaryEntropySourceId) { + return 'SRP'; + } + + return this.#resolveSocialIdentifierType(); + } + + /** + * Maps `SeedlessOnboardingController.state.authConnection` to a login + * identifier type when a social vault is present. + * + * Returns `SRP` if there is no vault, the provider is unrecognized, or + * SeedlessOnboardingController is unavailable on the messenger. + * + * @returns The social provider identifier type, or `SRP`. + */ + #resolveSocialIdentifierType(): LoginIdentifierType { + try { + const { vault, authConnection } = this.messenger.call( + 'SeedlessOnboardingController:getState', + ); + if (vault === null || vault === undefined) { + return 'SRP'; + } + + switch (authConnection) { + case AuthConnection.Google: + return 'GOOGLE'; + case AuthConnection.Apple: + return 'APPLE'; + case AuthConnection.Telegram: + return 'TELEGRAM'; + default: + return 'SRP'; + } + } catch { + return 'SRP'; + } + } + public async performSignIn(): Promise { this.#assertIsUnlocked('performSignIn'); diff --git a/packages/profile-sync-controller/src/controllers/authentication/mocks/mockResponses.ts b/packages/profile-sync-controller/src/controllers/authentication/mocks/mockResponses.ts index 2350b2e2331..aec914204df 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/mocks/mockResponses.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/mocks/mockResponses.ts @@ -56,8 +56,10 @@ export const getMockAuthLoginResponse = (): MockResponse => { response: (requestJsonBody?: { raw_message: string; }): typeof MOCK_LOGIN_RESPONSE => { - const splittedRawMessage = requestJsonBody?.raw_message.split(':'); - const e2eIdentifier = splittedRawMessage?.[splittedRawMessage.length - 2]; + // Format: metamask::[:]. Nonce (index 1) carries the + // e2e identifier set by getMockAuthNonceResponse — stable across the + // optional login tag segment. + const e2eIdentifier = requestJsonBody?.raw_message.split(':')[1]; return { ...MOCK_LOGIN_RESPONSE, diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts index 1879d580165..1f0188d3138 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts @@ -70,11 +70,17 @@ export const handleMockPairProfiles = (mockReply?: MockReply): nock.Scope => { return mockPairProfilesEndpoint; }; -export const handleMockSrpLogin = (mockReply?: MockReply): nock.Scope => { +export const handleMockSrpLogin = ( + mockReply?: MockReply, + onBody?: (body: unknown) => void, +): nock.Scope => { const reply = mockReply ?? { status: 200, body: MOCK_SRP_LOGIN_RESPONSE }; const mockLoginEndpoint = nock(MOCK_SRP_LOGIN_URL) .persist() - .post('') + .post('', (body) => { + onBody?.(body); + return true; + }) .reply(reply.status, reply.body); return mockLoginEndpoint; @@ -130,6 +136,7 @@ export const arrangeAuthAPIs = (options?: { mockPairProfiles?: MockReply; mockUserProfileLineageUrl?: MockReply; mockCustomerServiceTokenUrl?: MockReply; + onSrpLoginBody?: (body: unknown) => void; }): { mockNonceUrl: nock.Scope; mockOAuth2TokenUrl: nock.Scope; @@ -142,7 +149,10 @@ export const arrangeAuthAPIs = (options?: { } => { const mockNonceUrl = handleMockNonce(options?.mockNonceUrl); const mockOAuth2TokenUrl = handleMockOAuth2Token(options?.mockOAuth2TokenUrl); - const mockSrpLoginUrl = handleMockSrpLogin(options?.mockSrpLoginUrl); + const mockSrpLoginUrl = handleMockSrpLogin( + options?.mockSrpLoginUrl, + options?.onSrpLoginBody, + ); const mockSiweLoginUrl = handleMockSiweLogin(options?.mockSiweLoginUrl); const mockPairIdentifiersUrl = handleMockPairIdentifiers( options?.mockPairIdentifiers, 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 07e7e0fb31f..20eab69b452 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 @@ -650,3 +650,115 @@ describe('SRPJwtBearerAuth pairSrpProfiles deduplication', () => { expect(mockPairProfiles).toHaveBeenCalledTimes(2); }); }); + +describe('SRPJwtBearerAuth login tag', () => { + const config: AuthConfig & { type: AuthType.SRP } = { + type: AuthType.SRP, + env: Env.DEV, + platform: Platform.EXTENSION, + }; + + const MOCK_PROFILE: UserProfile = { + profileId: 'p1', + canonicalProfileId: 'p1', + metaMetricsId: 'm1', + identifierId: 'i1', + }; + + beforeEach((): void => { + jest.clearAllMocks(); + mockGetNonce.mockResolvedValue({ + nonce: 'nonce-1', + identifier: 'identifier-1', + expiresIn: 60, + }); + mockAuthenticate.mockResolvedValue({ + token: 'jwt-token', + expiresIn: 60, + profile: MOCK_PROFILE, + }); + mockAuthorizeOIDC.mockResolvedValue({ + accessToken: 'access', + expiresIn: 60, + obtainedAt: Date.now(), + }); + }); + + it('defaults to primary when getLoginTag is omitted', async () => { + const auth = new SRPJwtBearerAuth(config, { + storage: { + getLoginResponse: async (): Promise => null, + setLoginResponse: async (): Promise => undefined, + }, + signing: { + getIdentifier: async (): Promise => 'pubkey-1', + signMessage: async (): Promise => 'signature-1', + }, + }); + + await auth.getAccessToken(); + + expect(mockAuthenticate).toHaveBeenCalledWith( + 'metamask:nonce-1:pubkey-1:primary', + 'signature-1', + AuthType.SRP, + Env.DEV, + undefined, + 'SRP', + ); + }); + + it('appends the tag returned by getLoginTag', async () => { + const getLoginTag = jest.fn().mockResolvedValue('secondary'); + const auth = new SRPJwtBearerAuth(config, { + storage: { + getLoginResponse: async (): Promise => null, + setLoginResponse: async (): Promise => undefined, + }, + signing: { + getIdentifier: async (): Promise => 'pubkey-1', + signMessage: async (): Promise => 'signature-1', + }, + getLoginTag, + }); + + await auth.getAccessToken('entropy-secondary'); + + expect(getLoginTag).toHaveBeenCalledWith('entropy-secondary'); + expect(mockAuthenticate).toHaveBeenCalledWith( + 'metamask:nonce-1:pubkey-1:secondary', + 'signature-1', + AuthType.SRP, + Env.DEV, + undefined, + 'SRP', + ); + }); + + it('forwards getLoginIdentifierType to authenticate', async () => { + const getLoginIdentifierType = jest.fn().mockResolvedValue('GOOGLE'); + const auth = new SRPJwtBearerAuth(config, { + storage: { + getLoginResponse: async (): Promise => null, + setLoginResponse: async (): Promise => undefined, + }, + signing: { + getIdentifier: async (): Promise => 'pubkey-1', + signMessage: async (): Promise => 'signature-1', + }, + getLoginIdentifierType, + }); + + await auth.getAccessToken('entropy-primary'); + + expect(getLoginIdentifierType).toHaveBeenCalledWith('entropy-primary'); + expect(mockAuthenticate).toHaveBeenCalledWith( + 'metamask:nonce-1:pubkey-1:primary', + 'signature-1', + AuthType.SRP, + Env.DEV, + undefined, + 'GOOGLE', + ); + }); +}); 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 b2685d70c11..f52112638fc 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 @@ -25,7 +25,9 @@ import type { AuthStorageOptions, AuthType, IBaseAuth, + LoginIdentifierType, LoginResponse, + SrpLoginTag, UserProfile, UserProfileLineage, } from './types.js'; @@ -35,6 +37,18 @@ import * as timeUtils from './utils/time.js'; type JwtBearerAuth_SRP_Options = { storage: AuthStorageOptions; signing?: AuthSigningOptions; + /** + * Resolves the login tag for a given entropy source. Defaults to + * `'primary'` when omitted (SDK / EIP-6963 consumers). + */ + getLoginTag?: (entropySourceId?: string) => Promise; + /** + * Resolves the metametrics `identifier_type` for a given entropy source. + * Defaults to `'SRP'` when omitted. + */ + getLoginIdentifierType?: ( + entropySourceId?: string, + ) => Promise; rateLimitRetry?: { cooldownDefaultMs?: number; // default cooldown when 429 has no Retry-After maxLoginRetries?: number; // maximum number of login retries on rate limit @@ -104,6 +118,12 @@ export class SRPJwtBearerAuth implements IBaseAuth { // Maximum number of login retries on rate limit errors readonly #maxLoginRetries: number; + readonly #getLoginTag?: (entropySourceId?: string) => Promise; + + readonly #getLoginIdentifierType?: ( + entropySourceId?: string, + ) => Promise; + #customProvider?: Eip1193Provider; constructor( @@ -115,6 +135,8 @@ export class SRPJwtBearerAuth implements IBaseAuth { ) { this.#config = config; this.#customProvider = options.customProvider; + this.#getLoginTag = options.getLoginTag; + this.#getLoginIdentifierType = options.getLoginIdentifierType; this.#options = { storage: options.storage, signing: @@ -273,9 +295,13 @@ export class SRPJwtBearerAuth implements IBaseAuth { const publicKey = await this.getIdentifier(entropySourceId); const nonceRes = await getNonce(publicKey, this.#config.env); + const tag = (await this.#getLoginTag?.(entropySourceId)) ?? 'primary'; + const identifierType = + (await this.#getLoginIdentifierType?.(entropySourceId)) ?? 'SRP'; const rawMessage = this.#createSrpLoginRawMessage( nonceRes.nonce, publicKey, + tag, ); const signature = await this.signMessage(rawMessage, entropySourceId); @@ -286,6 +312,7 @@ export class SRPJwtBearerAuth implements IBaseAuth { this.#config.type, this.#config.env, this.#metametrics, + identifierType, ); // Resolve original profileId from aliases. @@ -390,7 +417,8 @@ export class SRPJwtBearerAuth implements IBaseAuth { #createSrpLoginRawMessage( nonce: string, publicKey: string, - ): `metamask:${string}:${string}` { - return `metamask:${nonce}:${publicKey}` as const; + tag: SrpLoginTag, + ): `metamask:${string}:${string}:${SrpLoginTag}` { + return `metamask:${nonce}:${publicKey}:${tag}` as const; } } 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 a34f2e40b5e..0d831a27b3b 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 @@ -501,6 +501,41 @@ describe('services', () => { metametrics: { metametrics_id: 'mm-id', agent: Platform.EXTENSION, + identifier_type: 'SRP', + }, + }), + }), + ); + }); + + it('should include identifier_type override in metametrics when provided', async () => { + const mockResponse = createMockResponse(mockAuthResponse); + mockFetch.mockResolvedValue(mockResponse); + + const mockMetametrics = { + getMetaMetricsId: jest.fn().mockResolvedValue('mm-id'), + agent: Platform.EXTENSION as Platform.EXTENSION, + }; + + await authenticate( + 'raw-message', + 'signature', + AuthType.SRP, + Env.DEV, + mockMetametrics, + 'TELEGRAM', + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + signature: 'signature', + raw_message: 'raw-message', + metametrics: { + metametrics_id: 'mm-id', + agent: Platform.EXTENSION, + identifier_type: 'TELEGRAM', }, }), }), @@ -539,6 +574,7 @@ describe('services', () => { metametrics_id: 'mm-id', agent: Platform.MOBILE, app_version: '12.34.5', + identifier_type: 'SRP', }, }), }), @@ -572,6 +608,7 @@ describe('services', () => { metametrics: { metametrics_id: 'mm-id', agent: Platform.EXTENSION, + identifier_type: 'SRP', }, }), }), 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 94b3c76d5d3..e9e18f53938 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 @@ -13,6 +13,7 @@ import { validatePairResponse } from '../utils/validate-pair-response.js'; import type { AccessToken, ErrorMessage, + LoginIdentifierType, ProfileAlias, UserProfile, UserProfileLineage, @@ -416,6 +417,8 @@ type Authentication = { * @param authType - authentication type/flow used * @param env - server environment * @param metametrics - optional metametrics + * @param identifierType - login identifier type included in metametrics + * (defaults to `SRP` when metametrics is present) * @returns Authentication Token */ export async function authenticate( @@ -424,6 +427,7 @@ export async function authenticate( authType: AuthType, env: Env, metametrics?: MetaMetricsAuth, + identifierType: LoginIdentifierType = 'SRP', ): Promise { const authenticationUrl = getAuthenticationUrl(authType, env); @@ -445,6 +449,7 @@ export async function authenticate( metametrics_id: await metametrics.getMetaMetricsId(), agent: metametrics.agent, app_version: await metametrics.getAppVersion?.(), + identifier_type: identifierType, }, } : {}), 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 9d2e35fbb2d..6fee779b67b 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 @@ -9,6 +9,25 @@ export enum AuthType { SiWE = 'SiWE', } +/** + * Login identifier type sent in the `/srp/login` metametrics object. + * Defaults to `SRP`; social-backed entropy overrides with the OAuth provider. + * + * Orthogonal to {@link SrpLoginTag}: a secondary entropy source can still be + * `GOOGLE` / `APPLE` / `TELEGRAM` if it is social-backed. + */ +export type LoginIdentifierType = 'SRP' | 'GOOGLE' | 'APPLE' | 'TELEGRAM'; + +/** + * Tag appended to the SRP login `raw_message` so the auth server can + * distinguish primary vs secondary SRPs. + * + * Message format: `metamask:::` + * + * Orthogonal to {@link LoginIdentifierType}: tag is entropy-slot only. + */ +export type SrpLoginTag = 'primary' | 'secondary'; + export type AuthConfig = { env: Env; platform: Platform; diff --git a/packages/profile-sync-controller/tsconfig.build.json b/packages/profile-sync-controller/tsconfig.build.json index df960063ed8..fae8a59b69a 100644 --- a/packages/profile-sync-controller/tsconfig.build.json +++ b/packages/profile-sync-controller/tsconfig.build.json @@ -9,6 +9,7 @@ "references": [ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../seedless-onboarding-controller/tsconfig.build.json" }, { "path": "../address-book-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" } ], diff --git a/packages/profile-sync-controller/tsconfig.json b/packages/profile-sync-controller/tsconfig.json index e6966e7a7c7..ba407611abb 100644 --- a/packages/profile-sync-controller/tsconfig.json +++ b/packages/profile-sync-controller/tsconfig.json @@ -6,6 +6,7 @@ "references": [ { "path": "../base-controller" }, { "path": "../keyring-controller" }, + { "path": "../seedless-onboarding-controller" }, { "path": "../address-book-controller" }, { "path": "../messenger" } ], diff --git a/yarn.lock b/yarn.lock index eee27dd4123..8a81d136371 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8595,6 +8595,7 @@ __metadata: "@metamask/keyring-internal-api": "npm:^11.0.2" "@metamask/messenger": "npm:^2.0.0" "@metamask/providers": "npm:^22.1.0" + "@metamask/seedless-onboarding-controller": "npm:^10.1.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2"