diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 29a627b6ee..3b8e1d5c8b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2057,11 +2057,6 @@ "count": 2 } }, - "packages/seedless-onboarding-controller/src/assertions.ts": { - "no-restricted-syntax": { - "count": 10 - } - }, "packages/seedless-onboarding-controller/src/errors.ts": { "no-restricted-syntax": { "count": 4 diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index a7416afc65..14f09883c5 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `SecretMetadata.matchesType` static method for checking if metadata matches a given type ([#7284](https://github.com/MetaMask/core/pull/7284)) - Re-export `EncAccountDataType` from `@metamask/toprf-secure-backup` ([#7284](https://github.com/MetaMask/core/pull/7284)) - Add third generic type parameter `EncryptionResult` to `SeedlessOnboardingController` and `SeedlessOnboardingControllerOptions`, constrained by `EncryptionResultConstraint` and defaulting to `DefaultEncryptionResult`, so the vault `encryptor` matches the full `Encryptor` typing from `@metamask/keyring-controller` ([#8411](https://github.com/MetaMask/core/pull/8411)) +- Add Telegram profile-pairing support with `pairProfileServiceWithSocialLogin`, including `AuthConnection.Telegram`, new token/error types, and encrypted-vault storage for `profilePairingToken` ([#8652](https://github.com/MetaMask/core/pull/8652)) +- Add new non-persisted value, `profilePairingToken` to controller state. ([#8652](https://github.com/MetaMask/core/pull/8652)) + - `authenticate` will also accept the `profilePairingToken` in the params and save it to state. +- Add optional `profilePairingStatus` to `SocialBackupsMetadata` so profile-pairing state is stored alongside the primary social backup metadata. ([#8652](https://github.com/MetaMask/core/pull/8652)) + - `pairProfileServiceWithSocialLogin` updates the primary social backup metadata entry instead of storing a top-level controller state value. ### Changed @@ -35,6 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove `version` getter from `SecretMetadata`; use `storageVersion` instead ([#7284](https://github.com/MetaMask/core/pull/7284)) - Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) - **BREAKING:** Remove `VaultEncryptor` type alias; use `Encryptor` from `@metamask/keyring-controller` with encryption key, key derivation params, and encryption result types ([#8411](https://github.com/MetaMask/core/pull/8411)) +- **BREAKING:** Constructor now require `fetchFunction` and `profilePairingEndpoint` in `SeedlessOnboardingControllerOptions` so the controller can pair social logins with the profile sync service ([#8652](https://github.com/MetaMask/core/pull/8652)) + - Consumers constructing `SeedlessOnboardingController` must now provide the HTTP transport and profile pairing endpoint URL ### Fixed diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts index 27bc150fb3..150a721854 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -35,6 +35,7 @@ export type SeedlessOnboardingControllerPreloadToprfNodeDetailsAction = { * @param params.accessToken - Access token for pairing with profile sync auth service and to access other services. * @param params.metadataAccessToken - Metadata access token for accessing the metadata service before the vault is created or unlocked. * @param params.skipLock - Optional flag to skip acquiring the controller lock. (to prevent deadlock in case the caller already acquired the lock) + * @param params.profilePairingToken - The profile pairing token used to pair the user social profile with the profile sync auth service later after the onboarding is complete. * @returns A promise that resolves to the authentication result. */ export type SeedlessOnboardingControllerAuthenticateAction = { @@ -165,6 +166,18 @@ export type SeedlessOnboardingControllerSetLockedAction = { handler: SeedlessOnboardingController['setLocked']; }; +/** + * Pair the user social profile with the profile sync auth service. + * + * @param profileSvcToken - The token from the profile service to pair the user social profile with the profile sync auth service. + * @returns A promise that resolves to the success of the operation. + */ +export type SeedlessOnboardingControllerPairProfileServiceWithSocialLoginAction = + { + type: `SeedlessOnboardingController:pairProfileServiceWithSocialLogin`; + handler: SeedlessOnboardingController['pairProfileServiceWithSocialLogin']; + }; + /** * Sync the latest global password to the controller. * reset vault with latest globalPassword, @@ -357,6 +370,7 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerGetSecretDataBackupStateAction | SeedlessOnboardingControllerSubmitPasswordAction | SeedlessOnboardingControllerSetLockedAction + | SeedlessOnboardingControllerPairProfileServiceWithSocialLoginAction | SeedlessOnboardingControllerSyncLatestGlobalPasswordAction | SeedlessOnboardingControllerSubmitGlobalPasswordAction | SeedlessOnboardingControllerCheckIsPasswordOutdatedAction diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index bc1406e3e6..2743b6d21f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -69,6 +69,7 @@ import { SeedlessOnboardingControllerErrorMessage, SeedlessOnboardingMigrationVersion, AuthConnection, + ProfilePairingStatus, SecretType, } from './constants'; import { PasswordSyncError, RecoveryError } from './errors'; @@ -77,11 +78,12 @@ import { SeedlessOnboardingController, getInitialSeedlessOnboardingControllerStateWithDefaults, } from './SeedlessOnboardingController'; +import type { SeedlessOnboardingControllerMessenger } from './SeedlessOnboardingController'; import type { - SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, -} from './SeedlessOnboardingController'; -import type { SeedlessOnboardingControllerState } from './types'; + SocialBackupsMetadata, + SeedlessOnboardingControllerState, +} from './types'; const authConnection = AuthConnection.Google; const socialLoginEmail = 'user-test@gmail.com'; @@ -166,6 +168,19 @@ type WithControllerArgs = WithControllerCallback, ]; +const mockFetchFunction = jest.fn() as typeof fetch; +const mockProfilePairingEndpoint = 'https://mock-profile-pairing.example'; + +function getMockSocialBackupMetadata( + overrides: Partial = {}, +): SocialBackupsMetadata { + return { + hash: 'mock-social-backup-hash', + type: SecretType.Mnemonic, + ...overrides, + }; +} + /** * Get the default vault encryptor for the Seedless Onboarding Controller. * @@ -255,6 +270,8 @@ async function withController( refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, + fetchFunction: mockFetchFunction, + profilePairingEndpoint: mockProfilePairingEndpoint, ...rest, }); @@ -537,6 +554,7 @@ async function mockCreateToprfKeyAndBackupSeedPhrase< * @param MOCK_PASSWORD - The mock password. * @param mockRevokeToken - The revoke token. * @param mockAccessToken - The access token. + * @param mockProfilePairingToken - The optional profile pairing token. * * @returns The mock vault data. */ @@ -548,12 +566,14 @@ async function createMockVault( MOCK_PASSWORD: string, mockRevokeToken: string = revokeToken, mockAccessToken: string = accessToken, + mockProfilePairingToken?: string, ): Promise<{ encryptedMockVault: string; vaultEncryptionKey: string; vaultEncryptionSalt: string; revokeToken: string; accessToken: string; + profilePairingToken?: string; encryptedKeyringEncryptionKey: Uint8Array; pwEncKey: Uint8Array; }> { @@ -568,6 +588,9 @@ async function createMockVault( }), revokeToken: mockRevokeToken, accessToken: mockAccessToken, + ...(mockProfilePairingToken + ? { profilePairingToken: mockProfilePairingToken } + : {}), }); const { vault: encryptedMockVault, exportedKeyString } = @@ -584,6 +607,7 @@ async function createMockVault( vaultEncryptionSalt: JSON.parse(encryptedMockVault).salt, revokeToken: mockRevokeToken, accessToken: mockAccessToken, + profilePairingToken: mockProfilePairingToken, encryptedKeyringEncryptionKey, pwEncKey, }; @@ -640,6 +664,7 @@ async function decryptVault( * @param options.withoutMockAccessToken - Whether to skip the accessToken in authenticated user state. * @param options.metadataAccessToken - The mock metadata access token. * @param options.accessToken - The mock access token. + * @param options.profilePairingToken - The mock profile pairing token. * @param options.encryptedSeedlessEncryptionKey - The mock encrypted seedless encryption key. * @param options.pendingToBeRevokedTokens - The mock pending to be revoked tokens. * @param options.migrationVersion - The mock migration version. @@ -658,6 +683,7 @@ function getMockInitialControllerState(options?: { encryptedSeedlessEncryptionKey?: string; metadataAccessToken?: string; accessToken?: string; + profilePairingToken?: string; pendingToBeRevokedTokens?: | { refreshToken: string; @@ -689,6 +715,12 @@ function getMockInitialControllerState(options?: { state.refreshToken = refreshToken; state.metadataAccessToken = options?.metadataAccessToken ?? metadataAccessToken; + state.profilePairingToken = Object.prototype.hasOwnProperty.call( + options ?? {}, + 'profilePairingToken', + ) + ? options?.profilePairingToken + : 'mock-profile-pairing-token'; state.isSeedlessOnboardingUserAuthenticated = true; if (!options?.withoutMockAccessToken || options?.accessToken) { state.accessToken = options?.accessToken ?? accessToken; @@ -739,6 +771,8 @@ describe('SeedlessOnboardingController', () => { refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, + profilePairingEndpoint: mockProfilePairingEndpoint, + fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); expect(controller.state).toStrictEqual( @@ -766,6 +800,8 @@ describe('SeedlessOnboardingController', () => { refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, + profilePairingEndpoint: mockProfilePairingEndpoint, + fetchFunction: mockFetchFunction, }), ).not.toThrow(); }); @@ -839,6 +875,8 @@ describe('SeedlessOnboardingController', () => { revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, state: initialState, + profilePairingEndpoint: mockProfilePairingEndpoint, + fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); expect(controller.state).toMatchObject(initialState); @@ -902,6 +940,310 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('pairProfileServiceWithSocialLogin', () => { + const mockPassword = 'mock-password'; + const mockProfileServiceToken = 'profile-service-token'; + const mockProfilePairingToken = 'profile-pairing-token'; + + async function createVaultForProfilePairing( + profilePairingToken?: string, + ): Promise>> { + const mockToprfEncryptor = createMockToprfEncryptor(); + + return await createMockVault( + mockToprfEncryptor.deriveEncKey(mockPassword), + mockToprfEncryptor.derivePwEncKey(mockPassword), + mockToprfEncryptor.deriveAuthKeyPair(mockPassword), + mockPassword, + revokeToken, + accessToken, + profilePairingToken, + ); + } + + it('should pair Telegram social login with the profile service', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + }); + const mockVault = await createVaultForProfilePairing( + mockProfilePairingToken, + ); + + await withController( + { + fetchFunction: mockFetch, + profilePairingEndpoint: mockProfilePairingEndpoint, + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: mockVault.encryptedMockVault, + profilePairingToken: undefined, + }), + authConnection: AuthConnection.Telegram, + socialBackupsMetadata: [getMockSocialBackupMetadata()], + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + await controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ); + + expect(mockFetch).toHaveBeenCalledWith(mockProfilePairingEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${mockProfileServiceToken}`, + }, + body: JSON.stringify({ + jwts: [mockProfilePairingToken], + }), + }); + expect( + controller.state.socialBackupsMetadata[0]?.profilePairingStatus, + ).toBe(ProfilePairingStatus.Paired); + }, + ); + }); + + it('should throw if there are no social backups to pair', async () => { + const mockFetch = jest.fn(); + const mockVault = await createVaultForProfilePairing( + mockProfilePairingToken, + ); + + await withController( + { + fetchFunction: mockFetch, + profilePairingEndpoint: mockProfilePairingEndpoint, + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: mockVault.encryptedMockVault, + profilePairingToken: undefined, + }), + authConnection: AuthConnection.Telegram, + socialBackupsMetadata: [], + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + await expect( + controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.NoSocialBackups, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(controller.state.socialBackupsMetadata).toStrictEqual([]); + }, + ); + }); + + it('should skip pairing when profile pairing already completed', async () => { + const mockFetch = jest.fn(); + const mockVault = await createVaultForProfilePairing( + mockProfilePairingToken, + ); + + await withController( + { + fetchFunction: mockFetch, + profilePairingEndpoint: mockProfilePairingEndpoint, + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: mockVault.encryptedMockVault, + }), + authConnection: AuthConnection.Telegram, + socialBackupsMetadata: [ + getMockSocialBackupMetadata({ + profilePairingStatus: ProfilePairingStatus.Paired, + }), + ], + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + expect( + await controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), + ).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + expect( + controller.state.socialBackupsMetadata[0]?.profilePairingStatus, + ).toBe(ProfilePairingStatus.Paired); + }, + ); + }); + + it('should update the primary social backup metadata to pairing failed when the profile pairing token is unavailable', async () => { + const mockFetch = jest.fn(); + const mockVault = await createVaultForProfilePairing(); + + await withController( + { + fetchFunction: mockFetch, + profilePairingEndpoint: mockProfilePairingEndpoint, + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: mockVault.encryptedMockVault, + profilePairingToken: undefined, + }), + authConnection: AuthConnection.Telegram, + socialBackupsMetadata: [getMockSocialBackupMetadata()], + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + await expect( + controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + expect( + controller.state.socialBackupsMetadata[0]?.profilePairingStatus, + ).toBe(ProfilePairingStatus.PairingFailed); + }, + ); + }); + + it('should skip pairing for non-Telegram social logins', async () => { + const mockFetch = jest.fn(); + const mockVault = await createVaultForProfilePairing(); + + await withController( + { + fetchFunction: mockFetch, + profilePairingEndpoint: mockProfilePairingEndpoint, + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: mockVault.encryptedMockVault, + }), + authConnection: AuthConnection.Google, + socialBackupsMetadata: [getMockSocialBackupMetadata()], + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + expect( + await controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), + ).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + expect( + controller.state.socialBackupsMetadata[0]?.profilePairingStatus, + ).toBeUndefined(); + }, + ); + }); + + it('should skip pairing for non-Telegram social logins without social backups', async () => { + const mockFetch = jest.fn(); + const mockVault = await createVaultForProfilePairing(); + + await withController( + { + fetchFunction: mockFetch, + profilePairingEndpoint: mockProfilePairingEndpoint, + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: mockVault.encryptedMockVault, + }), + authConnection: AuthConnection.Google, + socialBackupsMetadata: [], + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + expect( + await controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), + ).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(controller.state.socialBackupsMetadata).toStrictEqual([]); + }, + ); + }); + + it('should throw if the profile service pairing request fails', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + }); + const mockVault = await createVaultForProfilePairing( + mockProfilePairingToken, + ); + + await withController( + { + fetchFunction: mockFetch, + profilePairingEndpoint: mockProfilePairingEndpoint, + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: mockVault.encryptedMockVault, + }), + authConnection: AuthConnection.Telegram, + socialBackupsMetadata: [getMockSocialBackupMetadata()], + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + await expect( + controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToPairSocialLoginWithIdentityProfileService, + ); + expect( + controller.state.socialBackupsMetadata[0]?.profilePairingStatus, + ).toBe(ProfilePairingStatus.PairingFailed); + }, + ); + }); + }); + describe('authenticate', () => { it('should be able to register a new user', async () => { await withController( @@ -1032,6 +1374,81 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should be able to authenticate a Telegram user with profilePairingToken', async () => { + await withController( + async ({ controller, toprfClient, baseMessenger }) => { + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: true, + }); + + const authResult = await baseMessenger.call( + 'SeedlessOnboardingController:authenticate', + { + idTokens, + authConnectionId, + userId, + authConnection: AuthConnection.Telegram, + socialLoginEmail, + refreshToken, + revokeToken, + accessToken, + metadataAccessToken, + profilePairingToken: 'profile-pairing-token', + }, + ); + + expect(authResult).toBeDefined(); + expect(authResult.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + expect(authResult.isNewUser).toBe(true); + expect(controller.state.authConnection).toBe(AuthConnection.Telegram); + expect(controller.state.profilePairingToken).toBe( + 'profile-pairing-token', + ); + expect(controller.state.isSeedlessOnboardingUserAuthenticated).toBe( + true, + ); + }, + ); + }); + + it('should throw an error if a Telegram user is missing profilePairingToken', async () => { + await withController( + async ({ controller, toprfClient, baseMessenger }) => { + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: true, + }); + + await expect( + baseMessenger.call('SeedlessOnboardingController:authenticate', { + idTokens, + authConnectionId, + userId, + authConnection: AuthConnection.Telegram, + socialLoginEmail, + refreshToken, + revokeToken, + accessToken, + metadataAccessToken, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + + expect(controller.state.nodeAuthTokens).toBeUndefined(); + expect(controller.state.authConnectionId).toBeUndefined(); + expect(controller.state.userId).toBeUndefined(); + expect(controller.state.profilePairingToken).toBeUndefined(); + expect(controller.state.isSeedlessOnboardingUserAuthenticated).toBe( + false, + ); + }, + ); + }); + it('should throw an error if the authentication fails', async () => { const JSONRPC_ERROR = { jsonrpc: '2.0', @@ -1327,6 +1744,31 @@ describe('SeedlessOnboardingController', () => { ).toBe(false); }); }); + + it('should return false for a Telegram user missing profilePairingToken without mutating state', async () => { + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + profilePairingToken: undefined, + }), + authConnection: AuthConnection.Telegram, + isSeedlessOnboardingUserAuthenticated: true, + }, + }, + async ({ controller, baseMessenger }) => { + expect( + await baseMessenger.call( + 'SeedlessOnboardingController:getIsUserAuthenticated', + ), + ).toBe(false); + expect(controller.state.isSeedlessOnboardingUserAuthenticated).toBe( + true, + ); + }, + ); + }); }); describe('createToprfKeyAndBackupSeedPhrase', () => { @@ -1374,6 +1816,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -1441,6 +1886,123 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should preserve the state profilePairingToken when reading access and revoke tokens from the vault', async () => { + const stateProfilePairingToken = 'state-profile-pairing-token'; + const vaultProfilePairingToken = 'vault-profile-pairing-token'; + const mockToprfEncryptor = createMockToprfEncryptor(); + const mockVault = await createMockVault( + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD), + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD), + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD), + MOCK_PASSWORD, + revokeToken, + accessToken, + vaultProfilePairingToken, + ); + + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withoutMockAccessToken: true, + withoutMockRevokeToken: true, + vault: mockVault.encryptedMockVault, + vaultEncryptionKey: mockVault.vaultEncryptionKey, + vaultEncryptionSalt: mockVault.vaultEncryptionSalt, + profilePairingToken: stateProfilePairingToken, + }), + authConnection: AuthConnection.Telegram, + }, + }, + async ({ controller, toprfClient, encryptor, baseMessenger }) => { + const mockSecretDataAdd = handleMockSecretDataAdd(); + + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + await baseMessenger.call( + 'SeedlessOnboardingController:createToprfKeyAndBackupSeedPhrase', + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + + const decryptedVaultData = await encryptor.decrypt( + MOCK_PASSWORD, + controller.state.vault as string, + ); + const parsedVaultData = JSON.parse(decryptedVaultData as string); + + expect(parsedVaultData.accessToken).toBe(accessToken); + expect(parsedVaultData.revokeToken).toBe(revokeToken); + expect(parsedVaultData.profilePairingToken).toBe( + stateProfilePairingToken, + ); + }, + ); + }); + + it('should carry forward the vault profilePairingToken when it is missing from state', async () => { + const vaultProfilePairingToken = 'vault-profile-pairing-token'; + const mockToprfEncryptor = createMockToprfEncryptor(); + const mockVault = await createMockVault( + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD), + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD), + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD), + MOCK_PASSWORD, + revokeToken, + accessToken, + vaultProfilePairingToken, + ); + + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withoutMockAccessToken: true, + withoutMockRevokeToken: true, + vault: mockVault.encryptedMockVault, + vaultEncryptionKey: mockVault.vaultEncryptionKey, + vaultEncryptionSalt: mockVault.vaultEncryptionSalt, + profilePairingToken: undefined, + }), + authConnection: AuthConnection.Google, + }, + }, + async ({ controller, toprfClient, encryptor, baseMessenger }) => { + const mockSecretDataAdd = handleMockSecretDataAdd(); + + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + await baseMessenger.call( + 'SeedlessOnboardingController:createToprfKeyAndBackupSeedPhrase', + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + + const decryptedVaultData = await encryptor.decrypt( + MOCK_PASSWORD, + controller.state.vault as string, + ); + const parsedVaultData = JSON.parse(decryptedVaultData as string); + + expect(parsedVaultData.accessToken).toBe(accessToken); + expect(parsedVaultData.revokeToken).toBe(revokeToken); + expect(parsedVaultData.profilePairingToken).toBe( + vaultProfilePairingToken, + ); + }, + ); + }); + it('should refresh token and create new seed phrase backup in case of token errors', async () => { await withController( { @@ -1503,6 +2065,8 @@ describe('SeedlessOnboardingController', () => { authKeyPair, MOCK_PASSWORD, controller.state.revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -1584,6 +2148,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -1736,6 +2303,67 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should allow vault creation if Telegram user is missing profilePairingToken during vault creation', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const mockVault = await createMockVault( + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD), + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD), + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD), + MOCK_PASSWORD, + ); + + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: mockVault.encryptedMockVault, + profilePairingToken: 'profile-pairing-token', + }), + authConnection: AuthConnection.Telegram, + }, + }, + async ({ controller, toprfClient, encryptor, baseMessenger }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + const updateState = ( + controller as unknown as { + update: ( + callback: (state: SeedlessOnboardingControllerState) => void, + ) => void; + } + ).update.bind(controller); + + jest + .spyOn(toprfClient, 'persistLocalKey') + .mockImplementationOnce(async () => { + updateState((state) => { + state.profilePairingToken = undefined; + }); + }); + + const mockSecretDataAdd = handleMockSecretDataAdd(); + + await baseMessenger.call( + 'SeedlessOnboardingController:createToprfKeyAndBackupSeedPhrase', + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + + const decryptedVaultData = await encryptor.decrypt( + MOCK_PASSWORD, + controller.state.vault as string, + ); + const parsedVaultData = JSON.parse(decryptedVaultData as string); + + expect(parsedVaultData.profilePairingToken).toBeUndefined(); + }, + ); + }); + it('should throw an error if user does not have the AuthToken', async () => { await withController( { state: { userId, authConnectionId, groupedAuthConnectionId } }, @@ -2730,6 +3358,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -2812,6 +3443,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -2894,6 +3528,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -2979,6 +3616,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -3512,6 +4152,47 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should preserve the state profilePairingToken when unlocking an older Telegram vault', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const stateProfilePairingToken = 'state-profile-pairing-token'; + + const mockEncryptionKey = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const mockPasswordEncryptionKey = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); + const mockAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const { encryptedMockVault } = await createMockVault( + mockEncryptionKey, + mockPasswordEncryptionKey, + mockAuthKeyPair, + MOCK_PASSWORD, + ); + + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: encryptedMockVault, + profilePairingToken: stateProfilePairingToken, + }), + authConnection: AuthConnection.Telegram, + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + MOCK_PASSWORD, + ); + + expect(controller.state.profilePairingToken).toBe( + stateProfilePairingToken, + ); + }, + ); + }); + it('should throw error if the vault is missing', async () => { await withController(async ({ baseMessenger }) => { await expect( @@ -6259,6 +6940,7 @@ describe('SeedlessOnboardingController', () => { vault: MOCK_VAULT, vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + profilePairingToken: undefined, }), }, async ({ @@ -7547,6 +8229,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: validToken, }), renewRefreshToken: jest.fn(), + profilePairingEndpoint: mockProfilePairingEndpoint, + fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); @@ -7572,6 +8256,8 @@ describe('SeedlessOnboardingController', () => { revokeRefreshToken: jest.fn(), state, renewRefreshToken: jest.fn(), + profilePairingEndpoint: mockProfilePairingEndpoint, + fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); @@ -7598,6 +8284,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: expiredToken, }), renewRefreshToken: jest.fn(), + profilePairingEndpoint: mockProfilePairingEndpoint, + fetchFunction: mockFetchFunction, }); // mock refreshAuthTokens to return a new token @@ -7628,6 +8316,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: nearExpiryToken, }), renewRefreshToken: jest.fn(), + profilePairingEndpoint: mockProfilePairingEndpoint, + fetchFunction: mockFetchFunction, }); jest.spyOn(controller, 'refreshAuthTokens').mockResolvedValue(); @@ -7657,6 +8347,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: freshToken, }), renewRefreshToken: jest.fn(), + profilePairingEndpoint: mockProfilePairingEndpoint, + fetchFunction: mockFetchFunction, }); jest.spyOn(controller, 'refreshAuthTokens').mockResolvedValue(); @@ -8798,7 +9490,11 @@ describe('SeedlessOnboardingController', () => { ], refreshToken: 'refreshToken', revokeToken: 'revokeToken', - socialBackupsMetadata: [], + socialBackupsMetadata: [ + getMockSocialBackupMetadata({ + profilePairingStatus: ProfilePairingStatus.Paired, + }), + ], socialLoginEmail: 'socialLoginEmail', userId: 'userId', vault: 'vault', @@ -8836,7 +9532,13 @@ describe('SeedlessOnboardingController', () => { }, ], "refreshToken": "refreshToken", - "socialBackupsMetadata": [], + "socialBackupsMetadata": [ + { + "hash": "mock-social-backup-hash", + "profilePairingStatus": "paired", + "type": "mnemonic", + }, + ], "socialLoginEmail": "socialLoginEmail", "userId": "userId", "vault": "vault", diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index a28fbce534..2011ecb16e 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -43,7 +43,7 @@ import { assertIsValidPassword, assertIsValidVaultData, } from './assertions'; -import type { AuthConnection } from './constants'; +import { AuthConnection, ProfilePairingStatus } from './constants'; import { controllerName, PASSWORD_OUTDATED_CACHE_TTL_MS, @@ -70,7 +70,7 @@ import type { RenewRefreshToken, VaultData, DeserializedVaultData, - ToprfKeyDeriver, + SeedlessOnboardingControllerOptions, } from './types'; import { compareAndGetLatestToken, @@ -109,6 +109,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkNodeAuthTokenExpired', 'checkMetadataAccessTokenExpired', 'checkAccessTokenExpired', + 'pairProfileServiceWithSocialLogin', ] as const; // Actions @@ -142,80 +143,6 @@ export type SeedlessOnboardingControllerMessenger = Messenger< SeedlessOnboardingControllerEvents | AllowedEvents >; -/** - * Seedless Onboarding Controller Options. - * - * @param messenger - The messenger to use for this controller. - * @param state - The initial state to set on this controller. - * @param encryptor - The encryptor to use for encrypting and decrypting seedless onboarding vault. - */ -export type SeedlessOnboardingControllerOptions< - EncryptionKey = encryptionUtils.EncryptionKey, - SupportedKeyDerivationParams = encryptionUtils.KeyDerivationOptions, - EncryptionResult extends - EncryptionResultConstraint = - DefaultEncryptionResult, -> = { - messenger: SeedlessOnboardingControllerMessenger; - - /** - * Initial state to set on this controller. - */ - state?: Partial; - - /** - * Encryptor to use for encrypting and decrypting seedless onboarding vault. - * - * @default browser-passworder @link https://github.com/MetaMask/browser-passworder - */ - encryptor: Encryptor< - EncryptionKey, - SupportedKeyDerivationParams, - EncryptionResult - >; - - /** - * A function to get a new jwt token using refresh token. - */ - refreshJWTToken: RefreshJWTToken; - - /** - * A function to revoke the refresh token. - */ - revokeRefreshToken: RevokeRefreshToken; - - /** - * A function to renew the refresh token and get new revoke token. - */ - renewRefreshToken: RenewRefreshToken; - - /** - * Optional key derivation interface for the TOPRF client. - * - * If provided, it will be used as an additional step during - * key derivation. This can be used, for example, to inject a slow key - * derivation step to protect against local brute force attacks on the - * password. - * - * @default browser-passworder @link https://github.com/MetaMask/browser-passworder - */ - toprfKeyDeriver?: ToprfKeyDeriver; - - /** - * Type of Web3Auth network to be used for the Seedless Onboarding flow. - * - * @default Web3AuthNetwork.Mainnet - */ - network?: Web3AuthNetwork; - - /** - * The TTL of the password outdated cache in milliseconds. - * - * @default PASSWORD_OUTDATED_CACHE_TTL_MS - */ - passwordOutdatedCacheTTL?: number; -}; - /** * Get the initial state for the Seedless Onboarding Controller with defaults. * @@ -383,6 +310,12 @@ const seedlessOnboardingMetadata: StateMetadata { const doAuthenticateWithNodes = async (): Promise => { try { @@ -580,6 +525,7 @@ export class SeedlessOnboardingController< revokeToken, accessToken, metadataAccessToken, + profilePairingToken, } = params; const authenticationResult = await this.toprfClient.authenticate({ @@ -603,6 +549,14 @@ export class SeedlessOnboardingController< state.revokeToken = revokeToken; } state.accessToken = accessToken; + if (authConnection === AuthConnection.Telegram) { + if (!profilePairingToken) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + } + state.profilePairingToken = profilePairingToken; + } // we will check if the controller state is properly set with the authenticated user info // before setting the isSeedlessOnboardingUserAuthenticated to true @@ -1143,6 +1097,7 @@ export class SeedlessOnboardingController< delete state.vaultEncryptionSalt; delete state.revokeToken; delete state.accessToken; + delete state.profilePairingToken; }); this.#cachedDecryptedVaultData = undefined; @@ -1150,6 +1105,92 @@ export class SeedlessOnboardingController< }); } + /** + * Pair the user social profile with the profile sync auth service. + * + * @param profileSvcToken - The token from the profile service to pair the user social profile with the profile sync auth service. + * @returns A promise that resolves to the success of the operation. + */ + async pairProfileServiceWithSocialLogin( + profileSvcToken: string, + ): Promise { + return await this.#withControllerLock(async () => { + this.#assertIsUnlocked(); + + try { + const { profilePairingToken, authConnection, socialBackupsMetadata } = + this.state; + + if (authConnection !== AuthConnection.Telegram) { + // We only support profile pairing for Telegram right now, so other + // social logins should always be treated as a no-op. + log( + `warning: skipping profile pairing for ${authConnection} social login`, + ); + return; + } + + if (socialBackupsMetadata.length < 1) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.NoSocialBackups, + ); + } + + // For now, we only support profile pairing for the primary SRP. + const profilePairingStatus = + socialBackupsMetadata[0]?.profilePairingStatus; + if (profilePairingStatus === ProfilePairingStatus.Paired) { + log('Profile pairing already completed'); + return; + } + + if (!profilePairingToken) { + log( + 'Error: profile pairing token is not available for Telegram social login', + ); + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + } + + const response = await this.#fetch(this.#profilePairingEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${profileSvcToken}`, + }, + body: JSON.stringify({ + jwts: [profilePairingToken], + }), + }); + + if (!response.ok) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.FailedToPairSocialLoginWithIdentityProfileService, + ); + } + + this.update((state) => { + const primarySocialBackup = state.socialBackupsMetadata[0]; + if (primarySocialBackup) { + primarySocialBackup.profilePairingStatus = + ProfilePairingStatus.Paired; + } + }); + } catch (error) { + log('Error pairing profile service with social login', error); + this.update((state) => { + const primarySocialBackup = state.socialBackupsMetadata[0]; + if (primarySocialBackup) { + primarySocialBackup.profilePairingStatus = + ProfilePairingStatus.PairingFailed; + } + }); + throw error; + } + }); + } + /** * Sync the latest global password to the controller. * reset vault with latest globalPassword, @@ -1374,7 +1415,15 @@ export class SeedlessOnboardingController< async getIsUserAuthenticated(): Promise { try { this.#assertIsAuthenticatedUser(this.state); - return Boolean(this.state.accessToken) && Boolean(this.state.revokeToken); + const accessTokenAndRevokeTokenAreSet = + Boolean(this.state.accessToken) && Boolean(this.state.revokeToken); + if (this.state.authConnection === AuthConnection.Telegram) { + return ( + accessTokenAndRevokeTokenAreSet && + Boolean(this.state.profilePairingToken) + ); + } + return accessTokenAndRevokeTokenAreSet; } catch { return false; } @@ -1816,15 +1865,22 @@ export class SeedlessOnboardingController< const { vaultData, vaultEncryptionKey, vaultEncryptionSalt } = await this.#decryptAndParseVaultData(params); + const profilePairingToken = + this.state.profilePairingToken ?? vaultData.profilePairingToken; + const unlockedVaultData = { + ...vaultData, + profilePairingToken, + }; this.update((state) => { state.vaultEncryptionKey = vaultEncryptionKey; state.vaultEncryptionSalt = vaultEncryptionSalt; state.revokeToken = vaultData.revokeToken; state.accessToken = vaultData.accessToken; + state.profilePairingToken = profilePairingToken; }); - const deserializedVaultData = deserializeVaultData(vaultData); + const deserializedVaultData = deserializeVaultData(unlockedVaultData); this.#cachedDecryptedVaultData = deserializedVaultData; return deserializedVaultData; }); @@ -2017,8 +2073,8 @@ export class SeedlessOnboardingController< }): Promise { this.#assertIsAuthenticatedUser(this.state); - const { accessToken, revokeToken } = - await this.#getAccessTokenAndRevokeToken(password); + const { accessToken, revokeToken, profilePairingToken } = + await this.#getRevokeTokenAndProfilePairingTokens(password); const vaultData: DeserializedVaultData = { toprfAuthKeyPair: rawToprfAuthKeyPair, @@ -2026,6 +2082,7 @@ export class SeedlessOnboardingController< toprfPwEncryptionKey: rawToprfPwEncryptionKey, revokeToken, accessToken, + profilePairingToken, }; await this.#updateVault({ @@ -2148,19 +2205,21 @@ export class SeedlessOnboardingController< } /** - * Get the access token and revoke token from the state or the vault. + * Get the revoke token and profile pairing tokens (accessToken and profilePairingToken) from the state or the vault. * * @param password - The password to decrypt the vault. * @returns The access token and revoke token. */ - async #getAccessTokenAndRevokeToken( - password: string, - ): Promise<{ accessToken: string; revokeToken: string }> { - let { accessToken, revokeToken } = this.state; + async #getRevokeTokenAndProfilePairingTokens(password: string): Promise<{ + revokeToken: string; + accessToken: string; + profilePairingToken?: string; + }> { + let { accessToken, revokeToken, profilePairingToken } = this.state; // `accessToken` and `revokeToken` are both available in the state, `ONLY` when the wallet (vault) is unlocked // or during the period between the social authentication and the vault creation during the onboarding flow. if (accessToken && revokeToken) { - return { accessToken, revokeToken }; + return { accessToken, profilePairingToken, revokeToken }; } // if `password` is provided to decrypt the vault, decrypt the vault and get the access token and revoke token from the vault @@ -2169,6 +2228,8 @@ export class SeedlessOnboardingController< const { vaultData } = await this.#decryptAndParseVaultData({ password }); accessToken = accessToken ?? vaultData.accessToken; revokeToken = revokeToken ?? vaultData.revokeToken; + profilePairingToken = + profilePairingToken ?? vaultData.profilePairingToken; } // we should always throw an error if the access token or revoke token is not available @@ -2186,7 +2247,7 @@ export class SeedlessOnboardingController< ); } - return { accessToken, revokeToken }; + return { accessToken, profilePairingToken, revokeToken }; } /** @@ -2791,7 +2852,7 @@ export class SeedlessOnboardingController< this.#assertIsAuthenticatedUser(this.state); const { metadataAccessToken } = this.state; // assertIsAuthenticatedUser will throw if metadataAccessToken is missing - const decodedToken = decodeJWTToken(metadataAccessToken as string); + const decodedToken = decodeJWTToken(metadataAccessToken); return isTokenNearExpiry(decodedToken.exp, decodedToken.iat); } catch { return true; // Consider unauthenticated user as having expired tokens diff --git a/packages/seedless-onboarding-controller/src/assertions.test.ts b/packages/seedless-onboarding-controller/src/assertions.test.ts index 270f53adc2..0bc402f2b6 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/assertions.test.ts @@ -1,10 +1,14 @@ import { + assertIsSeedlessOnboardingUserAuthenticated, assertIsPasswordOutdatedCacheValid, assertIsValidPassword, assertIsValidVaultData, } from './assertions'; -import { SeedlessOnboardingControllerErrorMessage } from './constants'; -import { VaultData } from './types'; +import { + AuthConnection, + SeedlessOnboardingControllerErrorMessage, +} from './constants'; +import { AuthenticatedUserDetails, VaultData } from './types'; describe('assertIsValidPassword', () => { it('should throw when password is not a string', () => { @@ -42,6 +46,116 @@ describe('assertIsValidPassword', () => { }); }); +describe('assertIsSeedlessOnboardingUserAuthenticated', () => { + const createValidAuthenticatedUser = (): AuthenticatedUserDetails => ({ + authConnection: AuthConnection.Google, + authConnectionId: 'seedless-onboarding', + userId: 'user@example.com', + socialLoginEmail: 'user@example.com', + nodeAuthTokens: [ + { + authToken: 'auth-token-1', + nodeIndex: 1, + nodePubKey: 'node-pub-key-1', + }, + { + authToken: 'auth-token-2', + nodeIndex: 2, + nodePubKey: 'node-pub-key-2', + }, + { + authToken: 'auth-token-3', + nodeIndex: 3, + nodePubKey: 'node-pub-key-3', + }, + ], + refreshToken: 'refresh-token', + metadataAccessToken: 'metadata-access-token', + revokeToken: 'revoke-token', + }); + + it.each([ + [ + 'authConnectionId is missing', + (): AuthenticatedUserDetails => { + const invalidUser = createValidAuthenticatedUser(); + delete (invalidUser as Record).authConnectionId; + return invalidUser; + }, + ], + [ + 'userId is not a string', + (): AuthenticatedUserDetails => ({ + ...createValidAuthenticatedUser(), + // @ts-expect-error - invalid type for testing + userId: 123, + }), + ], + ])('should throw MissingAuthUserInfo when %s', (_caseName, buildValue) => { + expect(() => { + assertIsSeedlessOnboardingUserAuthenticated(buildValue()); + }).toThrow(SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo); + }); + + it.each([ + [ + 'nodeAuthTokens is missing', + (): AuthenticatedUserDetails => { + const invalidUser = createValidAuthenticatedUser(); + delete (invalidUser as Record).nodeAuthTokens; + return invalidUser; + }, + ], + [ + 'nodeAuthTokens is not an array', + (): AuthenticatedUserDetails => ({ + ...createValidAuthenticatedUser(), + // @ts-expect-error - invalid type for testing + nodeAuthTokens: 'invalid', + }), + ], + [ + 'nodeAuthTokens does not meet the minimum threshold', + (): AuthenticatedUserDetails => ({ + ...createValidAuthenticatedUser(), + nodeAuthTokens: [createValidAuthenticatedUser().nodeAuthTokens[0]], + }), + ], + ])('should throw InsufficientAuthToken when %s', (_caseName, buildValue) => { + expect(() => { + assertIsSeedlessOnboardingUserAuthenticated(buildValue()); + }).toThrow(SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken); + }); + + it('should throw InvalidRefreshToken when refreshToken is missing', () => { + const invalidUser = createValidAuthenticatedUser(); + delete (invalidUser as Record).refreshToken; + + expect(() => { + assertIsSeedlessOnboardingUserAuthenticated(invalidUser); + }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidRefreshToken); + }); + + it('should throw InvalidMetadataAccessToken when metadataAccessToken is invalid', () => { + expect(() => { + assertIsSeedlessOnboardingUserAuthenticated({ + ...createValidAuthenticatedUser(), + metadataAccessToken: 123, + }); + }).toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidMetadataAccessToken, + ); + }); + + it('should not throw for a valid authenticated user', () => { + expect(() => { + assertIsSeedlessOnboardingUserAuthenticated( + createValidAuthenticatedUser(), + ); + }).not.toThrow(); + }); +}); + describe('assertIsValidVaultData', () => { /** * Helper function to create valid vault data for testing @@ -54,6 +168,7 @@ describe('assertIsValidVaultData', () => { toprfAuthKeyPair: 'mock_auth_key_pair', accessToken: 'mock_access_token', revokeToken: 'mock_revoke_token', + profilePairingToken: 'mock_profile_pairing_token', }); describe('should throw VaultDataError for invalid data', () => { @@ -137,6 +252,17 @@ describe('assertIsValidVaultData', () => { assertIsValidVaultData(invalidData2); }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidAccessToken); }); + + it('should throw when profilePairingToken is present but not a string', () => { + expect(() => { + assertIsValidVaultData({ + ...createValidVaultData(), + profilePairingToken: 123, + }); + }).toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + }); }); describe('should NOT throw for valid data', () => { @@ -159,6 +285,15 @@ describe('assertIsValidVaultData', () => { assertIsValidVaultData(validDataWithExtras); }).not.toThrow(); }); + + it('should not throw when profilePairingToken is omitted', () => { + const validDataWithoutProfilePairingToken = createValidVaultData(); + delete validDataWithoutProfilePairingToken.profilePairingToken; + + expect(() => { + assertIsValidVaultData(validDataWithoutProfilePairingToken); + }).not.toThrow(); + }); }); }); diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/assertions.ts index c9929e1a2b..9fdb1e0e62 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -1,3 +1,5 @@ +import { hasProperty } from '@metamask/utils'; + import { SeedlessOnboardingControllerErrorMessage } from './constants'; import type { AuthenticatedUserDetails, VaultData } from './types'; @@ -33,9 +35,9 @@ export function assertIsSeedlessOnboardingUserAuthenticated( if ( !value || typeof value !== 'object' || - !('authConnectionId' in value) || + !hasProperty(value, 'authConnectionId') || typeof value.authConnectionId !== 'string' || - !('userId' in value) || + !hasProperty(value, 'userId') || typeof value.userId !== 'string' ) { throw new Error( @@ -44,7 +46,7 @@ export function assertIsSeedlessOnboardingUserAuthenticated( } if ( - !('nodeAuthTokens' in value) || + !hasProperty(value, 'nodeAuthTokens') || typeof value.nodeAuthTokens !== 'object' || !Array.isArray(value.nodeAuthTokens) || value.nodeAuthTokens.length < 3 // At least 3 auth tokens are required for Threshold OPRF service @@ -54,13 +56,16 @@ export function assertIsSeedlessOnboardingUserAuthenticated( ); } - if (!('refreshToken' in value) || typeof value.refreshToken !== 'string') { + if ( + !hasProperty(value, 'refreshToken') || + typeof value.refreshToken !== 'string' + ) { throw new Error( SeedlessOnboardingControllerErrorMessage.InvalidRefreshToken, ); } if ( - !('metadataAccessToken' in value) || + !hasProperty(value, 'metadataAccessToken') || typeof value.metadataAccessToken !== 'string' ) { throw new Error( @@ -104,25 +109,41 @@ export function assertIsValidVaultData( if ( !value || // value is not defined typeof value !== 'object' || // value is not an object - !('toprfEncryptionKey' in value) || // toprfEncryptionKey is not defined + !hasProperty(value, 'toprfEncryptionKey') || // toprfEncryptionKey is not defined typeof value.toprfEncryptionKey !== 'string' || // toprfEncryptionKey is not a string - !('toprfPwEncryptionKey' in value) || // toprfPwEncryptionKey is not defined + !hasProperty(value, 'toprfPwEncryptionKey') || // toprfPwEncryptionKey is not defined typeof value.toprfPwEncryptionKey !== 'string' || // toprfPwEncryptionKey is not a string - !('toprfAuthKeyPair' in value) || // toprfAuthKeyPair is not defined + !hasProperty(value, 'toprfAuthKeyPair') || // toprfAuthKeyPair is not defined typeof value.toprfAuthKeyPair !== 'string' // toprfAuthKeyPair is not a string ) { throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidVaultData); } - if (!('revokeToken' in value) || typeof value.revokeToken !== 'string') { + if ( + !hasProperty(value, 'revokeToken') || + typeof value.revokeToken !== 'string' + ) { throw new Error( SeedlessOnboardingControllerErrorMessage.InvalidRevokeToken, ); } - if (!('accessToken' in value) || typeof value.accessToken !== 'string') { + if ( + !hasProperty(value, 'accessToken') || + typeof value.accessToken !== 'string' + ) { throw new Error( SeedlessOnboardingControllerErrorMessage.InvalidAccessToken, ); } + + // if profilePairingToken is provided, it must be a string + if ( + hasProperty(value, 'profilePairingToken') && + typeof value.profilePairingToken !== 'string' + ) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + } } diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index e4e877cd3e..30bdb0c045 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -13,6 +13,7 @@ export enum Web3AuthNetwork { export enum AuthConnection { Google = 'google', Apple = 'apple', + Telegram = 'telegram', } export enum SecretType { @@ -24,6 +25,12 @@ export enum SeedlessOnboardingMigrationVersion { V1 = 1, } +export enum ProfilePairingStatus { + NotPaired = 'not_paired', + Paired = 'paired', + PairingFailed = 'pairing_failed', +} + export enum SeedlessOnboardingControllerErrorMessage { ControllerLocked = `${controllerName} - The operation cannot be completed while the controller is locked.`, VaultLocked = `${controllerName} - The operation cannot be completed while the vault is locked.`, @@ -36,6 +43,7 @@ export enum SeedlessOnboardingControllerErrorMessage { InvalidRevokeToken = `${controllerName} - Invalid revoke token`, InvalidAccessToken = `${controllerName} - Invalid access token`, InvalidMetadataAccessToken = `${controllerName} - Invalid metadata access token`, + InvalidProfilePairingToken = `${controllerName} - Invalid profile pairing token`, MissingCredentials = `${controllerName} - Cannot unlock vault without password and encryption key`, ExpiredCredentials = `${controllerName} - Encryption key and salt provided are expired`, InvalidEmptyPassword = `${controllerName} - Password cannot be empty.`, @@ -45,6 +53,7 @@ export enum SeedlessOnboardingControllerErrorMessage { VaultError = `${controllerName} - Cannot unlock without a previous vault.`, InvalidSecretMetadata = `${controllerName} - Invalid secret metadata`, MissingKeyringId = `${controllerName} - Keyring ID is required to store SRP backups.`, + NoSocialBackups = `${controllerName} - No social backups found`, FailedToEncryptAndStoreSecretData = `${controllerName} - Failed to encrypt and store secret data`, FailedToFetchSecretMetadata = `${controllerName} - Failed to fetch secret metadata`, NoSecretDataFound = `${controllerName} - No secret data found`, @@ -62,4 +71,5 @@ export enum SeedlessOnboardingControllerErrorMessage { InvalidPasswordOutdatedCache = `${controllerName} - Invalid password outdated cache provided.`, FailedToRefreshJWTTokens = `${controllerName} - Failed to refresh JWT tokens`, PrimarySrpCannotBeAddedViaAddNewSecretData = `${controllerName} - PrimarySrp cannot be added via addNewSecretData. Use createToprfKeyAndBackupSeedPhrase instead.`, + FailedToPairSocialLoginWithIdentityProfileService = `${controllerName} - Failed to pair social login with identity profile service`, } diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 690dafbab9..3f61afae09 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -3,7 +3,6 @@ export { getInitialSeedlessOnboardingControllerStateWithDefaults as getDefaultSeedlessOnboardingControllerState, } from './SeedlessOnboardingController'; export type { - SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerGetStateAction, SeedlessOnboardingControllerStateChangeEvent, @@ -37,8 +36,10 @@ export type { SeedlessOnboardingControllerCheckNodeAuthTokenExpiredAction, SeedlessOnboardingControllerCheckMetadataAccessTokenExpiredAction, SeedlessOnboardingControllerCheckAccessTokenExpiredAction, + SeedlessOnboardingControllerPairProfileServiceWithSocialLoginAction, } from './SeedlessOnboardingController-method-action-types'; export type { + SeedlessOnboardingControllerOptions, AuthenticatedUserDetails, SocialBackupsMetadata, SeedlessOnboardingControllerState, @@ -51,6 +52,7 @@ export { SeedlessOnboardingMigrationVersion, AuthConnection, SecretType, + ProfilePairingStatus, } from './constants'; export { SecretMetadata } from './SecretMetadata'; export { RecoveryError, SeedlessOnboardingError } from './errors'; diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 39033b6fee..fdc8987d4a 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -1,7 +1,19 @@ +import type * as encryptionUtils from '@metamask/browser-passworder'; +import { + DefaultEncryptionResult, + EncryptionResultConstraint, + Encryptor, +} from '@metamask/keyring-controller'; import type { KeyPair, NodeAuthTokens } from '@metamask/toprf-secure-backup'; import type { MutexInterface } from 'async-mutex'; -import type { AuthConnection, SecretType } from './constants'; +import { + AuthConnection, + ProfilePairingStatus, + SecretType, + Web3AuthNetwork, +} from './constants'; +import type { SeedlessOnboardingControllerMessenger } from './SeedlessOnboardingController'; /** * The backup state of the secret data. @@ -26,6 +38,11 @@ export type SocialBackupsMetadata = { * This is only required for `Mnemonic` secret data. */ keyringId?: string; + + /** + * The optional profile pairing status to determine if the secret data is paired with the profile sync service. + */ + profilePairingStatus?: ProfilePairingStatus; }; export type AuthenticatedUserDetails = { @@ -65,6 +82,22 @@ export type AuthenticatedUserDetails = { * The refresh token used to refresh expired nodeAuthTokens. */ refreshToken: string; + + /** + * The revoke token used to revoke refresh token and get new refresh token and new revoke token. + */ + revokeToken: string; + + /** + * The metadata access token used to authenticate with metadata service. + */ + metadataAccessToken: string; + + /** + * The profile pairing token used to pair Telegram social logins with the + * profile sync auth service. + */ + profilePairingToken?: string; }; export type SRPBackedUpUserDetails = { @@ -156,6 +189,7 @@ export type SeedlessOnboardingControllerState = /** * The access token used for pairing with profile sync auth service and to access other services. + * TODO: To be removed after the profile pairing token is implemented for other social logins. */ accessToken?: string; @@ -176,6 +210,12 @@ export type SeedlessOnboardingControllerState = * Used to prevent re-running migrations. */ migrationVersion: number; + + /** + * The profile pairing token used to pair the user social profile (telegram for now) with the profile sync auth service later after the onboarding is complete. + * This is temporarily stored in state during authentication and then persisted in the vault for the later use. + */ + profilePairingToken?: string; }; /** @@ -220,6 +260,90 @@ export type RenewRefreshToken = (params: { newRefreshToken: string; }>; +/** + * Seedless Onboarding Controller Options. + * + * @param messenger - The messenger to use for this controller. + * @param state - The initial state to set on this controller. + * @param encryptor - The encryptor to use for encrypting and decrypting seedless onboarding vault. + */ +export type SeedlessOnboardingControllerOptions< + EncryptionKey = encryptionUtils.EncryptionKey, + SupportedKeyDerivationParams = encryptionUtils.KeyDerivationOptions, + EncryptionResult extends + EncryptionResultConstraint = + DefaultEncryptionResult, +> = { + messenger: SeedlessOnboardingControllerMessenger; + + /** + * Initial state to set on this controller. + */ + state?: Partial; + + /** + * Encryptor to use for encrypting and decrypting seedless onboarding vault. + * + * @default browser-passworder @link https://github.com/MetaMask/browser-passworder + */ + encryptor: Encryptor< + EncryptionKey, + SupportedKeyDerivationParams, + EncryptionResult + >; + + /** + * The base URL of the profile service, which is used to pair the user social profile with the profile sync auth service. + */ + profilePairingEndpoint: string; + + /** + * A function to get a new jwt token using refresh token. + */ + refreshJWTToken: RefreshJWTToken; + + /** + * A function to revoke the refresh token. + */ + revokeRefreshToken: RevokeRefreshToken; + + /** + * A function to renew the refresh token and get new revoke token. + */ + renewRefreshToken: RenewRefreshToken; + + /** + * A function to make an HTTP request. e.g. Fetch API. + */ + fetchFunction: typeof fetch; + + /** + * Optional key derivation interface for the TOPRF client. + * + * If provided, it will be used as an additional step during + * key derivation. This can be used, for example, to inject a slow key + * derivation step to protect against local brute force attacks on the + * password. + * + * @default browser-passworder @link https://github.com/MetaMask/browser-passworder + */ + toprfKeyDeriver?: ToprfKeyDeriver; + + /** + * Type of Web3Auth network to be used for the Seedless Onboarding flow. + * + * @default Web3AuthNetwork.Mainnet + */ + network?: Web3AuthNetwork; + + /** + * The TTL of the password outdated cache in milliseconds. + * + * @default PASSWORD_OUTDATED_CACHE_TTL_MS + */ + passwordOutdatedCacheTTL?: number; +}; + /** * A function executed within a mutually exclusive lock, with * a mutex releaser in its option bag. @@ -257,11 +381,15 @@ export type VaultData = { * The access token used for pairing with profile sync auth service and to access other services. */ accessToken: string; + /** + * The profile pairing token used to pair the user social profile with the profile sync auth service later after the onboarding is complete. + */ + profilePairingToken?: string; }; export type DeserializedVaultData = Pick< VaultData, - 'accessToken' | 'revokeToken' + 'accessToken' | 'revokeToken' | 'profilePairingToken' > & { toprfEncryptionKey: Uint8Array; toprfPwEncryptionKey: Uint8Array; diff --git a/packages/seedless-onboarding-controller/src/utils.ts b/packages/seedless-onboarding-controller/src/utils.ts index 035f405d60..4114d6eb53 100644 --- a/packages/seedless-onboarding-controller/src/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils.ts @@ -64,6 +64,7 @@ export function serializeVaultData(data: DeserializedVaultData): string { toprfAuthKeyPair, revokeToken: data.revokeToken, accessToken: data.accessToken, + profilePairingToken: data.profilePairingToken, }); }