From 06041d10f72c281b4f40bd4a14b6a329611f83d9 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 30 Apr 2026 12:56:02 +0800 Subject: [PATCH 01/19] feat: telegram profile token minting --- .../src/SeedlessOnboardingController.ts | 157 +++++++++--------- .../src/assertions.ts | 52 ++++-- .../src/constants.ts | 1 + .../src/types.ts | 130 ++++++++++++++- 4 files changed, 253 insertions(+), 87 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index a28fbce534..534b2c9765 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -41,6 +41,7 @@ import { assertIsPasswordOutdatedCacheValid, assertIsSeedlessOnboardingUserAuthenticated, assertIsValidPassword, + assertIsValidTokenMintResult, assertIsValidVaultData, } from './assertions'; import type { AuthConnection } from './constants'; @@ -70,7 +71,7 @@ import type { RenewRefreshToken, VaultData, DeserializedVaultData, - ToprfKeyDeriver, + SeedlessOnboardingControllerOptions, } from './types'; import { compareAndGetLatestToken, @@ -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 { + try { + const authenticateResult = await this.#withControllerLock(async () => { + const { profilePairingToken, authConnection, authConnectionId, userId, groupedAuthConnectionId, socialLoginEmail } = params; + + const response = await this.#fetch(`${this.#authServiceBaseUrl}/profile-pairing/mint`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id_token: profilePairingToken, + }), + }); + + if (!response.ok) { + throw new Error('Failed to mint profile pairing token'); + } + + const data = await response.json(); + assertIsValidTokenMintResult(data); + + return this.authenticate({ + idTokens: data.idTokens, + accessToken: data.accessToken, + metadataAccessToken: data.metadataAccessToken, + authConnection, + authConnectionId, + userId, + groupedAuthConnectionId, + socialLoginEmail, + refreshToken: data.refreshToken, + revokeToken: data.revokeToken, + skipLock: true, // skip lock since we already have the lock + }) + }); + return authenticateResult; + } catch (error) { + log('Error minting profile pairing token', error); + throw error; + } + } + /** * Authenticate OAuth user using the seedless onboarding flow * and determine if the user is already registered or not. diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/assertions.ts index c9929e1a2b..5bef5d1c81 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -1,5 +1,32 @@ import { SeedlessOnboardingControllerErrorMessage } from './constants'; -import type { AuthenticatedUserDetails, VaultData } from './types'; +import type { AuthenticatedUserDetails, TokenMintResult, VaultData } from './types'; +import { hasProperty } from '@metamask/utils'; + +export function assertIsValidTokenMintResult(value: unknown): asserts value is TokenMintResult { + if (!value || typeof value !== 'object') { + throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + } + + if (!hasProperty(value, 'idTokens') || !Array.isArray(value.idTokens)) { + throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + } + + if (!hasProperty(value, 'accessToken') || typeof value.accessToken !== 'string') { + throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + } + + if (!hasProperty(value, 'metadataAccessToken') || typeof value.metadataAccessToken !== 'string') { + throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + } + + if (!hasProperty(value, 'revokeToken') || typeof value.revokeToken !== 'string') { + throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + } + + if (!hasProperty(value, 'refreshToken') || typeof value.refreshToken !== 'string') { + throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + } +} /** * Assert that the provided password is a valid non-empty string. @@ -33,9 +60,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 +71,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 +81,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,23 +134,23 @@ 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, ); diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index e4e877cd3e..82091a5cfd 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -62,4 +62,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.`, + InvalidTokenMintResult = `${controllerName} - Invalid token mint result.`, } diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 39033b6fee..59b2e9d4d8 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -1,7 +1,10 @@ import type { KeyPair, NodeAuthTokens } from '@metamask/toprf-secure-backup'; import type { MutexInterface } from 'async-mutex'; -import type { AuthConnection, SecretType } from './constants'; +import type { AuthConnection, SecretType, Web3AuthNetwork } from './constants'; +import { DefaultEncryptionResult, EncryptionResultConstraint, Encryptor } from '@metamask/keyring-controller'; +import type * as encryptionUtils from '@metamask/browser-passworder'; +import type { SeedlessOnboardingControllerMessenger } from './SeedlessOnboardingController'; /** * The backup state of the secret data. @@ -28,6 +31,35 @@ export type SocialBackupsMetadata = { keyringId?: string; }; +export type TokenMintResult = { + /** + * The ID tokens issued by the OAuth verification service. + * This will be provided to the TOPRF client to authenticate the user. + */ + idTokens: string[]; + + /** + * The access token used for pairing with profile sync auth service and to access other services. + * Currently, this is being used for the Google and Apple logins. + */ + accessToken: string; + + /** + * The metadata access token used to authenticate with metadata service. + */ + metadataAccessToken: string; + + /** + * The revoke token used to revoke refresh token and get new refresh token and new revoke token. + */ + revokeToken: string; + + /** + * The refresh token used to refresh expired nodeAuthTokens. + */ + refreshToken: string; +} + export type AuthenticatedUserDetails = { /** * Type of social login provider. @@ -156,6 +188,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 +209,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 +259,91 @@ 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 auth service, which is used to mint the profile pairing token. + */ + authServiceBaseUrl: 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,6 +381,10 @@ 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< From ce1c40ed75ba7650acc60855c1852bce90b284b0 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 30 Apr 2026 13:31:08 +0800 Subject: [PATCH 02/19] chore: add tests --- .../src/SeedlessOnboardingController.test.ts | 162 +++++++++++++- .../src/SeedlessOnboardingController.ts | 4 +- .../src/assertions.test.ts | 205 +++++++++++++++++- .../src/constants.ts | 1 + .../src/types.ts | 10 + 5 files changed, 376 insertions(+), 6 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index bc1406e3e6..d2749bf995 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -79,9 +79,8 @@ import { } from './SeedlessOnboardingController'; import type { SeedlessOnboardingControllerMessenger, - SeedlessOnboardingControllerOptions, } from './SeedlessOnboardingController'; -import type { SeedlessOnboardingControllerState } from './types'; +import type { SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerState } from './types'; const authConnection = AuthConnection.Google; const socialLoginEmail = 'user-test@gmail.com'; @@ -166,6 +165,9 @@ type WithControllerArgs = WithControllerCallback, ]; +const mockFetchFunction = jest.fn() as typeof fetch; +const mockAuthServiceBaseUrl = 'https://mock-auth-service.example'; + /** * Get the default vault encryptor for the Seedless Onboarding Controller. * @@ -255,6 +257,8 @@ async function withController( refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, + fetchFunction: mockFetchFunction, + authServiceBaseUrl: mockAuthServiceBaseUrl, ...rest, }); @@ -739,6 +743,8 @@ describe('SeedlessOnboardingController', () => { refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, + authServiceBaseUrl: mockAuthServiceBaseUrl, + fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); expect(controller.state).toStrictEqual( @@ -766,6 +772,8 @@ describe('SeedlessOnboardingController', () => { refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, + authServiceBaseUrl: mockAuthServiceBaseUrl, + fetchFunction: mockFetchFunction, }), ).not.toThrow(); }); @@ -839,6 +847,8 @@ describe('SeedlessOnboardingController', () => { revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, state: initialState, + authServiceBaseUrl: mockAuthServiceBaseUrl, + fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); expect(controller.state).toMatchObject(initialState); @@ -902,6 +912,144 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('mintProfilePairingTokenAndAuthenticate', () => { + const mockFetch = jest.fn(); + + it('should mint a profile pairing token and authenticate with the minted credentials', async () => { + const mintedTokenResult = { + idTokens: ['minted-id-token'], + accessToken: 'minted-access-token', + metadataAccessToken: 'minted-metadata-access-token', + refreshToken: 'minted-refresh-token', + revokeToken: 'minted-revoke-token', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mintedTokenResult), + }); + + await withController( + { + fetchFunction: mockFetch, + authServiceBaseUrl: mockAuthServiceBaseUrl, + }, + async ({ controller }) => { + const authenticateResult = { + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }; + const authenticateSpy = jest + .spyOn(controller, 'authenticate') + .mockResolvedValue(authenticateResult); + + const result = + await controller.mintProfilePairingTokenAndAuthenticate({ + profilePairingToken: 'profile-pairing-token', + authConnection, + authConnectionId, + groupedAuthConnectionId, + userId, + socialLoginEmail, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-auth-service.example/profile-pairing/mint', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id_token: 'profile-pairing-token', + }), + }, + ); + expect(authenticateSpy).toHaveBeenCalledWith({ + idTokens: mintedTokenResult.idTokens, + accessToken: mintedTokenResult.accessToken, + metadataAccessToken: mintedTokenResult.metadataAccessToken, + authConnection, + authConnectionId, + userId, + groupedAuthConnectionId, + socialLoginEmail, + refreshToken: mintedTokenResult.refreshToken, + revokeToken: mintedTokenResult.revokeToken, + skipLock: true, + }); + expect(result).toStrictEqual(authenticateResult); + }, + ); + }); + + it('should throw an error if the minting request failed', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + }); + + await withController( + { + fetchFunction: mockFetch, + authServiceBaseUrl: mockAuthServiceBaseUrl, + }, + async ({ controller }) => { + const authenticateSpy = jest.spyOn(controller, 'authenticate'); + + await expect( + controller.mintProfilePairingTokenAndAuthenticate({ + profilePairingToken: 'profile-pairing-token', + authConnection, + authConnectionId, + groupedAuthConnectionId, + userId, + socialLoginEmail, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToMintProfilePairingToken, + ); + expect(authenticateSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('should throw when the minted token response has an invalid shape', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + idTokens, + accessToken, + metadataAccessToken, + revokeToken, + }), + }); + + await withController( + { + fetchFunction: mockFetch, + authServiceBaseUrl: mockAuthServiceBaseUrl, + }, + async ({ controller }) => { + const authenticateSpy = jest.spyOn(controller, 'authenticate'); + + await expect( + controller.mintProfilePairingTokenAndAuthenticate({ + profilePairingToken: 'profile-pairing-token', + authConnection, + authConnectionId, + groupedAuthConnectionId, + userId, + socialLoginEmail, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult, + ); + expect(authenticateSpy).not.toHaveBeenCalled(); + }, + ); + }); + }); + describe('authenticate', () => { it('should be able to register a new user', async () => { await withController( @@ -7547,6 +7695,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: validToken, }), renewRefreshToken: jest.fn(), + authServiceBaseUrl: mockAuthServiceBaseUrl, + fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); @@ -7572,6 +7722,8 @@ describe('SeedlessOnboardingController', () => { revokeRefreshToken: jest.fn(), state, renewRefreshToken: jest.fn(), + authServiceBaseUrl: mockAuthServiceBaseUrl, + fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); @@ -7598,6 +7750,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: expiredToken, }), renewRefreshToken: jest.fn(), + authServiceBaseUrl: mockAuthServiceBaseUrl, + fetchFunction: mockFetchFunction, }); // mock refreshAuthTokens to return a new token @@ -7628,6 +7782,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: nearExpiryToken, }), renewRefreshToken: jest.fn(), + authServiceBaseUrl: mockAuthServiceBaseUrl, + fetchFunction: mockFetchFunction, }); jest.spyOn(controller, 'refreshAuthTokens').mockResolvedValue(); @@ -7657,6 +7813,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: freshToken, }), renewRefreshToken: jest.fn(), + authServiceBaseUrl: mockAuthServiceBaseUrl, + fetchFunction: mockFetchFunction, }); jest.spyOn(controller, 'refreshAuthTokens').mockResolvedValue(); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 534b2c9765..9d14811258 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -516,7 +516,7 @@ export class SeedlessOnboardingController< }); if (!response.ok) { - throw new Error('Failed to mint profile pairing token'); + throw new Error(SeedlessOnboardingControllerErrorMessage.FailedToMintProfilePairingToken); } const data = await response.json(); @@ -2798,7 +2798,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..57832f272a 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/assertions.test.ts @@ -1,10 +1,97 @@ import { + assertIsSeedlessOnboardingUserAuthenticated, assertIsPasswordOutdatedCacheValid, assertIsValidPassword, + assertIsValidTokenMintResult, assertIsValidVaultData, } from './assertions'; -import { SeedlessOnboardingControllerErrorMessage } from './constants'; -import { VaultData } from './types'; +import { AuthConnection, SeedlessOnboardingControllerErrorMessage } from './constants'; +import { AuthenticatedUserDetails, TokenMintResult, VaultData } from './types'; + +describe('assertIsValidTokenMintResult', () => { + const createValidTokenMintResult = (): TokenMintResult => ({ + idTokens: ['id-token'], + accessToken: 'access-token', + metadataAccessToken: 'metadata-access-token', + revokeToken: 'revoke-token', + refreshToken: 'refresh-token', + }); + + it('should throw when the value is not an object', () => { + expect(() => { + assertIsValidTokenMintResult(null); + }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + + expect(() => { + assertIsValidTokenMintResult(undefined); + }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + + expect(() => { + assertIsValidTokenMintResult('invalid'); + }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + }); + + it.each([ + [ + 'idTokens is missing', + (): TokenMintResult => { + const invalidResult = createValidTokenMintResult(); + delete (invalidResult as Record).idTokens; + return invalidResult; + }, + ], + [ + 'idTokens is not an array', + (): TokenMintResult => ({ + ...createValidTokenMintResult(), + // @ts-expect-error - invalid type for testing + idTokens: 'invalid', + }), + ], + [ + 'accessToken is missing', + (): TokenMintResult => { + const invalidResult = createValidTokenMintResult(); + delete (invalidResult as Record).accessToken; + return invalidResult; + }, + ], + [ + 'metadataAccessToken is not a string', + (): TokenMintResult => ({ + ...createValidTokenMintResult(), + // @ts-expect-error - invalid type for testing + metadataAccessToken: 123, + }), + ], + [ + 'revokeToken is missing', + (): TokenMintResult => { + const invalidResult = createValidTokenMintResult(); + delete (invalidResult as Record).revokeToken; + return invalidResult; + }, + ], + [ + 'refreshToken is not a string', + (): TokenMintResult => ({ + ...createValidTokenMintResult(), + // @ts-expect-error - invalid type for testing + refreshToken: false, + }), + ], + ])('should throw when %s', (_caseName, buildInvalidValue) => { + expect(() => { + assertIsValidTokenMintResult(buildInvalidValue()); + }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); + }); + + it('should not throw for a valid token mint result', () => { + expect(() => { + assertIsValidTokenMintResult(createValidTokenMintResult()); + }).not.toThrow(); + }); +}); describe('assertIsValidPassword', () => { it('should throw when password is not a string', () => { @@ -42,6 +129,119 @@ 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 +254,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', () => { diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 82091a5cfd..528fdf6103 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -62,5 +62,6 @@ 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.`, + FailedToMintProfilePairingToken = `${controllerName} - Failed to mint profile pairing token`, InvalidTokenMintResult = `${controllerName} - Invalid token mint result.`, } diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 59b2e9d4d8..371c7452a0 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -97,6 +97,16 @@ 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; }; export type SRPBackedUpUserDetails = { From 917395bd00db38cd67b19ebccba608b4e0793fa1 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 30 Apr 2026 17:10:28 +0800 Subject: [PATCH 03/19] feat: pairing social login to profile sync --- .../src/SeedlessOnboardingController.test.ts | 280 +++++++++++++++++- .../src/SeedlessOnboardingController.ts | 84 +++++- .../src/assertions.test.ts | 20 ++ .../src/assertions.ts | 7 + .../src/constants.ts | 3 + .../src/types.ts | 13 +- .../src/utils.ts | 1 + 7 files changed, 376 insertions(+), 32 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index d2749bf995..6bc2d959d7 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -167,6 +167,7 @@ type WithControllerArgs = const mockFetchFunction = jest.fn() as typeof fetch; const mockAuthServiceBaseUrl = 'https://mock-auth-service.example'; +const mockProfilePairingEndpoint = 'https://mock-profile-pairing.example'; /** * Get the default vault encryptor for the Seedless Onboarding Controller. @@ -258,7 +259,8 @@ async function withController( revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, fetchFunction: mockFetchFunction, - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, ...rest, }); @@ -541,6 +543,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. */ @@ -552,12 +555,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; }> { @@ -572,6 +577,9 @@ async function createMockVault( }), revokeToken: mockRevokeToken, accessToken: mockAccessToken, + ...(mockProfilePairingToken + ? { profilePairingToken: mockProfilePairingToken } + : {}), }); const { vault: encryptedMockVault, exportedKeyString } = @@ -588,6 +596,7 @@ async function createMockVault( vaultEncryptionSalt: JSON.parse(encryptedMockVault).salt, revokeToken: mockRevokeToken, accessToken: mockAccessToken, + profilePairingToken: mockProfilePairingToken, encryptedKeyringEncryptionKey, pwEncKey, }; @@ -644,6 +653,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. @@ -662,6 +672,7 @@ function getMockInitialControllerState(options?: { encryptedSeedlessEncryptionKey?: string; metadataAccessToken?: string; accessToken?: string; + profilePairingToken?: string; pendingToBeRevokedTokens?: | { refreshToken: string; @@ -693,6 +704,13 @@ 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; @@ -743,7 +761,8 @@ describe('SeedlessOnboardingController', () => { refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); @@ -772,7 +791,8 @@ describe('SeedlessOnboardingController', () => { refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }), ).not.toThrow(); @@ -847,7 +867,8 @@ describe('SeedlessOnboardingController', () => { revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, state: initialState, - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); @@ -931,7 +952,7 @@ describe('SeedlessOnboardingController', () => { await withController( { fetchFunction: mockFetch, - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, }, async ({ controller }) => { const authenticateResult = { @@ -953,7 +974,7 @@ describe('SeedlessOnboardingController', () => { }); expect(mockFetch).toHaveBeenCalledWith( - 'https://mock-auth-service.example/profile-pairing/mint', + mockAuthServiceBaseUrl, { method: 'POST', headers: { @@ -991,7 +1012,7 @@ describe('SeedlessOnboardingController', () => { await withController( { fetchFunction: mockFetch, - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, }, async ({ controller }) => { const authenticateSpy = jest.spyOn(controller, 'authenticate'); @@ -1027,7 +1048,7 @@ describe('SeedlessOnboardingController', () => { await withController( { fetchFunction: mockFetch, - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, }, async ({ controller }) => { const authenticateSpy = jest.spyOn(controller, 'authenticate'); @@ -1050,6 +1071,171 @@ 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, + }), + authConnection: AuthConnection.Telegram, + }, + }, + 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], + }), + }); + }, + ); + }); + + 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, + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + expect( + await controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), + ).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + }, + ); + }); + + it('should throw if the Telegram 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, + }), + authConnection: AuthConnection.Telegram, + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + await expect( + controller.pairProfileServiceWithSocialLogin(mockProfileServiceToken), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }, + ); + }); + + 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, + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + await expect( + controller.pairProfileServiceWithSocialLogin(mockProfileServiceToken), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToPairSocialLoginWithIdentityProfileService, + ); + }, + ); + }); + }); + describe('authenticate', () => { it('should be able to register a new user', async () => { await withController( @@ -1522,6 +1708,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -1651,6 +1840,8 @@ describe('SeedlessOnboardingController', () => { authKeyPair, MOCK_PASSWORD, controller.state.revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -1732,6 +1923,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -1884,6 +2078,49 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should throw error 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: undefined, + }), + authConnection: AuthConnection.Telegram, + }, + }, + async ({ controller, toprfClient, baseMessenger }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + + const mockSecretDataAdd = handleMockSecretDataAdd(); + + await expect( + baseMessenger.call( + 'SeedlessOnboardingController:createToprfKeyAndBackupSeedPhrase', + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + expect(controller.state.vault).toBe(mockVault.encryptedMockVault); + }, + ); + }); + it('should throw an error if user does not have the AuthToken', async () => { await withController( { state: { userId, authConnectionId, groupedAuthConnectionId } }, @@ -2878,6 +3115,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -2960,6 +3200,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -3042,6 +3285,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -3127,6 +3373,9 @@ describe('SeedlessOnboardingController', () => { pwEncKey, authKeyPair, MOCK_PASSWORD, + revokeToken, + accessToken, + controller.state.profilePairingToken, ); const expectedVaultValue = await encryptor.decrypt( @@ -7695,7 +7944,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: validToken, }), renewRefreshToken: jest.fn(), - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); @@ -7722,7 +7972,8 @@ describe('SeedlessOnboardingController', () => { revokeRefreshToken: jest.fn(), state, renewRefreshToken: jest.fn(), - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); expect(controller).toBeDefined(); @@ -7750,7 +8001,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: expiredToken, }), renewRefreshToken: jest.fn(), - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); @@ -7782,7 +8034,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: nearExpiryToken, }), renewRefreshToken: jest.fn(), - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); @@ -7813,7 +8066,8 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: freshToken, }), renewRefreshToken: jest.fn(), - authServiceBaseUrl: mockAuthServiceBaseUrl, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 9d14811258..d576ce3126 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -44,7 +44,7 @@ import { assertIsValidTokenMintResult, assertIsValidVaultData, } from './assertions'; -import type { AuthConnection } from './constants'; +import { AuthConnection } from './constants'; import { controllerName, PASSWORD_OUTDATED_CACHE_TTL_MS, @@ -350,7 +350,9 @@ export class SeedlessOnboardingController< readonly #fetch: typeof fetch; - readonly #authServiceBaseUrl: string; + readonly #idTokenMintEndpoint: string; + + readonly #profilePairingEndpoint: string; readonly #refreshJWTToken: RefreshJWTToken; @@ -385,7 +387,8 @@ export class SeedlessOnboardingController< * @param options.state - Initial state to set on this controller. * @param options.encryptor - An optional encryptor to use for encrypting and decrypting seedless onboarding vault. * @param options.fetchFunction - A function to make an HTTP request. e.g. Fetch API. - * @param options.authServiceBaseUrl - The base URL of the auth service, which is used to mint the profile pairing token. + * @param options.idTokenMintEndpoint - The base URL of the auth service, which is used to mint the profile pairing token. + * @param options.profilePairingEndpoint - The base URL of the profile service, which is used to pair the user social profile with the profile sync auth service. * @param options.toprfKeyDeriver - An optional key derivation interface for the TOPRF client. * @param options.network - The network to be used for the Seedless Onboarding flow. * @param options.refreshJWTToken - A function to get a new jwt token using refresh token. @@ -403,7 +406,8 @@ export class SeedlessOnboardingController< revokeRefreshToken, renewRefreshToken, fetchFunction, - authServiceBaseUrl, + idTokenMintEndpoint, + profilePairingEndpoint, passwordOutdatedCacheTTL = PASSWORD_OUTDATED_CACHE_TTL_MS, }: SeedlessOnboardingControllerOptions< EncryptionKey, @@ -428,7 +432,8 @@ export class SeedlessOnboardingController< fetchMetadataAccessCreds: this.fetchMetadataAccessCreds.bind(this), }); this.#fetch = fetchFunction; - this.#authServiceBaseUrl = authServiceBaseUrl; + this.#idTokenMintEndpoint = idTokenMintEndpoint; + this.#profilePairingEndpoint = profilePairingEndpoint; this.#refreshJWTToken = refreshJWTToken; this.#revokeRefreshToken = revokeRefreshToken; this.#renewRefreshToken = renewRefreshToken; @@ -505,7 +510,7 @@ export class SeedlessOnboardingController< const authenticateResult = await this.#withControllerLock(async () => { const { profilePairingToken, authConnection, authConnectionId, userId, groupedAuthConnectionId, socialLoginEmail } = params; - const response = await this.#fetch(`${this.#authServiceBaseUrl}/profile-pairing/mint`, { + const response = await this.#fetch(this.#idTokenMintEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1157,6 +1162,46 @@ 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(); + + const { profilePairingToken, authConnection } = this.state; + if (authConnection !== AuthConnection.Telegram) { + // we don't throw the error here, since we don't support profile pairing for other social logins yet + // technically, clients should not call this method for other social logins + log(`warning: skipping profile pairing for ${authConnection} social login`); + 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); + } + }); + } + /** * Sync the latest global password to the controller. * reset vault with latest globalPassword, @@ -1829,6 +1874,7 @@ export class SeedlessOnboardingController< state.vaultEncryptionSalt = vaultEncryptionSalt; state.revokeToken = vaultData.revokeToken; state.accessToken = vaultData.accessToken; + state.profilePairingToken = vaultData.profilePairingToken; }); const deserializedVaultData = deserializeVaultData(vaultData); @@ -2024,8 +2070,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, @@ -2033,6 +2079,7 @@ export class SeedlessOnboardingController< toprfPwEncryptionKey: rawToprfPwEncryptionKey, revokeToken, accessToken, + profilePairingToken, }; await this.#updateVault({ @@ -2155,19 +2202,20 @@ 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( + async #getRevokeTokenAndProfilePairingTokens( password: string, - ): Promise<{ accessToken: string; revokeToken: string }> { - let { accessToken, revokeToken } = this.state; + ): Promise<{ revokeToken: string; accessToken: string; profilePairingToken?: string }> { + let { accessToken, revokeToken, profilePairingToken } = this.state; + const { authConnection } = 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 }; + if (accessToken && revokeToken && profilePairingToken) { + 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 @@ -2176,6 +2224,7 @@ 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 @@ -2193,7 +2242,12 @@ export class SeedlessOnboardingController< ); } - return { accessToken, revokeToken }; + if (authConnection === AuthConnection.Telegram && !profilePairingToken) { + // for now, profilePairingToken is only available for Telegram social login + throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken); + } + + return { accessToken, profilePairingToken, revokeToken }; } /** diff --git a/packages/seedless-onboarding-controller/src/assertions.test.ts b/packages/seedless-onboarding-controller/src/assertions.test.ts index 57832f272a..36f23d071c 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/assertions.test.ts @@ -338,6 +338,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', () => { @@ -360,6 +371,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 5bef5d1c81..12848ff7d9 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -155,4 +155,11 @@ export function assertIsValidVaultData( 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 528fdf6103..5797ec62ee 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 { @@ -36,6 +37,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.`, @@ -64,4 +66,5 @@ export enum SeedlessOnboardingControllerErrorMessage { PrimarySrpCannotBeAddedViaAddNewSecretData = `${controllerName} - PrimarySrp cannot be added via addNewSecretData. Use createToprfKeyAndBackupSeedPhrase instead.`, FailedToMintProfilePairingToken = `${controllerName} - Failed to mint profile pairing token`, InvalidTokenMintResult = `${controllerName} - Invalid token mint result.`, + FailedToPairSocialLoginWithIdentityProfileService = `${controllerName} - Failed to pair social login with identity profile service`, } diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 371c7452a0..bb0635a74f 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -1,7 +1,7 @@ import type { KeyPair, NodeAuthTokens } from '@metamask/toprf-secure-backup'; import type { MutexInterface } from 'async-mutex'; -import type { AuthConnection, SecretType, Web3AuthNetwork } from './constants'; +import { AuthConnection, SecretType, Web3AuthNetwork } from './constants'; import { DefaultEncryptionResult, EncryptionResultConstraint, Encryptor } from '@metamask/keyring-controller'; import type * as encryptionUtils from '@metamask/browser-passworder'; import type { SeedlessOnboardingControllerMessenger } from './SeedlessOnboardingController'; @@ -304,7 +304,12 @@ export type SeedlessOnboardingControllerOptions< /** * The base URL of the auth service, which is used to mint the profile pairing token. */ - authServiceBaseUrl: string; + idTokenMintEndpoint: string; + + /** + * 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. @@ -394,12 +399,12 @@ export type VaultData = { /** * 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; + 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, }); } From f2d9456bb640638697ecc814b9cceec92f3a0f5f Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 30 Apr 2026 18:16:43 +0800 Subject: [PATCH 04/19] chore: updated ChangeLog --- packages/seedless-onboarding-controller/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index a7416afc65..46030dfe59 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -19,6 +19,7 @@ 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 `mintProfilePairingTokenAndAuthenticate` and `pairProfileServiceWithSocialLogin`, including `AuthConnection.Telegram`, new token/error types, and encrypted-vault storage for `profilePairingToken` ([#8652](https://github.com/MetaMask/core/pull/8652)) ### Changed @@ -35,6 +36,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`, `idTokenMintEndpoint`, and `profilePairingEndpoint` in `SeedlessOnboardingControllerOptions` so the controller can mint Telegram login tokens and 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 plus both endpoint URLs ### Fixed From cb2aeca4b7def97201c61dab3e2b72388f825de8 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 30 Apr 2026 18:17:48 +0800 Subject: [PATCH 05/19] feat: update isSeedlessOnboardingUserAuthenticated state for telegram user --- .../src/SeedlessOnboardingController.test.ts | 79 ++++++++++++++++++- .../src/SeedlessOnboardingController.ts | 11 ++- .../src/assertions.test.ts | 21 +++++ .../src/assertions.ts | 17 +++- .../src/types.ts | 6 ++ 5 files changed, 125 insertions(+), 9 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 6bc2d959d7..0896e41d74 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -875,6 +875,40 @@ describe('SeedlessOnboardingController', () => { expect(controller.state).toMatchObject(initialState); }); + it('should initialize isSeedlessOnboardingUserAuthenticated as false for Telegram users without profilePairingToken', () => { + const mockRefreshJWTToken = jest.fn().mockResolvedValue({ + idTokens: ['newIdToken'], + }); + const mockRevokeRefreshToken = jest.fn().mockResolvedValue(undefined); + const mockRenewRefreshToken = jest.fn().mockResolvedValue({ + newRevokeToken: 'newRevokeToken', + newRefreshToken: 'newRefreshToken', + }); + const { messenger } = mockSeedlessOnboardingMessenger(); + + const controller = new SeedlessOnboardingController({ + messenger, + encryptor: getDefaultSeedlessOnboardingVaultEncryptor(), + refreshJWTToken: mockRefreshJWTToken, + revokeRefreshToken: mockRevokeRefreshToken, + renewRefreshToken: mockRenewRefreshToken, + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + profilePairingToken: undefined, + }), + authConnection: AuthConnection.Telegram, + }, + idTokenMintEndpoint: mockAuthServiceBaseUrl, + profilePairingEndpoint: mockProfilePairingEndpoint, + fetchFunction: mockFetchFunction, + }); + + expect(controller.state.isSeedlessOnboardingUserAuthenticated).toBe( + false, + ); + }); + it('should throw an error if the password outdated cache TTL is not a valid number', () => { const mockRefreshJWTToken = jest.fn().mockResolvedValue({ idTokens: ['newIdToken'], @@ -989,6 +1023,7 @@ describe('SeedlessOnboardingController', () => { idTokens: mintedTokenResult.idTokens, accessToken: mintedTokenResult.accessToken, metadataAccessToken: mintedTokenResult.metadataAccessToken, + profilePairingToken: 'profile-pairing-token', authConnection, authConnectionId, userId, @@ -1661,6 +1696,31 @@ describe('SeedlessOnboardingController', () => { ).toBe(false); }); }); + + it('should return false and update state when a Telegram user is missing profilePairingToken', 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( + false, + ); + }, + ); + }); }); describe('createToprfKeyAndBackupSeedPhrase', () => { @@ -2093,14 +2153,29 @@ describe('SeedlessOnboardingController', () => { ...getMockInitialControllerState({ withMockAuthenticatedUser: true, vault: mockVault.encryptedMockVault, - profilePairingToken: undefined, + profilePairingToken: 'profile-pairing-token', }), authConnection: AuthConnection.Telegram, }, }, async ({ controller, toprfClient, baseMessenger }) => { mockcreateLocalKey(toprfClient, MOCK_PASSWORD); - jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + + 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(); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index d576ce3126..0f3969a5fa 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -528,6 +528,7 @@ export class SeedlessOnboardingController< assertIsValidTokenMintResult(data); return this.authenticate({ + profilePairingToken, idTokens: data.idTokens, accessToken: data.accessToken, metadataAccessToken: data.metadataAccessToken, @@ -564,6 +565,7 @@ export class SeedlessOnboardingController< * @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. */ async authenticate(params: { @@ -578,6 +580,7 @@ export class SeedlessOnboardingController< refreshToken: string; revokeToken?: string; skipLock?: boolean; + profilePairingToken?: string; }): Promise { const doAuthenticateWithNodes = async (): Promise => { try { @@ -592,6 +595,7 @@ export class SeedlessOnboardingController< revokeToken, accessToken, metadataAccessToken, + profilePairingToken, } = params; const authenticationResult = await this.toprfClient.authenticate({ @@ -615,6 +619,7 @@ export class SeedlessOnboardingController< state.revokeToken = revokeToken; } state.accessToken = accessToken; + state.profilePairingToken = profilePairingToken; // we will check if the controller state is properly set with the authenticated user info // before setting the isSeedlessOnboardingUserAuthenticated to true @@ -2211,7 +2216,6 @@ export class SeedlessOnboardingController< password: string, ): Promise<{ revokeToken: string; accessToken: string; profilePairingToken?: string }> { let { accessToken, revokeToken, profilePairingToken } = this.state; - const { authConnection } = 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 && profilePairingToken) { @@ -2242,11 +2246,6 @@ export class SeedlessOnboardingController< ); } - if (authConnection === AuthConnection.Telegram && !profilePairingToken) { - // for now, profilePairingToken is only available for Telegram social login - throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken); - } - return { accessToken, profilePairingToken, revokeToken }; } diff --git a/packages/seedless-onboarding-controller/src/assertions.test.ts b/packages/seedless-onboarding-controller/src/assertions.test.ts index 36f23d071c..c17d8c3614 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/assertions.test.ts @@ -233,6 +233,27 @@ describe('assertIsSeedlessOnboardingUserAuthenticated', () => { ); }); + it('should throw InvalidProfilePairingToken for Telegram users when profilePairingToken is missing', () => { + expect(() => { + assertIsSeedlessOnboardingUserAuthenticated({ + ...createValidAuthenticatedUser(), + authConnection: AuthConnection.Telegram, + }); + }).toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + }); + + it('should not throw for Telegram users when profilePairingToken is present', () => { + expect(() => { + assertIsSeedlessOnboardingUserAuthenticated({ + ...createValidAuthenticatedUser(), + authConnection: AuthConnection.Telegram, + profilePairingToken: 'profile-pairing-token', + }); + }).not.toThrow(); + }); + it('should not throw for a valid authenticated user', () => { expect(() => { assertIsSeedlessOnboardingUserAuthenticated( diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/assertions.ts index 12848ff7d9..58f0c05d09 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -1,4 +1,7 @@ -import { SeedlessOnboardingControllerErrorMessage } from './constants'; +import { + AuthConnection, + SeedlessOnboardingControllerErrorMessage, +} from './constants'; import type { AuthenticatedUserDetails, TokenMintResult, VaultData } from './types'; import { hasProperty } from '@metamask/utils'; @@ -97,6 +100,18 @@ export function assertIsSeedlessOnboardingUserAuthenticated( SeedlessOnboardingControllerErrorMessage.InvalidMetadataAccessToken, ); } + + // if authConnection is Telegram, profilePairingToken must be provided + if ( + hasProperty(value, 'authConnection') && + value.authConnection === AuthConnection.Telegram && + (!hasProperty(value, 'profilePairingToken') || + typeof value.profilePairingToken !== 'string') + ) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + } } /** diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index bb0635a74f..d7e175cb03 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -107,6 +107,12 @@ export type AuthenticatedUserDetails = { * 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 = { From 7c06ac6dece308601379dbe73849c71a4ac7337d Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 30 Apr 2026 18:28:27 +0800 Subject: [PATCH 06/19] fix: fixed build --- .../src/SeedlessOnboardingController.ts | 2 +- packages/seedless-onboarding-controller/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 0f3969a5fa..38e981924c 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -2218,7 +2218,7 @@ export class SeedlessOnboardingController< 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 && profilePairingToken) { + if (accessToken && revokeToken) { return { accessToken, profilePairingToken, revokeToken }; } diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 690dafbab9..6c7ec0506b 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, @@ -39,6 +38,7 @@ export type { SeedlessOnboardingControllerCheckAccessTokenExpiredAction, } from './SeedlessOnboardingController-method-action-types'; export type { + SeedlessOnboardingControllerOptions, AuthenticatedUserDetails, SocialBackupsMetadata, SeedlessOnboardingControllerState, From 7b757acc56438178aa3ae5ef77091f09f72b925d Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 12:16:54 +0800 Subject: [PATCH 07/19] feat: removed minting method from the controller and updated tests --- .../CHANGELOG.md | 8 +- ...nboardingController-method-action-types.ts | 1 + .../src/SeedlessOnboardingController.test.ts | 267 ++++++++---------- .../src/SeedlessOnboardingController.ts | 71 ----- .../src/assertions.test.ts | 88 +----- .../src/assertions.ts | 28 +- .../src/constants.ts | 2 - .../src/types.ts | 34 --- 8 files changed, 125 insertions(+), 374 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 46030dfe59..1a01dec3c6 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -19,7 +19,9 @@ 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 `mintProfilePairingTokenAndAuthenticate` and `pairProfileServiceWithSocialLogin`, including `AuthConnection.Telegram`, new token/error types, and encrypted-vault storage for `profilePairingToken` ([#8652](https://github.com/MetaMask/core/pull/8652)) +- 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)) + - `authenticateMethod` will also accept the `profilePairingToken` in the params and will save to the state. ### Changed @@ -36,8 +38,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`, `idTokenMintEndpoint`, and `profilePairingEndpoint` in `SeedlessOnboardingControllerOptions` so the controller can mint Telegram login tokens and 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 plus both endpoint URLs +- **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..600a4ed2c5 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 = { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 0896e41d74..b32a9e50d2 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -166,7 +166,6 @@ type WithControllerArgs = ]; const mockFetchFunction = jest.fn() as typeof fetch; -const mockAuthServiceBaseUrl = 'https://mock-auth-service.example'; const mockProfilePairingEndpoint = 'https://mock-profile-pairing.example'; /** @@ -259,7 +258,6 @@ async function withController( revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, fetchFunction: mockFetchFunction, - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, ...rest, }); @@ -761,7 +759,6 @@ describe('SeedlessOnboardingController', () => { refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); @@ -791,7 +788,6 @@ describe('SeedlessOnboardingController', () => { refreshJWTToken: mockRefreshJWTToken, revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }), @@ -867,7 +863,6 @@ describe('SeedlessOnboardingController', () => { revokeRefreshToken: mockRevokeRefreshToken, renewRefreshToken: mockRenewRefreshToken, state: initialState, - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); @@ -899,7 +894,6 @@ describe('SeedlessOnboardingController', () => { }), authConnection: AuthConnection.Telegram, }, - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); @@ -967,145 +961,6 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('mintProfilePairingTokenAndAuthenticate', () => { - const mockFetch = jest.fn(); - - it('should mint a profile pairing token and authenticate with the minted credentials', async () => { - const mintedTokenResult = { - idTokens: ['minted-id-token'], - accessToken: 'minted-access-token', - metadataAccessToken: 'minted-metadata-access-token', - refreshToken: 'minted-refresh-token', - revokeToken: 'minted-revoke-token', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mintedTokenResult), - }); - - await withController( - { - fetchFunction: mockFetch, - idTokenMintEndpoint: mockAuthServiceBaseUrl, - }, - async ({ controller }) => { - const authenticateResult = { - nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, - isNewUser: false, - }; - const authenticateSpy = jest - .spyOn(controller, 'authenticate') - .mockResolvedValue(authenticateResult); - - const result = - await controller.mintProfilePairingTokenAndAuthenticate({ - profilePairingToken: 'profile-pairing-token', - authConnection, - authConnectionId, - groupedAuthConnectionId, - userId, - socialLoginEmail, - }); - - expect(mockFetch).toHaveBeenCalledWith( - mockAuthServiceBaseUrl, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - id_token: 'profile-pairing-token', - }), - }, - ); - expect(authenticateSpy).toHaveBeenCalledWith({ - idTokens: mintedTokenResult.idTokens, - accessToken: mintedTokenResult.accessToken, - metadataAccessToken: mintedTokenResult.metadataAccessToken, - profilePairingToken: 'profile-pairing-token', - authConnection, - authConnectionId, - userId, - groupedAuthConnectionId, - socialLoginEmail, - refreshToken: mintedTokenResult.refreshToken, - revokeToken: mintedTokenResult.revokeToken, - skipLock: true, - }); - expect(result).toStrictEqual(authenticateResult); - }, - ); - }); - - it('should throw an error if the minting request failed', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 401, - }); - - await withController( - { - fetchFunction: mockFetch, - idTokenMintEndpoint: mockAuthServiceBaseUrl, - }, - async ({ controller }) => { - const authenticateSpy = jest.spyOn(controller, 'authenticate'); - - await expect( - controller.mintProfilePairingTokenAndAuthenticate({ - profilePairingToken: 'profile-pairing-token', - authConnection, - authConnectionId, - groupedAuthConnectionId, - userId, - socialLoginEmail, - }), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.FailedToMintProfilePairingToken, - ); - expect(authenticateSpy).not.toHaveBeenCalled(); - }, - ); - }); - - it('should throw when the minted token response has an invalid shape', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ - idTokens, - accessToken, - metadataAccessToken, - revokeToken, - }), - }); - - await withController( - { - fetchFunction: mockFetch, - idTokenMintEndpoint: mockAuthServiceBaseUrl, - }, - async ({ controller }) => { - const authenticateSpy = jest.spyOn(controller, 'authenticate'); - - await expect( - controller.mintProfilePairingTokenAndAuthenticate({ - profilePairingToken: 'profile-pairing-token', - authConnection, - authConnectionId, - groupedAuthConnectionId, - userId, - socialLoginEmail, - }), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult, - ); - expect(authenticateSpy).not.toHaveBeenCalled(); - }, - ); - }); - }); - describe('pairProfileServiceWithSocialLogin', () => { const mockPassword = 'mock-password'; const mockProfileServiceToken = 'profile-service-token'; @@ -1838,6 +1693,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( { @@ -8019,7 +7991,6 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: validToken, }), renewRefreshToken: jest.fn(), - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); @@ -8047,7 +8018,6 @@ describe('SeedlessOnboardingController', () => { revokeRefreshToken: jest.fn(), state, renewRefreshToken: jest.fn(), - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); @@ -8076,7 +8046,6 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: expiredToken, }), renewRefreshToken: jest.fn(), - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); @@ -8109,7 +8078,6 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: nearExpiryToken, }), renewRefreshToken: jest.fn(), - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); @@ -8141,7 +8109,6 @@ describe('SeedlessOnboardingController', () => { metadataAccessToken: freshToken, }), renewRefreshToken: jest.fn(), - idTokenMintEndpoint: mockAuthServiceBaseUrl, profilePairingEndpoint: mockProfilePairingEndpoint, fetchFunction: mockFetchFunction, }); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 38e981924c..956fa0c900 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -41,7 +41,6 @@ import { assertIsPasswordOutdatedCacheValid, assertIsSeedlessOnboardingUserAuthenticated, assertIsValidPassword, - assertIsValidTokenMintResult, assertIsValidVaultData, } from './assertions'; import { AuthConnection } from './constants'; @@ -350,8 +349,6 @@ export class SeedlessOnboardingController< readonly #fetch: typeof fetch; - readonly #idTokenMintEndpoint: string; - readonly #profilePairingEndpoint: string; readonly #refreshJWTToken: RefreshJWTToken; @@ -387,7 +384,6 @@ export class SeedlessOnboardingController< * @param options.state - Initial state to set on this controller. * @param options.encryptor - An optional encryptor to use for encrypting and decrypting seedless onboarding vault. * @param options.fetchFunction - A function to make an HTTP request. e.g. Fetch API. - * @param options.idTokenMintEndpoint - The base URL of the auth service, which is used to mint the profile pairing token. * @param options.profilePairingEndpoint - The base URL of the profile service, which is used to pair the user social profile with the profile sync auth service. * @param options.toprfKeyDeriver - An optional key derivation interface for the TOPRF client. * @param options.network - The network to be used for the Seedless Onboarding flow. @@ -406,7 +402,6 @@ export class SeedlessOnboardingController< revokeRefreshToken, renewRefreshToken, fetchFunction, - idTokenMintEndpoint, profilePairingEndpoint, passwordOutdatedCacheTTL = PASSWORD_OUTDATED_CACHE_TTL_MS, }: SeedlessOnboardingControllerOptions< @@ -432,7 +427,6 @@ export class SeedlessOnboardingController< fetchMetadataAccessCreds: this.fetchMetadataAccessCreds.bind(this), }); this.#fetch = fetchFunction; - this.#idTokenMintEndpoint = idTokenMintEndpoint; this.#profilePairingEndpoint = profilePairingEndpoint; this.#refreshJWTToken = refreshJWTToken; this.#revokeRefreshToken = revokeRefreshToken; @@ -484,71 +478,6 @@ export class SeedlessOnboardingController< } } - /** - * Mint a profile pairing token for the user. - * This function is used to mint the token from the Social login (Telegram for now). - * The minting will return values ready for the Toprf authentication and authenticate the user. - * - * @param params - The parameters for minting the profile pairing token. - * @param params.profilePairingToken - The profile pairing token to be used for minting the profile pairing token. - * @param params.authConnection - The social login provider. - * @param params.authConnectionId - OAuth authConnectionId from dashboard - * @param params.userId - user email or id from Social login - * @param params.groupedAuthConnectionId - Optional grouped authConnectionId to be used for the authenticate request. - * @param params.socialLoginEmail - The user email from Social login. - * @returns A promise that resolves to the TOPRF Authentication result. - */ - async mintProfilePairingTokenAndAuthenticate(params: { - profilePairingToken: string; - authConnection: AuthConnection; - authConnectionId: string; - userId: string; - groupedAuthConnectionId?: string; - socialLoginEmail?: string; - }): Promise { - try { - const authenticateResult = await this.#withControllerLock(async () => { - const { profilePairingToken, authConnection, authConnectionId, userId, groupedAuthConnectionId, socialLoginEmail } = params; - - const response = await this.#fetch(this.#idTokenMintEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - id_token: profilePairingToken, - }), - }); - - if (!response.ok) { - throw new Error(SeedlessOnboardingControllerErrorMessage.FailedToMintProfilePairingToken); - } - - const data = await response.json(); - assertIsValidTokenMintResult(data); - - return this.authenticate({ - profilePairingToken, - idTokens: data.idTokens, - accessToken: data.accessToken, - metadataAccessToken: data.metadataAccessToken, - authConnection, - authConnectionId, - userId, - groupedAuthConnectionId, - socialLoginEmail, - refreshToken: data.refreshToken, - revokeToken: data.revokeToken, - skipLock: true, // skip lock since we already have the lock - }) - }); - return authenticateResult; - } catch (error) { - log('Error minting profile pairing token', error); - throw error; - } - } - /** * Authenticate OAuth user using the seedless onboarding flow * and determine if the user is already registered or not. diff --git a/packages/seedless-onboarding-controller/src/assertions.test.ts b/packages/seedless-onboarding-controller/src/assertions.test.ts index c17d8c3614..078f2f7172 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/assertions.test.ts @@ -2,96 +2,10 @@ import { assertIsSeedlessOnboardingUserAuthenticated, assertIsPasswordOutdatedCacheValid, assertIsValidPassword, - assertIsValidTokenMintResult, assertIsValidVaultData, } from './assertions'; import { AuthConnection, SeedlessOnboardingControllerErrorMessage } from './constants'; -import { AuthenticatedUserDetails, TokenMintResult, VaultData } from './types'; - -describe('assertIsValidTokenMintResult', () => { - const createValidTokenMintResult = (): TokenMintResult => ({ - idTokens: ['id-token'], - accessToken: 'access-token', - metadataAccessToken: 'metadata-access-token', - revokeToken: 'revoke-token', - refreshToken: 'refresh-token', - }); - - it('should throw when the value is not an object', () => { - expect(() => { - assertIsValidTokenMintResult(null); - }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - - expect(() => { - assertIsValidTokenMintResult(undefined); - }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - - expect(() => { - assertIsValidTokenMintResult('invalid'); - }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - }); - - it.each([ - [ - 'idTokens is missing', - (): TokenMintResult => { - const invalidResult = createValidTokenMintResult(); - delete (invalidResult as Record).idTokens; - return invalidResult; - }, - ], - [ - 'idTokens is not an array', - (): TokenMintResult => ({ - ...createValidTokenMintResult(), - // @ts-expect-error - invalid type for testing - idTokens: 'invalid', - }), - ], - [ - 'accessToken is missing', - (): TokenMintResult => { - const invalidResult = createValidTokenMintResult(); - delete (invalidResult as Record).accessToken; - return invalidResult; - }, - ], - [ - 'metadataAccessToken is not a string', - (): TokenMintResult => ({ - ...createValidTokenMintResult(), - // @ts-expect-error - invalid type for testing - metadataAccessToken: 123, - }), - ], - [ - 'revokeToken is missing', - (): TokenMintResult => { - const invalidResult = createValidTokenMintResult(); - delete (invalidResult as Record).revokeToken; - return invalidResult; - }, - ], - [ - 'refreshToken is not a string', - (): TokenMintResult => ({ - ...createValidTokenMintResult(), - // @ts-expect-error - invalid type for testing - refreshToken: false, - }), - ], - ])('should throw when %s', (_caseName, buildInvalidValue) => { - expect(() => { - assertIsValidTokenMintResult(buildInvalidValue()); - }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - }); - - it('should not throw for a valid token mint result', () => { - expect(() => { - assertIsValidTokenMintResult(createValidTokenMintResult()); - }).not.toThrow(); - }); -}); +import { AuthenticatedUserDetails, VaultData } from './types'; describe('assertIsValidPassword', () => { it('should throw when password is not a string', () => { diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/assertions.ts index 58f0c05d09..2a4443bb44 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -2,35 +2,9 @@ import { AuthConnection, SeedlessOnboardingControllerErrorMessage, } from './constants'; -import type { AuthenticatedUserDetails, TokenMintResult, VaultData } from './types'; +import type { AuthenticatedUserDetails, VaultData } from './types'; import { hasProperty } from '@metamask/utils'; -export function assertIsValidTokenMintResult(value: unknown): asserts value is TokenMintResult { - if (!value || typeof value !== 'object') { - throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - } - - if (!hasProperty(value, 'idTokens') || !Array.isArray(value.idTokens)) { - throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - } - - if (!hasProperty(value, 'accessToken') || typeof value.accessToken !== 'string') { - throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - } - - if (!hasProperty(value, 'metadataAccessToken') || typeof value.metadataAccessToken !== 'string') { - throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - } - - if (!hasProperty(value, 'revokeToken') || typeof value.revokeToken !== 'string') { - throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - } - - if (!hasProperty(value, 'refreshToken') || typeof value.refreshToken !== 'string') { - throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidTokenMintResult); - } -} - /** * Assert that the provided password is a valid non-empty string. * diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 5797ec62ee..2f3d53ece6 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -64,7 +64,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.`, - FailedToMintProfilePairingToken = `${controllerName} - Failed to mint profile pairing token`, - InvalidTokenMintResult = `${controllerName} - Invalid token mint result.`, FailedToPairSocialLoginWithIdentityProfileService = `${controllerName} - Failed to pair social login with identity profile service`, } diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index d7e175cb03..d09dfe2a77 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -31,35 +31,6 @@ export type SocialBackupsMetadata = { keyringId?: string; }; -export type TokenMintResult = { - /** - * The ID tokens issued by the OAuth verification service. - * This will be provided to the TOPRF client to authenticate the user. - */ - idTokens: string[]; - - /** - * The access token used for pairing with profile sync auth service and to access other services. - * Currently, this is being used for the Google and Apple logins. - */ - accessToken: string; - - /** - * The metadata access token used to authenticate with metadata service. - */ - metadataAccessToken: string; - - /** - * The revoke token used to revoke refresh token and get new refresh token and new revoke token. - */ - revokeToken: string; - - /** - * The refresh token used to refresh expired nodeAuthTokens. - */ - refreshToken: string; -} - export type AuthenticatedUserDetails = { /** * Type of social login provider. @@ -307,11 +278,6 @@ export type SeedlessOnboardingControllerOptions< EncryptionResult >; - /** - * The base URL of the auth service, which is used to mint the profile pairing token. - */ - idTokenMintEndpoint: string; - /** * The base URL of the profile service, which is used to pair the user social profile with the profile sync auth service. */ From dccbcec3e929e75276ae248f5732439a8ecbcab6 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 12:26:20 +0800 Subject: [PATCH 08/19] fix: fixed lint --- .../src/SeedlessOnboardingController.test.ts | 28 ++++++++------- .../src/SeedlessOnboardingController.ts | 35 +++++++++++++------ .../src/assertions.test.ts | 20 +++++------ .../src/assertions.ts | 18 +++++++--- .../src/types.ts | 9 +++-- 5 files changed, 70 insertions(+), 40 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index b32a9e50d2..511b240cb1 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -77,10 +77,11 @@ import { SeedlessOnboardingController, getInitialSeedlessOnboardingControllerStateWithDefaults, } from './SeedlessOnboardingController'; +import type { SeedlessOnboardingControllerMessenger } from './SeedlessOnboardingController'; import type { - SeedlessOnboardingControllerMessenger, -} from './SeedlessOnboardingController'; -import type { SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerState } from './types'; + SeedlessOnboardingControllerOptions, + SeedlessOnboardingControllerState, +} from './types'; const authConnection = AuthConnection.Google; const socialLoginEmail = 'user-test@gmail.com'; @@ -702,13 +703,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.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; @@ -1081,7 +1081,9 @@ describe('SeedlessOnboardingController', () => { ); await expect( - controller.pairProfileServiceWithSocialLogin(mockProfileServiceToken), + controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, ); @@ -1117,7 +1119,9 @@ describe('SeedlessOnboardingController', () => { ); await expect( - controller.pairProfileServiceWithSocialLogin(mockProfileServiceToken), + controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.FailedToPairSocialLoginWithIdentityProfileService, ); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 956fa0c900..9aafdcc7b6 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1102,28 +1102,36 @@ export class SeedlessOnboardingController< * @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 { + async pairProfileServiceWithSocialLogin( + profileSvcToken: string, + ): Promise { return await this.#withControllerLock(async () => { this.#assertIsUnlocked(); - + const { profilePairingToken, authConnection } = this.state; if (authConnection !== AuthConnection.Telegram) { // we don't throw the error here, since we don't support profile pairing for other social logins yet // technically, clients should not call this method for other social logins - log(`warning: skipping profile pairing for ${authConnection} social login`); + log( + `warning: skipping profile pairing for ${authConnection} social login`, + ); return; } if (!profilePairingToken) { - log('Error: profile pairing token is not available for Telegram social login'); - throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken); + 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}`, + Authorization: `Bearer ${profileSvcToken}`, }, body: JSON.stringify({ jwts: [profilePairingToken], @@ -1131,7 +1139,9 @@ export class SeedlessOnboardingController< }); if (!response.ok) { - throw new Error(SeedlessOnboardingControllerErrorMessage.FailedToPairSocialLoginWithIdentityProfileService); + throw new Error( + SeedlessOnboardingControllerErrorMessage.FailedToPairSocialLoginWithIdentityProfileService, + ); } }); } @@ -2141,9 +2151,11 @@ export class SeedlessOnboardingController< * @param password - The password to decrypt the vault. * @returns The access token and revoke token. */ - async #getRevokeTokenAndProfilePairingTokens( - password: string, - ): Promise<{ revokeToken: string; accessToken: string; profilePairingToken?: string }> { + 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. @@ -2157,7 +2169,8 @@ export class SeedlessOnboardingController< const { vaultData } = await this.#decryptAndParseVaultData({ password }); accessToken = accessToken ?? vaultData.accessToken; revokeToken = revokeToken ?? vaultData.revokeToken; - profilePairingToken = profilePairingToken ?? vaultData.profilePairingToken; + profilePairingToken = + profilePairingToken ?? vaultData.profilePairingToken; } // we should always throw an error if the access token or revoke token is not available diff --git a/packages/seedless-onboarding-controller/src/assertions.test.ts b/packages/seedless-onboarding-controller/src/assertions.test.ts index 078f2f7172..eed3c17cd7 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/assertions.test.ts @@ -4,7 +4,10 @@ import { assertIsValidPassword, assertIsValidVaultData, } from './assertions'; -import { AuthConnection, SeedlessOnboardingControllerErrorMessage } from './constants'; +import { + AuthConnection, + SeedlessOnboardingControllerErrorMessage, +} from './constants'; import { AuthenticatedUserDetails, VaultData } from './types'; describe('assertIsValidPassword', () => { @@ -105,7 +108,7 @@ describe('assertIsSeedlessOnboardingUserAuthenticated', () => { ], [ 'nodeAuthTokens is not an array', - (): AuthenticatedUserDetails => ({ + (): AuthenticatedUserDetails => ({ ...createValidAuthenticatedUser(), // @ts-expect-error - invalid type for testing nodeAuthTokens: 'invalid', @@ -118,14 +121,11 @@ describe('assertIsSeedlessOnboardingUserAuthenticated', () => { nodeAuthTokens: [createValidAuthenticatedUser().nodeAuthTokens[0]], }), ], - ])( - 'should throw InsufficientAuthToken when %s', - (_caseName, buildValue) => { - expect(() => { - assertIsSeedlessOnboardingUserAuthenticated(buildValue()); - }).toThrow(SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken); - }, - ); + ])('should throw InsufficientAuthToken when %s', (_caseName, buildValue) => { + expect(() => { + assertIsSeedlessOnboardingUserAuthenticated(buildValue()); + }).toThrow(SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken); + }); it('should throw InvalidRefreshToken when refreshToken is missing', () => { const invalidUser = createValidAuthenticatedUser(); diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/assertions.ts index 2a4443bb44..ff954f7786 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -1,9 +1,10 @@ +import { hasProperty } from '@metamask/utils'; + import { AuthConnection, SeedlessOnboardingControllerErrorMessage, } from './constants'; import type { AuthenticatedUserDetails, VaultData } from './types'; -import { hasProperty } from '@metamask/utils'; /** * Assert that the provided password is a valid non-empty string. @@ -133,20 +134,29 @@ export function assertIsValidVaultData( throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidVaultData); } - if (!hasProperty(value, 'revokeToken') || typeof value.revokeToken !== 'string') { + if ( + !hasProperty(value, 'revokeToken') || + typeof value.revokeToken !== 'string' + ) { throw new Error( SeedlessOnboardingControllerErrorMessage.InvalidRevokeToken, ); } - if (!hasProperty(value, 'accessToken') || 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') { + if ( + hasProperty(value, 'profilePairingToken') && + typeof value.profilePairingToken !== 'string' + ) { throw new Error( SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, ); diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index d09dfe2a77..419c639413 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -1,9 +1,13 @@ +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 { AuthConnection, SecretType, Web3AuthNetwork } from './constants'; -import { DefaultEncryptionResult, EncryptionResultConstraint, Encryptor } from '@metamask/keyring-controller'; -import type * as encryptionUtils from '@metamask/browser-passworder'; import type { SeedlessOnboardingControllerMessenger } from './SeedlessOnboardingController'; /** @@ -330,7 +334,6 @@ export type SeedlessOnboardingControllerOptions< passwordOutdatedCacheTTL?: number; }; - /** * A function executed within a mutually exclusive lock, with * a mutex releaser in its option bag. From f6a2341a531642719bd548214c21eaab9ead0385 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 12:28:38 +0800 Subject: [PATCH 09/19] feat: cleanup profilePairingToken when locked --- .../src/SeedlessOnboardingController.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 9aafdcc7b6..ad82463506 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1089,6 +1089,7 @@ export class SeedlessOnboardingController< delete state.vaultEncryptionSalt; delete state.revokeToken; delete state.accessToken; + delete state.profilePairingToken; }); this.#cachedDecryptedVaultData = undefined; From b676cc5726000d99abb6ef00193ab1115966899c Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 12:39:19 +0800 Subject: [PATCH 10/19] fix: fixed eslint suppressions --- eslint-suppressions.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 29a627b6ee..a0404dd38d 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 @@ -2351,4 +2346,4 @@ "count": 10 } } -} +} \ No newline at end of file From 440fb9b8effa6bcb8fcc7d225b74741a298093ea Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 12:49:14 +0800 Subject: [PATCH 11/19] fix: fixed lint check --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a0404dd38d..3b8e1d5c8b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2346,4 +2346,4 @@ "count": 10 } } -} \ No newline at end of file +} From b8719af922cfb8e8d52114a5445ccf251e0cd4f3 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 15:15:35 +0800 Subject: [PATCH 12/19] feat: added new state, 'profilePairingStatus' --- .../CHANGELOG.md | 3 + ...nboardingController-method-action-types.ts | 11 ++ .../src/SeedlessOnboardingController.test.ts | 116 ++++++++++++++++++ .../src/SeedlessOnboardingController.ts | 110 ++++++++++++----- .../src/constants.ts | 7 ++ .../src/index.ts | 2 + .../src/types.ts | 15 ++- 7 files changed, 231 insertions(+), 33 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 1a01dec3c6..01769c1da7 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) - `authenticateMethod` will also accept the `profilePairingToken` in the params and will save to the state. +- Add new persisted value, `profilePairingStatus` to controller state. ([#8652](https://github.com/MetaMask/core/pull/8652)) + - `profilePairingStatus` has the default value as `ProfilePairingStatus.NotPaired` and the value will be updated when we do the paring. +- Add new public method, `updateProfilePairingStatus` which is used to update the controller state. ([#8652](https://github.com/MetaMask/core/pull/8652)) ### Changed 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 600a4ed2c5..f247b8387d 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -110,6 +110,16 @@ export type SeedlessOnboardingControllerUpdateBackupMetadataStateAction = { handler: SeedlessOnboardingController['updateBackupMetadataState']; }; +/** + * Update the profile pairing status in the controller state. + * + * @param status - The new profile pairing status. + */ +export type SeedlessOnboardingControllerUpdateProfilePairingStatusAction = { + type: `SeedlessOnboardingController:updateProfilePairingStatus`; + handler: SeedlessOnboardingController['updateProfilePairingStatus']; +}; + /** * Verify the password validity by decrypting the vault. * @@ -354,6 +364,7 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerFetchAllSecretDataAction | SeedlessOnboardingControllerChangePasswordAction | SeedlessOnboardingControllerUpdateBackupMetadataStateAction + | SeedlessOnboardingControllerUpdateProfilePairingStatusAction | SeedlessOnboardingControllerVerifyVaultPasswordAction | SeedlessOnboardingControllerGetSecretDataBackupStateAction | SeedlessOnboardingControllerSubmitPasswordAction diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 511b240cb1..5fe3dc3995 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'; @@ -961,6 +962,25 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('updateProfilePairingStatus', () => { + it('should update the profile pairing status through the messenger', async () => { + await withController(async ({ controller, baseMessenger }) => { + expect(controller.state.profilePairingStatus).toBe( + ProfilePairingStatus.NotPaired, + ); + + await baseMessenger.call( + 'SeedlessOnboardingController:updateProfilePairingStatus', + ProfilePairingStatus.Paired, + ); + + expect(controller.state.profilePairingStatus).toBe( + ProfilePairingStatus.Paired, + ); + }); + }); + }); + describe('pairProfileServiceWithSocialLogin', () => { const mockPassword = 'mock-password'; const mockProfileServiceToken = 'profile-service-token'; @@ -1022,6 +1042,85 @@ describe('SeedlessOnboardingController', () => { jwts: [mockProfilePairingToken], }), }); + expect(controller.state.profilePairingStatus).toBe( + ProfilePairingStatus.Paired, + ); + }, + ); + }); + + 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, + profilePairingStatus: ProfilePairingStatus.Paired, + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + await expect( + controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), + ).resolves.toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(controller.state.profilePairingStatus).toBe( + ProfilePairingStatus.Paired, + ); + }, + ); + }); + + it('should skip pairing when profile pairing is already in progress', 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, + profilePairingStatus: ProfilePairingStatus.PairingInProgress, + }, + }, + async ({ controller, baseMessenger }) => { + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + mockPassword, + ); + + await expect( + controller.pairProfileServiceWithSocialLogin( + mockProfileServiceToken, + ), + ).resolves.toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(controller.state.profilePairingStatus).toBe( + ProfilePairingStatus.PairingInProgress, + ); }, ); }); @@ -1054,6 +1153,9 @@ describe('SeedlessOnboardingController', () => { ), ).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); + expect(controller.state.profilePairingStatus).toBe( + ProfilePairingStatus.NotPaired, + ); }, ); }); @@ -1088,6 +1190,9 @@ describe('SeedlessOnboardingController', () => { SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, ); expect(mockFetch).not.toHaveBeenCalled(); + expect(controller.state.profilePairingStatus).toBe( + ProfilePairingStatus.PairingFailed, + ); }, ); }); @@ -1125,6 +1230,9 @@ describe('SeedlessOnboardingController', () => { ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.FailedToPairSocialLoginWithIdentityProfileService, ); + expect(controller.state.profilePairingStatus).toBe( + ProfilePairingStatus.PairingFailed, + ); }, ); }); @@ -9141,6 +9249,7 @@ describe('SeedlessOnboardingController', () => { pendingToBeRevokedTokens: [ { refreshToken: 'refreshToken', revokeToken: 'revokeToken' }, ], + profilePairingStatus: ProfilePairingStatus.Paired, refreshToken: 'refreshToken', revokeToken: 'revokeToken', socialBackupsMetadata: [], @@ -9169,6 +9278,7 @@ describe('SeedlessOnboardingController', () => { "isExpiredPwd": false, "timestamp": 1234567890, }, + "profilePairingStatus": "paired", } `); }, @@ -9196,6 +9306,7 @@ describe('SeedlessOnboardingController', () => { pendingToBeRevokedTokens: [ { refreshToken: 'refreshToken', revokeToken: 'revokeToken' }, ], + profilePairingStatus: ProfilePairingStatus.Paired, refreshToken: 'refreshToken', revokeToken: 'revokeToken', socialBackupsMetadata: [], @@ -9226,6 +9337,7 @@ describe('SeedlessOnboardingController', () => { "isExpiredPwd": false, "timestamp": 1234567890, }, + "profilePairingStatus": "paired", "userId": "userId", } `); @@ -9254,6 +9366,7 @@ describe('SeedlessOnboardingController', () => { pendingToBeRevokedTokens: [ { refreshToken: 'refreshToken', revokeToken: 'revokeToken' }, ], + profilePairingStatus: ProfilePairingStatus.Paired, refreshToken: 'refreshToken', revokeToken: 'revokeToken', socialBackupsMetadata: [], @@ -9293,6 +9406,7 @@ describe('SeedlessOnboardingController', () => { "revokeToken": "revokeToken", }, ], + "profilePairingStatus": "paired", "refreshToken": "refreshToken", "socialBackupsMetadata": [], "socialLoginEmail": "socialLoginEmail", @@ -9325,6 +9439,7 @@ describe('SeedlessOnboardingController', () => { pendingToBeRevokedTokens: [ { refreshToken: 'refreshToken', revokeToken: 'revokeToken' }, ], + profilePairingStatus: ProfilePairingStatus.Paired, refreshToken: 'refreshToken', revokeToken: 'revokeToken', socialBackupsMetadata: [], @@ -9345,6 +9460,7 @@ describe('SeedlessOnboardingController', () => { ).toMatchInlineSnapshot(` { "authConnection": "google", + "profilePairingStatus": "paired", "socialLoginEmail": "socialLoginEmail", } `); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index ad82463506..704768068b 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 { AuthConnection } from './constants'; +import { AuthConnection, ProfilePairingStatus } from './constants'; import { controllerName, PASSWORD_OUTDATED_CACHE_TTL_MS, @@ -109,6 +109,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkNodeAuthTokenExpired', 'checkMetadataAccessTokenExpired', 'checkAccessTokenExpired', + 'updateProfilePairingStatus', ] as const; // Actions @@ -154,6 +155,7 @@ export function getInitialSeedlessOnboardingControllerStateWithDefaults( const initialState = { socialBackupsMetadata: [], isSeedlessOnboardingUserAuthenticated: false, + profilePairingStatus: ProfilePairingStatus.NotPaired, migrationVersion: 0, ...overrides, }; @@ -315,6 +317,12 @@ const seedlessOnboardingMetadata: StateMetadata { + state.profilePairingStatus = status; + }); + } + /** * Verify the password validity by decrypting the vault. * @@ -1109,40 +1128,67 @@ export class SeedlessOnboardingController< return await this.#withControllerLock(async () => { this.#assertIsUnlocked(); - const { profilePairingToken, authConnection } = this.state; - if (authConnection !== AuthConnection.Telegram) { - // we don't throw the error here, since we don't support profile pairing for other social logins yet - // technically, clients should not call this method for other social logins - log( - `warning: skipping profile pairing for ${authConnection} social login`, - ); - return; - } + try { + const { profilePairingToken, authConnection, profilePairingStatus } = + this.state; + 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, - ); - } + if (profilePairingStatus === ProfilePairingStatus.PairingInProgress) { + log('Profile pairing already in progress'); + return; + } - const response = await this.#fetch(this.#profilePairingEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${profileSvcToken}`, - }, - body: JSON.stringify({ - jwts: [profilePairingToken], - }), - }); + if (authConnection !== AuthConnection.Telegram) { + // we don't throw the error here, since we don't support profile pairing for other social logins yet + // technically, clients should not call this method for other social logins + log( + `warning: skipping profile pairing for ${authConnection} social login`, + ); + return; + } - if (!response.ok) { - throw new Error( - SeedlessOnboardingControllerErrorMessage.FailedToPairSocialLoginWithIdentityProfileService, - ); + if (!profilePairingToken) { + log( + 'Error: profile pairing token is not available for Telegram social login', + ); + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); + } + + this.update((state) => { + state.profilePairingStatus = ProfilePairingStatus.PairingInProgress; + }); + + 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) => { + state.profilePairingStatus = ProfilePairingStatus.Paired; + }); + } catch (error) { + log('Error pairing profile service with social login', error); + this.update((state) => { + state.profilePairingStatus = ProfilePairingStatus.PairingFailed; + }); + throw error; } }); } diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 2f3d53ece6..e22861d7a9 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -25,6 +25,13 @@ export enum SeedlessOnboardingMigrationVersion { V1 = 1, } +export enum ProfilePairingStatus { + NotPaired = 'not_paired', + PairingInProgress = 'pairing_in_progress', + 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.`, diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 6c7ec0506b..cab06cd812 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -36,6 +36,7 @@ export type { SeedlessOnboardingControllerCheckNodeAuthTokenExpiredAction, SeedlessOnboardingControllerCheckMetadataAccessTokenExpiredAction, SeedlessOnboardingControllerCheckAccessTokenExpiredAction, + SeedlessOnboardingControllerUpdateProfilePairingStatusAction, } from './SeedlessOnboardingController-method-action-types'; export type { SeedlessOnboardingControllerOptions, @@ -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 419c639413..5296834323 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -7,7 +7,12 @@ import { import type { KeyPair, NodeAuthTokens } from '@metamask/toprf-secure-backup'; import type { MutexInterface } from 'async-mutex'; -import { AuthConnection, SecretType, Web3AuthNetwork } from './constants'; +import { + AuthConnection, + ProfilePairingStatus, + SecretType, + Web3AuthNetwork, +} from './constants'; import type { SeedlessOnboardingControllerMessenger } from './SeedlessOnboardingController'; /** @@ -206,6 +211,14 @@ export type SeedlessOnboardingControllerState = * This is temporarily stored in state during authentication and then persisted in the vault for the later use. */ profilePairingToken?: string; + + /** + * The profile pairing status. + * This is used to track the profile pairing status and to prevent re-pairing the user social profile with the profile sync auth service. + * + * @default ProfilePairingStatus.NotPaired + */ + profilePairingStatus: ProfilePairingStatus; }; /** From 3d798fb16bbe5fa21942da04a0271cfa46e243dd Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 15:24:23 +0800 Subject: [PATCH 13/19] fix: avoid overwriting profilePairingToken in state if emtpy --- .../src/SeedlessOnboardingController.test.ts | 58 ++++++++++++++++--- .../src/SeedlessOnboardingController.ts | 10 +++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 5fe3dc3995..24ab21137c 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -969,7 +969,7 @@ describe('SeedlessOnboardingController', () => { ProfilePairingStatus.NotPaired, ); - await baseMessenger.call( + baseMessenger.call( 'SeedlessOnboardingController:updateProfilePairingStatus', ProfilePairingStatus.Paired, ); @@ -1018,6 +1018,7 @@ describe('SeedlessOnboardingController', () => { ...getMockInitialControllerState({ withMockAuthenticatedUser: true, vault: mockVault.encryptedMockVault, + profilePairingToken: undefined, }), authConnection: AuthConnection.Telegram, }, @@ -1074,11 +1075,11 @@ describe('SeedlessOnboardingController', () => { mockPassword, ); - await expect( - controller.pairProfileServiceWithSocialLogin( + expect( + await controller.pairProfileServiceWithSocialLogin( mockProfileServiceToken, ), - ).resolves.toBeUndefined(); + ).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); expect(controller.state.profilePairingStatus).toBe( ProfilePairingStatus.Paired, @@ -1112,11 +1113,11 @@ describe('SeedlessOnboardingController', () => { mockPassword, ); - await expect( - controller.pairProfileServiceWithSocialLogin( + expect( + await controller.pairProfileServiceWithSocialLogin( mockProfileServiceToken, ), - ).resolves.toBeUndefined(); + ).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); expect(controller.state.profilePairingStatus).toBe( ProfilePairingStatus.PairingInProgress, @@ -1172,6 +1173,7 @@ describe('SeedlessOnboardingController', () => { ...getMockInitialControllerState({ withMockAuthenticatedUser: true, vault: mockVault.encryptedMockVault, + profilePairingToken: undefined, }), authConnection: AuthConnection.Telegram, }, @@ -4068,6 +4070,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( @@ -6815,6 +6858,7 @@ describe('SeedlessOnboardingController', () => { vault: MOCK_VAULT, vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + profilePairingToken: undefined, }), }, async ({ diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 704768068b..a6d405a9b0 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1859,16 +1859,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 = vaultData.profilePairingToken; + state.profilePairingToken = profilePairingToken; }); - const deserializedVaultData = deserializeVaultData(vaultData); + const deserializedVaultData = deserializeVaultData(unlockedVaultData); this.#cachedDecryptedVaultData = deserializedVaultData; return deserializedVaultData; }); From 6a6df566b57131e1080ae27000b43916b177c4a3 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 15:36:10 +0800 Subject: [PATCH 14/19] feat: removed PairingStatus.InProgress --- .../src/SeedlessOnboardingController.test.ts | 38 ------------------- .../src/SeedlessOnboardingController.ts | 11 +----- .../src/constants.ts | 1 - 3 files changed, 2 insertions(+), 48 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 24ab21137c..e737372623 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -1088,44 +1088,6 @@ describe('SeedlessOnboardingController', () => { ); }); - it('should skip pairing when profile pairing is already in progress', 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, - profilePairingStatus: ProfilePairingStatus.PairingInProgress, - }, - }, - async ({ controller, baseMessenger }) => { - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - mockPassword, - ); - - expect( - await controller.pairProfileServiceWithSocialLogin( - mockProfileServiceToken, - ), - ).toBeUndefined(); - expect(mockFetch).not.toHaveBeenCalled(); - expect(controller.state.profilePairingStatus).toBe( - ProfilePairingStatus.PairingInProgress, - ); - }, - ); - }); - it('should skip pairing for non-Telegram social logins', async () => { const mockFetch = jest.fn(); const mockVault = await createVaultForProfilePairing(); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index a6d405a9b0..9e1b921deb 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1136,11 +1136,8 @@ export class SeedlessOnboardingController< return; } - if (profilePairingStatus === ProfilePairingStatus.PairingInProgress) { - log('Profile pairing already in progress'); - return; - } - + // The controller lock already serializes active pairing calls, so allow + // retries from a persisted PairingInProgress state after a crash. if (authConnection !== AuthConnection.Telegram) { // we don't throw the error here, since we don't support profile pairing for other social logins yet // technically, clients should not call this method for other social logins @@ -1159,10 +1156,6 @@ export class SeedlessOnboardingController< ); } - this.update((state) => { - state.profilePairingStatus = ProfilePairingStatus.PairingInProgress; - }); - const response = await this.#fetch(this.#profilePairingEndpoint, { method: 'POST', headers: { diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index e22861d7a9..9ff1d15c04 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -27,7 +27,6 @@ export enum SeedlessOnboardingMigrationVersion { export enum ProfilePairingStatus { NotPaired = 'not_paired', - PairingInProgress = 'pairing_in_progress', Paired = 'paired', PairingFailed = 'pairing_failed', } From 1df82aaf0b47ae0c3132d3c0a0a5ba88118b4cbd Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 16:18:44 +0800 Subject: [PATCH 15/19] fix: fixed broken state for telegram login --- .../src/SeedlessOnboardingController.test.ts | 135 ++++++++++++------ .../src/SeedlessOnboardingController.ts | 13 +- .../src/assertions.test.ts | 21 --- .../src/assertions.ts | 13 -- 4 files changed, 99 insertions(+), 83 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index e737372623..3979172f47 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -871,39 +871,6 @@ describe('SeedlessOnboardingController', () => { expect(controller.state).toMatchObject(initialState); }); - it('should initialize isSeedlessOnboardingUserAuthenticated as false for Telegram users without profilePairingToken', () => { - const mockRefreshJWTToken = jest.fn().mockResolvedValue({ - idTokens: ['newIdToken'], - }); - const mockRevokeRefreshToken = jest.fn().mockResolvedValue(undefined); - const mockRenewRefreshToken = jest.fn().mockResolvedValue({ - newRevokeToken: 'newRevokeToken', - newRefreshToken: 'newRefreshToken', - }); - const { messenger } = mockSeedlessOnboardingMessenger(); - - const controller = new SeedlessOnboardingController({ - messenger, - encryptor: getDefaultSeedlessOnboardingVaultEncryptor(), - refreshJWTToken: mockRefreshJWTToken, - revokeRefreshToken: mockRevokeRefreshToken, - renewRefreshToken: mockRenewRefreshToken, - state: { - ...getMockInitialControllerState({ - withMockAuthenticatedUser: true, - profilePairingToken: undefined, - }), - authConnection: AuthConnection.Telegram, - }, - profilePairingEndpoint: mockProfilePairingEndpoint, - fetchFunction: mockFetchFunction, - }); - - expect(controller.state.isSeedlessOnboardingUserAuthenticated).toBe( - false, - ); - }); - it('should throw an error if the password outdated cache TTL is not a valid number', () => { const mockRefreshJWTToken = jest.fn().mockResolvedValue({ idTokens: ['newIdToken'], @@ -1332,6 +1299,77 @@ 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', @@ -1628,7 +1666,7 @@ describe('SeedlessOnboardingController', () => { }); }); - it('should return false and update state when a Telegram user is missing profilePairingToken', async () => { + it('should return false for a Telegram user missing profilePairingToken without mutating state', async () => { await withController( { state: { @@ -1647,7 +1685,7 @@ describe('SeedlessOnboardingController', () => { ), ).toBe(false); expect(controller.state.isSeedlessOnboardingUserAuthenticated).toBe( - false, + true, ); }, ); @@ -2186,7 +2224,7 @@ describe('SeedlessOnboardingController', () => { ); }); - it('should throw error if Telegram user is missing profilePairingToken during vault creation', async () => { + 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), @@ -2206,7 +2244,7 @@ describe('SeedlessOnboardingController', () => { authConnection: AuthConnection.Telegram, }, }, - async ({ controller, toprfClient, baseMessenger }) => { + async ({ controller, toprfClient, encryptor, baseMessenger }) => { mockcreateLocalKey(toprfClient, MOCK_PASSWORD); const updateState = ( @@ -2227,19 +2265,22 @@ describe('SeedlessOnboardingController', () => { const mockSecretDataAdd = handleMockSecretDataAdd(); - await expect( - baseMessenger.call( - 'SeedlessOnboardingController:createToprfKeyAndBackupSeedPhrase', - MOCK_PASSWORD, - MOCK_SEED_PHRASE, - MOCK_KEYRING_ID, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + await baseMessenger.call( + 'SeedlessOnboardingController:createToprfKeyAndBackupSeedPhrase', + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, ); expect(mockSecretDataAdd.isDone()).toBe(true); - expect(controller.state.vault).toBe(mockVault.encryptedMockVault); + + const decryptedVaultData = await encryptor.decrypt( + MOCK_PASSWORD, + controller.state.vault as string, + ); + const parsedVaultData = JSON.parse(decryptedVaultData as string); + + expect(parsedVaultData.profilePairingToken).toBeUndefined(); }, ); }); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 9e1b921deb..6f3bae33c9 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -556,7 +556,12 @@ export class SeedlessOnboardingController< state.revokeToken = revokeToken; } state.accessToken = accessToken; - state.profilePairingToken = profilePairingToken; + 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 @@ -1410,7 +1415,11 @@ 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; } diff --git a/packages/seedless-onboarding-controller/src/assertions.test.ts b/packages/seedless-onboarding-controller/src/assertions.test.ts index eed3c17cd7..0bc402f2b6 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/assertions.test.ts @@ -147,27 +147,6 @@ describe('assertIsSeedlessOnboardingUserAuthenticated', () => { ); }); - it('should throw InvalidProfilePairingToken for Telegram users when profilePairingToken is missing', () => { - expect(() => { - assertIsSeedlessOnboardingUserAuthenticated({ - ...createValidAuthenticatedUser(), - authConnection: AuthConnection.Telegram, - }); - }).toThrow( - SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, - ); - }); - - it('should not throw for Telegram users when profilePairingToken is present', () => { - expect(() => { - assertIsSeedlessOnboardingUserAuthenticated({ - ...createValidAuthenticatedUser(), - authConnection: AuthConnection.Telegram, - profilePairingToken: 'profile-pairing-token', - }); - }).not.toThrow(); - }); - it('should not throw for a valid authenticated user', () => { expect(() => { assertIsSeedlessOnboardingUserAuthenticated( diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/assertions.ts index ff954f7786..0b7a305f00 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -1,7 +1,6 @@ import { hasProperty } from '@metamask/utils'; import { - AuthConnection, SeedlessOnboardingControllerErrorMessage, } from './constants'; import type { AuthenticatedUserDetails, VaultData } from './types'; @@ -75,18 +74,6 @@ export function assertIsSeedlessOnboardingUserAuthenticated( SeedlessOnboardingControllerErrorMessage.InvalidMetadataAccessToken, ); } - - // if authConnection is Telegram, profilePairingToken must be provided - if ( - hasProperty(value, 'authConnection') && - value.authConnection === AuthConnection.Telegram && - (!hasProperty(value, 'profilePairingToken') || - typeof value.profilePairingToken !== 'string') - ) { - throw new Error( - SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, - ); - } } /** From 2262b7cbe53bae366b57d6755919a3dddd9ad232 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 17:10:58 +0800 Subject: [PATCH 16/19] feat: updated 'SocialBackupsMetadata' state to track pairing status --- .../CHANGELOG.md | 7 +- ...nboardingController-method-action-types.ts | 11 -- .../src/SeedlessOnboardingController.test.ts | 147 +++++++++++------- .../src/SeedlessOnboardingController.ts | 45 +++--- .../src/constants.ts | 1 + .../src/index.ts | 1 - .../src/types.ts | 13 +- 7 files changed, 125 insertions(+), 100 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 01769c1da7..14f09883c5 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -21,10 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) - - `authenticateMethod` will also accept the `profilePairingToken` in the params and will save to the state. -- Add new persisted value, `profilePairingStatus` to controller state. ([#8652](https://github.com/MetaMask/core/pull/8652)) - - `profilePairingStatus` has the default value as `ProfilePairingStatus.NotPaired` and the value will be updated when we do the paring. -- Add new public method, `updateProfilePairingStatus` which is used to update the 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 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 f247b8387d..600a4ed2c5 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -110,16 +110,6 @@ export type SeedlessOnboardingControllerUpdateBackupMetadataStateAction = { handler: SeedlessOnboardingController['updateBackupMetadataState']; }; -/** - * Update the profile pairing status in the controller state. - * - * @param status - The new profile pairing status. - */ -export type SeedlessOnboardingControllerUpdateProfilePairingStatusAction = { - type: `SeedlessOnboardingController:updateProfilePairingStatus`; - handler: SeedlessOnboardingController['updateProfilePairingStatus']; -}; - /** * Verify the password validity by decrypting the vault. * @@ -364,7 +354,6 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerFetchAllSecretDataAction | SeedlessOnboardingControllerChangePasswordAction | SeedlessOnboardingControllerUpdateBackupMetadataStateAction - | SeedlessOnboardingControllerUpdateProfilePairingStatusAction | SeedlessOnboardingControllerVerifyVaultPasswordAction | SeedlessOnboardingControllerGetSecretDataBackupStateAction | SeedlessOnboardingControllerSubmitPasswordAction diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 3979172f47..30d82aa245 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -81,6 +81,7 @@ import { import type { SeedlessOnboardingControllerMessenger } from './SeedlessOnboardingController'; import type { SeedlessOnboardingControllerOptions, + SocialBackupsMetadata, SeedlessOnboardingControllerState, } from './types'; @@ -170,6 +171,16 @@ type WithControllerArgs = 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. * @@ -929,25 +940,6 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('updateProfilePairingStatus', () => { - it('should update the profile pairing status through the messenger', async () => { - await withController(async ({ controller, baseMessenger }) => { - expect(controller.state.profilePairingStatus).toBe( - ProfilePairingStatus.NotPaired, - ); - - baseMessenger.call( - 'SeedlessOnboardingController:updateProfilePairingStatus', - ProfilePairingStatus.Paired, - ); - - expect(controller.state.profilePairingStatus).toBe( - ProfilePairingStatus.Paired, - ); - }); - }); - }); - describe('pairProfileServiceWithSocialLogin', () => { const mockPassword = 'mock-password'; const mockProfileServiceToken = 'profile-service-token'; @@ -988,6 +980,7 @@ describe('SeedlessOnboardingController', () => { profilePairingToken: undefined, }), authConnection: AuthConnection.Telegram, + socialBackupsMetadata: [getMockSocialBackupMetadata()], }, }, async ({ controller, baseMessenger }) => { @@ -1010,14 +1003,14 @@ describe('SeedlessOnboardingController', () => { jwts: [mockProfilePairingToken], }), }); - expect(controller.state.profilePairingStatus).toBe( - ProfilePairingStatus.Paired, - ); + expect( + controller.state.socialBackupsMetadata[0]?.profilePairingStatus, + ).toBe(ProfilePairingStatus.Paired); }, ); }); - it('should skip pairing when profile pairing already completed', async () => { + it('should throw if there are no social backups to pair', async () => { const mockFetch = jest.fn(); const mockVault = await createVaultForProfilePairing( mockProfilePairingToken, @@ -1031,9 +1024,10 @@ describe('SeedlessOnboardingController', () => { ...getMockInitialControllerState({ withMockAuthenticatedUser: true, vault: mockVault.encryptedMockVault, + profilePairingToken: undefined, }), authConnection: AuthConnection.Telegram, - profilePairingStatus: ProfilePairingStatus.Paired, + socialBackupsMetadata: [], }, }, async ({ controller, baseMessenger }) => { @@ -1042,22 +1036,25 @@ describe('SeedlessOnboardingController', () => { mockPassword, ); - expect( - await controller.pairProfileServiceWithSocialLogin( + await expect( + controller.pairProfileServiceWithSocialLogin( mockProfileServiceToken, ), - ).toBeUndefined(); - expect(mockFetch).not.toHaveBeenCalled(); - expect(controller.state.profilePairingStatus).toBe( - ProfilePairingStatus.Paired, + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.NoSocialBackups, ); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(controller.state.socialBackupsMetadata).toStrictEqual([]); }, ); }); - it('should skip pairing for non-Telegram social logins', async () => { + it('should skip pairing when profile pairing already completed', async () => { const mockFetch = jest.fn(); - const mockVault = await createVaultForProfilePairing(); + const mockVault = await createVaultForProfilePairing( + mockProfilePairingToken, + ); await withController( { @@ -1068,7 +1065,12 @@ describe('SeedlessOnboardingController', () => { withMockAuthenticatedUser: true, vault: mockVault.encryptedMockVault, }), - authConnection: AuthConnection.Google, + authConnection: AuthConnection.Telegram, + socialBackupsMetadata: [ + getMockSocialBackupMetadata({ + profilePairingStatus: ProfilePairingStatus.Paired, + }), + ], }, }, async ({ controller, baseMessenger }) => { @@ -1083,14 +1085,14 @@ describe('SeedlessOnboardingController', () => { ), ).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); - expect(controller.state.profilePairingStatus).toBe( - ProfilePairingStatus.NotPaired, - ); + expect( + controller.state.socialBackupsMetadata[0]?.profilePairingStatus, + ).toBe(ProfilePairingStatus.Paired); }, ); }); - it('should throw if the Telegram profile pairing token is unavailable', async () => { + 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(); @@ -1105,6 +1107,7 @@ describe('SeedlessOnboardingController', () => { profilePairingToken: undefined, }), authConnection: AuthConnection.Telegram, + socialBackupsMetadata: [getMockSocialBackupMetadata()], }, }, async ({ controller, baseMessenger }) => { @@ -1120,10 +1123,47 @@ describe('SeedlessOnboardingController', () => { ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, ); + expect(mockFetch).not.toHaveBeenCalled(); - expect(controller.state.profilePairingStatus).toBe( - ProfilePairingStatus.PairingFailed, + 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(); }, ); }); @@ -1146,6 +1186,7 @@ describe('SeedlessOnboardingController', () => { vault: mockVault.encryptedMockVault, }), authConnection: AuthConnection.Telegram, + socialBackupsMetadata: [getMockSocialBackupMetadata()], }, }, async ({ controller, baseMessenger }) => { @@ -1161,9 +1202,9 @@ describe('SeedlessOnboardingController', () => { ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.FailedToPairSocialLoginWithIdentityProfileService, ); - expect(controller.state.profilePairingStatus).toBe( - ProfilePairingStatus.PairingFailed, - ); + expect( + controller.state.socialBackupsMetadata[0]?.profilePairingStatus, + ).toBe(ProfilePairingStatus.PairingFailed); }, ); }); @@ -9296,7 +9337,6 @@ describe('SeedlessOnboardingController', () => { pendingToBeRevokedTokens: [ { refreshToken: 'refreshToken', revokeToken: 'revokeToken' }, ], - profilePairingStatus: ProfilePairingStatus.Paired, refreshToken: 'refreshToken', revokeToken: 'revokeToken', socialBackupsMetadata: [], @@ -9325,7 +9365,6 @@ describe('SeedlessOnboardingController', () => { "isExpiredPwd": false, "timestamp": 1234567890, }, - "profilePairingStatus": "paired", } `); }, @@ -9353,7 +9392,6 @@ describe('SeedlessOnboardingController', () => { pendingToBeRevokedTokens: [ { refreshToken: 'refreshToken', revokeToken: 'revokeToken' }, ], - profilePairingStatus: ProfilePairingStatus.Paired, refreshToken: 'refreshToken', revokeToken: 'revokeToken', socialBackupsMetadata: [], @@ -9384,7 +9422,6 @@ describe('SeedlessOnboardingController', () => { "isExpiredPwd": false, "timestamp": 1234567890, }, - "profilePairingStatus": "paired", "userId": "userId", } `); @@ -9413,10 +9450,13 @@ describe('SeedlessOnboardingController', () => { pendingToBeRevokedTokens: [ { refreshToken: 'refreshToken', revokeToken: 'revokeToken' }, ], - profilePairingStatus: ProfilePairingStatus.Paired, refreshToken: 'refreshToken', revokeToken: 'revokeToken', - socialBackupsMetadata: [], + socialBackupsMetadata: [ + getMockSocialBackupMetadata({ + profilePairingStatus: ProfilePairingStatus.Paired, + }), + ], socialLoginEmail: 'socialLoginEmail', userId: 'userId', vault: 'vault', @@ -9453,9 +9493,14 @@ describe('SeedlessOnboardingController', () => { "revokeToken": "revokeToken", }, ], - "profilePairingStatus": "paired", "refreshToken": "refreshToken", - "socialBackupsMetadata": [], + "socialBackupsMetadata": [ + { + "hash": "mock-social-backup-hash", + "profilePairingStatus": "paired", + "type": "mnemonic", + }, + ], "socialLoginEmail": "socialLoginEmail", "userId": "userId", "vault": "vault", @@ -9486,7 +9531,6 @@ describe('SeedlessOnboardingController', () => { pendingToBeRevokedTokens: [ { refreshToken: 'refreshToken', revokeToken: 'revokeToken' }, ], - profilePairingStatus: ProfilePairingStatus.Paired, refreshToken: 'refreshToken', revokeToken: 'revokeToken', socialBackupsMetadata: [], @@ -9507,7 +9551,6 @@ describe('SeedlessOnboardingController', () => { ).toMatchInlineSnapshot(` { "authConnection": "google", - "profilePairingStatus": "paired", "socialLoginEmail": "socialLoginEmail", } `); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 6f3bae33c9..6a2a98c1e1 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -109,7 +109,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkNodeAuthTokenExpired', 'checkMetadataAccessTokenExpired', 'checkAccessTokenExpired', - 'updateProfilePairingStatus', ] as const; // Actions @@ -155,7 +154,6 @@ export function getInitialSeedlessOnboardingControllerStateWithDefaults( const initialState = { socialBackupsMetadata: [], isSeedlessOnboardingUserAuthenticated: false, - profilePairingStatus: ProfilePairingStatus.NotPaired, migrationVersion: 0, ...overrides, }; @@ -317,12 +315,6 @@ const seedlessOnboardingMetadata: StateMetadata { - state.profilePairingStatus = status; - }); - } - /** * Verify the password validity by decrypting the vault. * @@ -1134,15 +1115,23 @@ export class SeedlessOnboardingController< this.#assertIsUnlocked(); try { - const { profilePairingToken, authConnection, profilePairingStatus } = + const { profilePairingToken, authConnection, socialBackupsMetadata } = this.state; + + 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; } - // The controller lock already serializes active pairing calls, so allow - // retries from a persisted PairingInProgress state after a crash. + // The controller lock already serializes active pairing calls, so only + // a completed pairing should short-circuit retries. if (authConnection !== AuthConnection.Telegram) { // we don't throw the error here, since we don't support profile pairing for other social logins yet // technically, clients should not call this method for other social logins @@ -1179,12 +1168,20 @@ export class SeedlessOnboardingController< } this.update((state) => { - state.profilePairingStatus = ProfilePairingStatus.Paired; + 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) => { - state.profilePairingStatus = ProfilePairingStatus.PairingFailed; + const primarySocialBackup = state.socialBackupsMetadata[0]; + if (primarySocialBackup) { + primarySocialBackup.profilePairingStatus = + ProfilePairingStatus.PairingFailed; + } }); throw error; } diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 9ff1d15c04..30bdb0c045 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -53,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`, diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index cab06cd812..0f7b820a6b 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -36,7 +36,6 @@ export type { SeedlessOnboardingControllerCheckNodeAuthTokenExpiredAction, SeedlessOnboardingControllerCheckMetadataAccessTokenExpiredAction, SeedlessOnboardingControllerCheckAccessTokenExpiredAction, - SeedlessOnboardingControllerUpdateProfilePairingStatusAction, } from './SeedlessOnboardingController-method-action-types'; export type { SeedlessOnboardingControllerOptions, diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 5296834323..fdc8987d4a 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -38,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 = { @@ -211,14 +216,6 @@ export type SeedlessOnboardingControllerState = * This is temporarily stored in state during authentication and then persisted in the vault for the later use. */ profilePairingToken?: string; - - /** - * The profile pairing status. - * This is used to track the profile pairing status and to prevent re-pairing the user social profile with the profile sync auth service. - * - * @default ProfilePairingStatus.NotPaired - */ - profilePairingStatus: ProfilePairingStatus; }; /** From 1d44681fe6a92efe1df61be1874b454caad25ae4 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 17:14:58 +0800 Subject: [PATCH 17/19] fix: fixed lint --- ...nboardingController-method-action-types.ts | 15 ++++- .../src/SeedlessOnboardingController.test.ts | 62 ++++++++++--------- .../src/SeedlessOnboardingController.ts | 17 +++-- .../src/assertions.ts | 4 +- .../src/index.ts | 1 + 5 files changed, 62 insertions(+), 37 deletions(-) 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 600a4ed2c5..a57c671faf 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -342,6 +342,18 @@ export type SeedlessOnboardingControllerCheckAccessTokenExpiredAction = { handler: SeedlessOnboardingController['checkAccessTokenExpired']; }; +/** + * 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']; + }; + /** * Union of all SeedlessOnboardingController action types. */ @@ -371,4 +383,5 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerGetAccessTokenAction | SeedlessOnboardingControllerCheckNodeAuthTokenExpiredAction | SeedlessOnboardingControllerCheckMetadataAccessTokenExpiredAction - | SeedlessOnboardingControllerCheckAccessTokenExpiredAction; + | SeedlessOnboardingControllerCheckAccessTokenExpiredAction + | SeedlessOnboardingControllerPairProfileServiceWithSocialLoginAction; diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 30d82aa245..0a78a9e247 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -1365,7 +1365,9 @@ describe('SeedlessOnboardingController', () => { ); expect(authResult).toBeDefined(); - expect(authResult.nodeAuthTokens).toStrictEqual(MOCK_NODE_AUTH_TOKENS); + 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( @@ -1379,36 +1381,38 @@ describe('SeedlessOnboardingController', () => { }); 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 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, - ); + 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, - ); - }); + 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 () => { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 6a2a98c1e1..1853756612 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -109,6 +109,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkNodeAuthTokenExpired', 'checkMetadataAccessTokenExpired', 'checkAccessTokenExpired', + 'pairProfileServiceWithSocialLogin', ] as const; // Actions @@ -550,7 +551,9 @@ export class SeedlessOnboardingController< state.accessToken = accessToken; if (authConnection === AuthConnection.Telegram) { if (!profilePairingToken) { - throw new Error(SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken); + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidProfilePairingToken, + ); } state.profilePairingToken = profilePairingToken; } @@ -1119,7 +1122,9 @@ export class SeedlessOnboardingController< this.state; if (socialBackupsMetadata.length < 1) { - throw new Error(SeedlessOnboardingControllerErrorMessage.NoSocialBackups); + throw new Error( + SeedlessOnboardingControllerErrorMessage.NoSocialBackups, + ); } // For now, we only support profile pairing for the primary SRP. @@ -1412,9 +1417,13 @@ export class SeedlessOnboardingController< async getIsUserAuthenticated(): Promise { try { this.#assertIsAuthenticatedUser(this.state); - const accessTokenAndRevokeTokenAreSet = 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 && + Boolean(this.state.profilePairingToken) + ); } return accessTokenAndRevokeTokenAreSet; } catch { diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/assertions.ts index 0b7a305f00..9fdb1e0e62 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -1,8 +1,6 @@ import { hasProperty } from '@metamask/utils'; -import { - SeedlessOnboardingControllerErrorMessage, -} from './constants'; +import { SeedlessOnboardingControllerErrorMessage } from './constants'; import type { AuthenticatedUserDetails, VaultData } from './types'; /** diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 0f7b820a6b..3f61afae09 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -36,6 +36,7 @@ export type { SeedlessOnboardingControllerCheckNodeAuthTokenExpiredAction, SeedlessOnboardingControllerCheckMetadataAccessTokenExpiredAction, SeedlessOnboardingControllerCheckAccessTokenExpiredAction, + SeedlessOnboardingControllerPairProfileServiceWithSocialLoginAction, } from './SeedlessOnboardingController-method-action-types'; export type { SeedlessOnboardingControllerOptions, From 5c3d076cecb8c133e7849ba9dfaa9c41145ff232 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 17:22:48 +0800 Subject: [PATCH 18/19] fix: fixed messenger-action-types --- ...nboardingController-method-action-types.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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 a57c671faf..150a721854 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -166,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, @@ -342,18 +354,6 @@ export type SeedlessOnboardingControllerCheckAccessTokenExpiredAction = { handler: SeedlessOnboardingController['checkAccessTokenExpired']; }; -/** - * 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']; - }; - /** * Union of all SeedlessOnboardingController action types. */ @@ -370,6 +370,7 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerGetSecretDataBackupStateAction | SeedlessOnboardingControllerSubmitPasswordAction | SeedlessOnboardingControllerSetLockedAction + | SeedlessOnboardingControllerPairProfileServiceWithSocialLoginAction | SeedlessOnboardingControllerSyncLatestGlobalPasswordAction | SeedlessOnboardingControllerSubmitGlobalPasswordAction | SeedlessOnboardingControllerCheckIsPasswordOutdatedAction @@ -383,5 +384,4 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerGetAccessTokenAction | SeedlessOnboardingControllerCheckNodeAuthTokenExpiredAction | SeedlessOnboardingControllerCheckMetadataAccessTokenExpiredAction - | SeedlessOnboardingControllerCheckAccessTokenExpiredAction - | SeedlessOnboardingControllerPairProfileServiceWithSocialLoginAction; + | SeedlessOnboardingControllerCheckAccessTokenExpiredAction; From 95a687acf6df7689a62b91d6c984ecf697b95a87 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 4 May 2026 17:40:54 +0800 Subject: [PATCH 19/19] feat: handle profile-pairing error --- .../src/SeedlessOnboardingController.test.ts | 34 +++++++++++++++++++ .../src/SeedlessOnboardingController.ts | 20 +++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 0a78a9e247..2743b6d21f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -1168,6 +1168,40 @@ describe('SeedlessOnboardingController', () => { ); }); + 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, diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 1853756612..2011ecb16e 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1121,6 +1121,15 @@ export class SeedlessOnboardingController< 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, @@ -1135,17 +1144,6 @@ export class SeedlessOnboardingController< return; } - // The controller lock already serializes active pairing calls, so only - // a completed pairing should short-circuit retries. - if (authConnection !== AuthConnection.Telegram) { - // we don't throw the error here, since we don't support profile pairing for other social logins yet - // technically, clients should not call this method for other social logins - log( - `warning: skipping profile pairing for ${authConnection} social login`, - ); - return; - } - if (!profilePairingToken) { log( 'Error: profile pairing token is not available for Telegram social login',