From 3b3f9284d1506aa0d64debc04f8f37f0ed1cb1b8 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 2 Feb 2026 14:01:52 +0800 Subject: [PATCH 01/16] feat: added 'getAccessToken' with refresh token if expired --- .../src/SeedlessOnboardingController.test.ts | 147 ++++++++++++++- .../src/SeedlessOnboardingController.ts | 177 ++++++++++++++++-- .../src/index.ts | 9 +- .../src/types.ts | 103 ---------- .../tests/__fixtures__/mockMessenger.ts | 2 +- 5 files changed, 310 insertions(+), 128 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index f6f1b1f9fdc..36bffc9050f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -48,10 +48,10 @@ import { SecretMetadata } from './SecretMetadata'; import { getInitialSeedlessOnboardingControllerStateWithDefaults, SeedlessOnboardingController, -} from './SeedlessOnboardingController'; -import type { SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, +} from './SeedlessOnboardingController'; +import type { SeedlessOnboardingControllerState, VaultEncryptor, } from './types'; @@ -5447,6 +5447,149 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('getAccessToken', () => { + const MOCK_ACCESS_TOKEN = 'mock-access-token'; + const MOCK_PASSWORD = 'mock-password'; + let MOCK_VAULT: string; + let MOCK_VAULT_ENCRYPTION_KEY: string; + let MOCK_VAULT_ENCRYPTION_SALT: string; + + beforeAll(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_PASSWORD_ENCRYPTION_KEY = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_PASSWORD_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + revokeToken, + MOCK_ACCESS_TOKEN, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should return the access token when controller is unlocked', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + accessToken: MOCK_ACCESS_TOKEN, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + + const result = await controller.getAccessToken(); + expect(result).toBe(MOCK_ACCESS_TOKEN); + }, + ); + }); + + it('should return undefined when controller is locked', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + accessToken: MOCK_ACCESS_TOKEN, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + // Controller is locked by default, so don't call submitPassword + const result = await controller.getAccessToken(); + expect(result).toBeUndefined(); + }, + ); + }); + + it('should return undefined when error is thrown', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withoutMockAccessToken: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + jest + .spyOn(controller, 'checkNodeAuthTokenExpired') + .mockImplementationOnce(() => { + throw new Error('test'); + }); + await controller.submitPassword(MOCK_PASSWORD); + + const result = await controller.getAccessToken(); + expect(result).toBeUndefined(); + }, + ); + }); + + it('should refresh tokens if expired and return the new access token', async () => { + // Create an expired JWT token + const pastExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const payload = { exp: pastExp }; + const encodedPayload = btoa(JSON.stringify(payload)); + const expiredAccessToken = `header.${encodedPayload}.signature`; + + const NEW_ACCESS_TOKEN = 'new-access-token'; + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + accessToken: expiredAccessToken, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, mockRefreshJWTToken, toprfClient }) => { + jest + .spyOn(controller, 'checkAccessTokenExpired') + .mockReturnValueOnce(true); + + // Mock the token refresh to return a new access token + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: NEW_ACCESS_TOKEN, + metadataAccessToken: 'newMetadataAccessToken', + }); + + const authenticateSpy = jest + .spyOn(toprfClient, 'authenticate') + .mockResolvedValueOnce({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.submitPassword(MOCK_PASSWORD); + + const result = await controller.getAccessToken(); + expect(result).toBe(NEW_ACCESS_TOKEN); + expect(mockRefreshJWTToken).toHaveBeenCalled(); + expect(authenticateSpy).toHaveBeenCalled(); + }, + ); + }); + }); + describe('#getAccessTokenAndRevokeToken', () => { const MOCK_PASSWORD = 'mock-password'; diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 093e77ac361..50b558427c5 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1,7 +1,12 @@ import { keccak256AndHexify } from '@metamask/auth-network-utils'; import { BaseController } from '@metamask/base-controller'; -import type { StateMetadata } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; import type * as encryptionUtils from '@metamask/browser-passworder'; +import { Messenger } from '@metamask/messenger'; import type { AuthenticateResult, ChangeEncryptionKeyResult, @@ -43,8 +48,6 @@ import { projectLogger, createModuleLogger } from './logger'; import { SecretMetadata } from './SecretMetadata'; import type { MutuallyExclusiveCallback, - SeedlessOnboardingControllerMessenger, - SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerState, AuthenticatedUserDetails, SocialBackupsMetadata, @@ -54,6 +57,7 @@ import type { RenewRefreshToken, VaultData, DeserializedVaultData, + ToprfKeyDeriver, } from './types'; import { decodeJWTToken, @@ -64,6 +68,110 @@ import { const log = createModuleLogger(projectLogger, controllerName); +// Actions +export type SeedlessOnboardingControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + SeedlessOnboardingControllerState + >; +export type SeedlessOnboardingControllerGetAccessTokenAction = { + type: `${typeof controllerName}:getAccessToken`; + handler: SeedlessOnboardingController< + encryptionUtils.EncryptionKey, + encryptionUtils.KeyDerivationOptions + >['getAccessToken']; +}; +export type SeedlessOnboardingControllerActions = + | SeedlessOnboardingControllerGetStateAction + | SeedlessOnboardingControllerGetAccessTokenAction; + +type AllowedActions = never; + +// Events +export type SeedlessOnboardingControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + SeedlessOnboardingControllerState + >; +export type SeedlessOnboardingControllerEvents = + SeedlessOnboardingControllerStateChangeEvent; + +type AllowedEvents = never; + +// Messenger +export type SeedlessOnboardingControllerMessenger = Messenger< + typeof controllerName, + SeedlessOnboardingControllerActions | AllowedActions, + 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, + SupportedKeyDerivationParams, +> = { + 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: VaultEncryptor; + + /** + * 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. * @@ -1970,6 +2078,29 @@ export class SeedlessOnboardingController< }); } + /** + * Get the access token from the state. + * + * If the tokens are expired, the method will refresh them and return the new access token. + * + * @returns The access token. + */ + async getAccessToken(): Promise { + return this.#withControllerLock(async () => { + try { + this.#assertIsUnlocked(); + // if the tokens are expired, refresh them and return the access token + const accessToken = await this.#executeWithTokenRefresh(async () => { + return this.state.accessToken; + }, 'getAccessToken'); + return accessToken; + } catch (error) { + log('Error getting access token', error); + return undefined; + } + }); + } + /** * Add a pending refresh, revoke token to the state to be revoked later. * @@ -2047,22 +2178,7 @@ export class SeedlessOnboardingController< operationName: string, ): Promise { try { - // proactively check for expired tokens and refresh them if needed - const isNodeAuthTokenExpired = this.checkNodeAuthTokenExpired(); - const isMetadataAccessTokenExpired = - this.checkMetadataAccessTokenExpired(); - // access token is only accessible when the vault is unlocked - // so skip the check if the vault is locked - let isAccessTokenExpired = false; - if (this.#isUnlocked) { - isAccessTokenExpired = this.checkAccessTokenExpired(); - } - - if ( - isNodeAuthTokenExpired || - isMetadataAccessTokenExpired || - isAccessTokenExpired - ) { + if (this.#checkTokensExpired()) { log( `JWT token expired during ${operationName}, attempting to refresh tokens`, 'node auth token exp check', @@ -2094,6 +2210,29 @@ export class SeedlessOnboardingController< } } + /** + * Check if the tokens are expired. + * + * @returns True if the tokens are expired, false otherwise. + */ + #checkTokensExpired(): boolean { + // proactively check for expired tokens and refresh them if needed + const isNodeAuthTokenExpired = this.checkNodeAuthTokenExpired(); + const isMetadataAccessTokenExpired = this.checkMetadataAccessTokenExpired(); + // access token is only accessible when the vault is unlocked + // so skip the check if the vault is locked + let isAccessTokenExpired = false; + if (this.#isUnlocked) { + isAccessTokenExpired = this.checkAccessTokenExpired(); + } + + return ( + isNodeAuthTokenExpired || + isMetadataAccessTokenExpired || + isAccessTokenExpired + ); + } + /** * Check if the current node auth token is expired. * diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 4d445795530..4637ec2fa7b 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -3,15 +3,18 @@ export { getInitialSeedlessOnboardingControllerStateWithDefaults as getDefaultSeedlessOnboardingControllerState, } from './SeedlessOnboardingController'; export type { - AuthenticatedUserDetails, - SocialBackupsMetadata, - SeedlessOnboardingControllerState, SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerGetStateAction, + SeedlessOnboardingControllerGetAccessTokenAction, SeedlessOnboardingControllerStateChangeEvent, SeedlessOnboardingControllerActions, SeedlessOnboardingControllerEvents, +} from './SeedlessOnboardingController'; +export type { + AuthenticatedUserDetails, + SocialBackupsMetadata, + SeedlessOnboardingControllerState, ToprfKeyDeriver, RecoveryErrorData, } from './types'; diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index a9e17731dc5..8ea9fe4f1f9 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -1,18 +1,11 @@ -import type { - ControllerGetStateAction, - ControllerStateChangeEvent, -} from '@metamask/base-controller'; import type { Encryptor } from '@metamask/keyring-controller'; -import type { Messenger } from '@metamask/messenger'; import type { KeyPair, NodeAuthTokens } from '@metamask/toprf-secure-backup'; import type { MutexInterface } from 'async-mutex'; import type { AuthConnection, - controllerName, SecretMetadataVersion, SecretType, - Web3AuthNetwork, } from './constants'; /** @@ -184,35 +177,6 @@ export type SeedlessOnboardingControllerState = isSeedlessOnboardingUserAuthenticated: boolean; }; -// Actions -export type SeedlessOnboardingControllerGetStateAction = - ControllerGetStateAction< - typeof controllerName, - SeedlessOnboardingControllerState - >; -export type SeedlessOnboardingControllerActions = - SeedlessOnboardingControllerGetStateAction; - -type AllowedActions = never; - -// Events -export type SeedlessOnboardingControllerStateChangeEvent = - ControllerStateChangeEvent< - typeof controllerName, - SeedlessOnboardingControllerState - >; -export type SeedlessOnboardingControllerEvents = - SeedlessOnboardingControllerStateChangeEvent; - -type AllowedEvents = never; - -// Messenger -export type SeedlessOnboardingControllerMessenger = Messenger< - typeof controllerName, - SeedlessOnboardingControllerActions | AllowedActions, - SeedlessOnboardingControllerEvents | AllowedEvents ->; - /** * Encryptor interface for encrypting and decrypting seedless onboarding vault. */ @@ -263,73 +227,6 @@ 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, - SupportedKeyDerivationParams, -> = { - 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: VaultEncryptor; - - /** - * 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; -}; - /** * A function executed within a mutually exclusive lock, with * a mutex releaser in its option bag. diff --git a/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts index d821f12c4da..51a791e5d17 100644 --- a/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts +++ b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts @@ -10,7 +10,7 @@ import type { } from '@metamask/messenger'; import { controllerName } from '../../src/constants'; -import type { SeedlessOnboardingControllerMessenger } from '../../src/types'; +import type { SeedlessOnboardingControllerMessenger } from '../../src/SeedlessOnboardingController'; export type AllSeedlessOnboardingControllerActions = MessengerActions; From 719a409703d6a18b4bea232fbdbfee9a622d511f Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 2 Feb 2026 14:16:24 +0800 Subject: [PATCH 02/16] chore: updated ChangeLog --- packages/seedless-onboarding-controller/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 5293235b901..6dd57f5f791 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added new public method, `getAccessToken`. ([#7800](https://github.com/MetaMask/core/pull/7800)) + - Clients can use this method to get `accessToken` from the controller, instead of directly accessing from the state. + - This method also adds refresh token mechanism when `accessToken` is expired, hence preventing expired token usage in the clients. + ### Changed - Update StateMetadata's `includeInStateLogs` property. ([#7750](https://github.com/MetaMask/core/pull/7750)) From dcbbb243192e5d201a6f7a1500d931e025463091 Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 2 Feb 2026 16:03:48 +0800 Subject: [PATCH 03/16] feat: removed 'assertUnlock' for 'getRefreshToken' method --- .../src/SeedlessOnboardingController.test.ts | 14 +++++--------- .../src/SeedlessOnboardingController.ts | 1 - 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 36bffc9050f..76c09c95bab 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -5497,19 +5497,15 @@ describe('SeedlessOnboardingController', () => { ); }); - it('should return undefined when controller is locked', async () => { + it('should return undefined when accessToken is not set', async () => { await withController( { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - accessToken: MOCK_ACCESS_TOKEN, - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - }), + state: { + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + metadataAccessToken, + }, }, async ({ controller }) => { - // Controller is locked by default, so don't call submitPassword const result = await controller.getAccessToken(); expect(result).toBeUndefined(); }, diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 50b558427c5..dc4c3a41dd0 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -2088,7 +2088,6 @@ export class SeedlessOnboardingController< async getAccessToken(): Promise { return this.#withControllerLock(async () => { try { - this.#assertIsUnlocked(); // if the tokens are expired, refresh them and return the access token const accessToken = await this.#executeWithTokenRefresh(async () => { return this.state.accessToken; From 2b1da349a0bfebe53025499dc08fd6e287e069ab Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 2 Feb 2026 16:26:51 +0800 Subject: [PATCH 04/16] chore: throw errors if user is not authenticated or token refresh fails --- .../src/SeedlessOnboardingController.test.ts | 39 +++++++++++++++---- .../src/SeedlessOnboardingController.ts | 22 ++++++----- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 76c09c95bab..8a8c54ff818 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -5477,7 +5477,7 @@ describe('SeedlessOnboardingController', () => { MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; }); - it('should return the access token when controller is unlocked', async () => { + it('should return the access token', async () => { await withController( { state: getMockInitialControllerState({ @@ -5503,6 +5503,11 @@ describe('SeedlessOnboardingController', () => { state: { nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, metadataAccessToken, + userId, + authConnectionId, + groupedAuthConnectionId, + authConnection, + refreshToken, }, }, async ({ controller }) => { @@ -5512,7 +5517,20 @@ describe('SeedlessOnboardingController', () => { ); }); - it('should return undefined when error is thrown', async () => { + it('should throw error when user is not authenticated', async () => { + await withController( + { + state: {}, + }, + async ({ controller }) => { + await expect(controller.getAccessToken()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo, + ); + }, + ); + }); + + it('should throw error when authentication fails', async () => { await withController( { state: getMockInitialControllerState({ @@ -5526,13 +5544,18 @@ describe('SeedlessOnboardingController', () => { async ({ controller }) => { jest .spyOn(controller, 'checkNodeAuthTokenExpired') - .mockImplementationOnce(() => { - throw new Error('test'); - }); - await controller.submitPassword(MOCK_PASSWORD); + .mockReturnValueOnce(true); + jest + .spyOn(controller, 'authenticate') + .mockRejectedValueOnce( + new Error( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ), + ); - const result = await controller.getAccessToken(); - expect(result).toBeUndefined(); + await expect(controller.getAccessToken()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); }, ); }); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index dc4c3a41dd0..d474d3a045d 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -74,6 +74,13 @@ export type SeedlessOnboardingControllerGetStateAction = typeof controllerName, SeedlessOnboardingControllerState >; + +/** + * Get the access token from the controller. + * If the tokens are expired, the method will refresh them and return the new access token. + * + * @returns The access token. + */ export type SeedlessOnboardingControllerGetAccessTokenAction = { type: `${typeof controllerName}:getAccessToken`; handler: SeedlessOnboardingController< @@ -2087,16 +2094,11 @@ export class SeedlessOnboardingController< */ async getAccessToken(): Promise { return this.#withControllerLock(async () => { - try { - // if the tokens are expired, refresh them and return the access token - const accessToken = await this.#executeWithTokenRefresh(async () => { - return this.state.accessToken; - }, 'getAccessToken'); - return accessToken; - } catch (error) { - log('Error getting access token', error); - return undefined; - } + this.#assertIsAuthenticatedUser(this.state); + + return this.#executeWithTokenRefresh(async () => { + return this.state.accessToken; + }, 'getAccessToken'); }); } From 4594ee99cf96b6a027eb0b7011ac8ebcfb48377f Mon Sep 17 00:00:00 2001 From: lwin Date: Mon, 2 Feb 2026 16:29:03 +0800 Subject: [PATCH 05/16] feat: register 'SeedlessOnboarding:getAccessToken' actions to messenger --- .../src/SeedlessOnboardingController.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index d474d3a045d..5373ce95ea0 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -433,6 +433,11 @@ export class SeedlessOnboardingController< this.#refreshJWTToken = refreshJWTToken; this.#revokeRefreshToken = revokeRefreshToken; this.#renewRefreshToken = renewRefreshToken; + + this.messenger.registerActionHandler( + 'SeedlessOnboardingController:getAccessToken', + this.getAccessToken.bind(this), + ); } async fetchMetadataAccessCreds(): Promise<{ From fdbf117e431f2723380d9a2b8f635f153eabc637 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 3 Feb 2026 01:26:02 +0800 Subject: [PATCH 06/16] feat: added new function, 'encryptWithKey' to SeedlessOnboarding Vault Encryptor interface --- packages/seedless-onboarding-controller/src/types.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 8ea9fe4f1f9..b1713f04882 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -180,10 +180,8 @@ export type SeedlessOnboardingControllerState = /** * Encryptor interface for encrypting and decrypting seedless onboarding vault. */ -export type VaultEncryptor = Omit< - Encryptor, - 'encryptWithKey' ->; +export type VaultEncryptor = + Encryptor; /** * Additional key deriver for the TOPRF client. From 85f1a30c5c9877d341ba18c17c39b5a4db36fca9 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 3 Feb 2026 01:26:27 +0800 Subject: [PATCH 07/16] fix: update accessToken in encryptedVault after token refresh while the wallet is unlocked --- .../src/SeedlessOnboardingController.test.ts | 182 +++++++++++++++--- .../src/SeedlessOnboardingController.ts | 52 +++++ 2 files changed, 204 insertions(+), 30 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 8a8c54ff818..af7480c643d 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -14,6 +14,7 @@ import { exportKey as exportKeyBrowserPassworder, generateSalt as generateSaltBrowserPassworder, keyFromPassword as keyFromPasswordBrowserPassworder, + encryptWithKey as encryptWithKeyBrowserPassworder, } from '@metamask/browser-passworder'; import { TOPRFError, TOPRFErrorCode } from '@metamask/toprf-secure-backup'; import type { @@ -171,6 +172,7 @@ function getDefaultSeedlessOnboardingVaultEncryptor(): VaultEncryptor< exportKey: exportKeyBrowserPassworder, generateSalt: generateSaltBrowserPassworder, keyFromPassword: keyFromPasswordBrowserPassworder, + encryptWithKey: encryptWithKeyBrowserPassworder, }; } @@ -5058,22 +5060,32 @@ describe('SeedlessOnboardingController', () => { }); describe('refreshAuthTokens', () => { - it('should successfully refresh node auth tokens', async () => { - const mockToprfEncryptor = createMockToprfEncryptor(); - const MOCK_ENCRYPTION_KEY = - mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); - const MOCK_PW_ENCRYPTION_KEY = + const mockToprfEncryptor = createMockToprfEncryptor(); + let MOCK_ENCRYPTION_KEY: Uint8Array; + let MOCK_PW_ENCRYPTION_KEY: Uint8Array; + let MOCK_AUTH_KEY_PAIR: KeyPair; + let encryptedMockVault: string; + let vaultEncryptionKey: string; + let vaultEncryptionSalt: string; + + beforeEach(async () => { + MOCK_ENCRYPTION_KEY = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + MOCK_PW_ENCRYPTION_KEY = mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); - const MOCK_AUTH_KEY_PAIR = + MOCK_AUTH_KEY_PAIR = mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); - const { encryptedMockVault, vaultEncryptionKey, vaultEncryptionSalt } = - await createMockVault( - MOCK_ENCRYPTION_KEY, - MOCK_PW_ENCRYPTION_KEY, - MOCK_AUTH_KEY_PAIR, - MOCK_PASSWORD, - ); + const mockVault = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_PW_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + ); + encryptedMockVault = mockVault.encryptedMockVault; + vaultEncryptionKey = mockVault.vaultEncryptionKey; + vaultEncryptionSalt = mockVault.vaultEncryptionSalt; + }); + it('should successfully refresh node auth tokens', async () => { await withController( { state: getMockInitialControllerState({ @@ -5088,23 +5100,7 @@ describe('SeedlessOnboardingController', () => { // Mock authenticate for token refresh jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ - nodeAuthTokens: [ - { - authToken: 'newAuthToken1', - nodeIndex: 1, - nodePubKey: 'newNodePubKey1', - }, - { - authToken: 'newAuthToken2', - nodeIndex: 2, - nodePubKey: 'newNodePubKey2', - }, - { - authToken: 'newAuthToken3', - nodeIndex: 3, - nodePubKey: 'newNodePubKey3', - }, - ], + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, isNewUser: false, }); @@ -5188,6 +5184,132 @@ describe('SeedlessOnboardingController', () => { }, ); }); + + it('should update accessToken and metadataAccessToken in state after refresh', async () => { + const newAccessToken = 'new-access-token'; + const newMetadataAccessToken = 'new-metadata-access-token'; + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: encryptedMockVault, + vaultEncryptionKey, + vaultEncryptionSalt, + }), + }, + async ({ + controller, + toprfClient, + mockRefreshJWTToken, + encryptor, + }) => { + const encryptWithKeySpy = jest.spyOn(encryptor, 'encryptWithKey'); + await controller.submitPassword(MOCK_PASSWORD); + + // Mock refreshJWTToken to return new tokens + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: newAccessToken, + metadataAccessToken: newMetadataAccessToken, + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.refreshAuthTokens(); + + // Verify the state is updated with new tokens + expect(controller.state.accessToken).toBe(newAccessToken); + expect(controller.state.metadataAccessToken).toBe( + newMetadataAccessToken, + ); + expect(encryptWithKeySpy).toHaveBeenCalled(); + }, + ); + }); + + it('should store accessToken in state when vault is locked', async () => { + const newAccessToken = 'new-access-token-when-locked'; + const newMetadataAccessToken = 'new-metadata-access-token-when-locked'; + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + // Vault is not unlocked (no submitPassword called) + + // Mock refreshJWTToken to return new tokens + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: newAccessToken, + metadataAccessToken: newMetadataAccessToken, + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.refreshAuthTokens(); + + // The accessToken should be stored in state even when vault is locked + expect(controller.state.accessToken).toBe(newAccessToken); + expect(controller.state.metadataAccessToken).toBe( + newMetadataAccessToken, + ); + }, + ); + }); + + it('should throw error when vaultEncryptionKey or vaultEncryptionSalt is missing while vault is unlocked', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: encryptedMockVault, + vaultEncryptionKey, + vaultEncryptionSalt, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + // Unlock the vault first + await controller.submitPassword(MOCK_PASSWORD); + + // Mock refreshJWTToken to return new tokens + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: 'new-access-token', + metadataAccessToken: 'new-metadata-access-token', + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + // Clear vaultEncryptionKey from state after unlocking + // @ts-expect-error Accessing protected method for testing + controller.update((state) => { + state.vaultEncryptionKey = undefined; + state.vaultEncryptionSalt = undefined; + }); + + // Should throw AuthenticationError (which wraps MissingCredentials) + await expect(controller.refreshAuthTokens()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + }, + ); + }); }); }); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 5373ce95ea0..dc822c72682 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1983,6 +1983,9 @@ export class SeedlessOnboardingController< refreshToken, skipLock: true, }); + + // update the vault with new access token + await this.#updateVaultAfterAuthTokenRefresh(accessToken); } catch (error) { log('Error refreshing node auth tokens', error); throw new Error( @@ -2216,6 +2219,55 @@ export class SeedlessOnboardingController< } } + async #updateVaultAfterAuthTokenRefresh(accessToken: string): Promise { + if (!this.#isUnlocked) { + // we just temporarily store the access token in the state + // when user attempts to unlock the vault, we will use this access token to update the vault + this.update((state) => { + state.accessToken = accessToken; + }); + return; + } + + const { vaultEncryptionKey, vaultEncryptionSalt } = this.state; + if ( + !vaultEncryptionKey || + !vaultEncryptionSalt || + !this.#cachedDecryptedVaultData + ) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.MissingCredentials, + ); + } + + // update the cached decrypted vault data with the new access token + this.#cachedDecryptedVaultData.accessToken = accessToken; + + const serializedVaultData = serializeVaultData( + this.#cachedDecryptedVaultData, + ); + + const encryptionKey = + await this.#vaultEncryptor.importKey(vaultEncryptionKey); + const updatedEncVault = await this.#vaultEncryptor.encryptWithKey( + encryptionKey, + serializedVaultData, + ); + + // NOTE: Referenced from keyring-controller! + // We need to include the salt used to derive + // the encryption key, to be able to derive it + // from password again. + updatedEncVault.salt = vaultEncryptionSalt; + + this.update((state) => { + state.vault = JSON.stringify(updatedEncVault); + state.vaultEncryptionSalt = vaultEncryptionSalt; + state.accessToken = accessToken; + state.vaultEncryptionKey = vaultEncryptionKey; + }); + } + /** * Check if the tokens are expired. * From 69f51f81d15c9190347337d3a587e0eb75b74ff3 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 3 Feb 2026 12:21:11 +0800 Subject: [PATCH 08/16] feat: update vault on 'submitPassword' if accessToken in state is updated --- .../src/SeedlessOnboardingController.ts | 73 ++++++++-- .../src/utils.test.ts | 126 ++++++++++++++---- .../src/utils.ts | 34 +++++ 3 files changed, 195 insertions(+), 38 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index dc822c72682..538601b4bb3 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -60,6 +60,7 @@ import type { ToprfKeyDeriver, } from './types'; import { + compareAndGetLatestToken, decodeJWTToken, decodeNodeAuthToken, deserializeVaultData, @@ -855,7 +856,7 @@ export class SeedlessOnboardingController< /** * Submit the password to the controller, verify the password validity and unlock the controller. * - * This method will be used especially when user rehydrate/unlock the wallet. + * This method will be used especially when user unlock the wallet. * The provided password will be verified against the encrypted vault, encryption key will be derived and saved in the controller state. * * This operation is useful when user performs some actions that requires the user password/encryption key. e.g. add new srp backup @@ -865,7 +866,50 @@ export class SeedlessOnboardingController< */ async submitPassword(password: string): Promise { return await this.#withControllerLock(async () => { - await this.#unlockVaultAndGetVaultData({ password }); + // get the access token from the state before unlocking, it might be the new token set from the `refreshAuthTokens` method. + const { accessToken: accessTokenBeforeUnlock } = this.state; + + const deserializedVaultData = await this.#unlockVaultAndGetVaultData({ + password, + }); + + const { accessToken: accessTokenAfterUnlock } = this.state; + + let latestAccessToken = accessTokenAfterUnlock; + + // compare the access token from state before unlocking and after unlocking, take the latest access token if it's different. + if ( + accessTokenBeforeUnlock && + accessTokenAfterUnlock && + accessTokenBeforeUnlock !== accessTokenAfterUnlock + ) { + // If there was an access token in the state before unlocking, it might be the token set from the `refreshAuthTokens` method. + // Compare it with the access token value from decrypted vault after unlocking and take the latest access token. + latestAccessToken = compareAndGetLatestToken( + accessTokenBeforeUnlock, + accessTokenAfterUnlock, + ); + } + + // update the state and vault with the latest access token if it's different from the current access token in the state. + if (latestAccessToken && latestAccessToken !== accessTokenAfterUnlock) { + // update the access token in the state with the latest access token if it's different from the decrypted access token after unlocking + this.update((state) => { + state.accessToken = latestAccessToken; + }); + + const updatedVaultData = { + ...deserializedVaultData, + accessToken: latestAccessToken, + }; + + await this.#updateVault({ + password, + vaultData: updatedVaultData, + pwEncKey: deserializedVaultData.toprfPwEncryptionKey, + }); + } + this.#setUnlocked(); }); } @@ -1725,7 +1769,7 @@ export class SeedlessOnboardingController< }: { password: string; vaultData: DeserializedVaultData; - pwEncKey: Uint8Array; + pwEncKey?: Uint8Array; }): Promise { await this.#withVaultLock(async () => { assertIsValidPassword(password); @@ -1744,15 +1788,26 @@ export class SeedlessOnboardingController< serializedVaultData, ); + const updatedState: Partial = { + vault, + vaultEncryptionKey: exportedKeyString, + vaultEncryptionSalt: JSON.parse(vault).salt, + }; + // Encrypt vault key. - const aes = managedNonce(gcm)(pwEncKey); - const encryptedKey = aes.encrypt(utf8ToBytes(exportedKeyString)); + if (pwEncKey) { + const aes = managedNonce(gcm)(pwEncKey); + const encryptedKey = aes.encrypt(utf8ToBytes(exportedKeyString)); + updatedState.encryptedSeedlessEncryptionKey = + bytesToBase64(encryptedKey); + } this.update((state) => { - state.vault = vault; - state.vaultEncryptionKey = exportedKeyString; - state.vaultEncryptionSalt = JSON.parse(vault).salt; - state.encryptedSeedlessEncryptionKey = bytesToBase64(encryptedKey); + state.vault = updatedState.vault; + state.vaultEncryptionKey = updatedState.vaultEncryptionKey; + state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; + state.encryptedSeedlessEncryptionKey = + updatedState.encryptedSeedlessEncryptionKey; }); }); } diff --git a/packages/seedless-onboarding-controller/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils.test.ts index 497c8e8c896..8d456edc58d 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils.test.ts @@ -2,7 +2,37 @@ import { bytesToBase64 } from '@metamask/utils'; import { utf8ToBytes } from '@noble/ciphers/utils'; import type { DecodedNodeAuthToken, DecodedBaseJWTToken } from './types'; -import { decodeNodeAuthToken, decodeJWTToken } from './utils'; +import { + decodeNodeAuthToken, + decodeJWTToken, + compareAndGetLatestToken, +} from './utils'; + +/** + * Creates a mock JWT token for testing + * + * @param payload - The payload to encode + * @returns The JWT token string + */ +const createMockJWTToken = ( + payload: Partial = {}, +): string => { + const defaultPayload: DecodedBaseJWTToken = { + exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + iat: Math.floor(Date.now() / 1000), // issued now + aud: 'mock_audience', + iss: 'mock_issuer', + sub: 'mock_subject', + ...payload, + }; + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64'); + const encodedPayload = Buffer.from(JSON.stringify(defaultPayload)).toString( + 'base64', + ); + const signature = 'mock_signature'; + return `${encodedHeader}.${encodedPayload}.${signature}`; +}; describe('utils', () => { describe('decodeNodeAuthToken', () => { @@ -75,34 +105,6 @@ describe('utils', () => { }); describe('decodeJWTToken', () => { - /** - * Creates a mock JWT token for testing - * - * @param payload - The payload to encode - * @returns The JWT token string - */ - const createMockJWTToken = ( - payload: Partial = {}, - ): string => { - const defaultPayload: DecodedBaseJWTToken = { - exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now - iat: Math.floor(Date.now() / 1000), // issued now - aud: 'mock_audience', - iss: 'mock_issuer', - sub: 'mock_subject', - ...payload, - }; - const header = { alg: 'HS256', typ: 'JWT' }; - const encodedHeader = Buffer.from(JSON.stringify(header)).toString( - 'base64', - ); - const encodedPayload = Buffer.from( - JSON.stringify(defaultPayload), - ).toString('base64'); - const signature = 'mock_signature'; - return `${encodedHeader}.${encodedPayload}.${signature}`; - }; - it('should successfully decode a valid JWT token', () => { const mockPayload = { exp: 1234567890, @@ -175,4 +177,70 @@ describe('utils', () => { expect(result.sub).toBe('user-123@example.com'); }); }); + + describe('compareAndGetLatestToken', () => { + it('should return the first token when it has a later expiration', () => { + const laterToken = createMockJWTToken({ exp: 2000000000 }); // Later expiration + const earlierToken = createMockJWTToken({ exp: 1000000000 }); // Earlier expiration + + const result = compareAndGetLatestToken(laterToken, earlierToken); + + expect(result).toBe(laterToken); + }); + + it('should return the second token when it has a later expiration', () => { + const earlierToken = createMockJWTToken({ exp: 1000000000 }); // Earlier expiration + const laterToken = createMockJWTToken({ exp: 2000000000 }); // Later expiration + + const result = compareAndGetLatestToken(earlierToken, laterToken); + + expect(result).toBe(laterToken); + }); + + it('should return the second token when both have the same expiration', () => { + const token1 = createMockJWTToken({ exp: 1500000000 }); + const token2 = createMockJWTToken({ exp: 1500000000 }); + + const result = compareAndGetLatestToken(token1, token2); + + expect(result).toBe(token2); + }); + + it('should return the second token when the first token is invalid', () => { + const invalidToken = 'invalid.token'; // Missing signature part + const validToken = createMockJWTToken({ exp: 1500000000 }); + + const result = compareAndGetLatestToken(invalidToken, validToken); + + expect(result).toBe(validToken); + }); + + it('should return the first token when the second token is invalid', () => { + const validToken = createMockJWTToken({ exp: 1500000000 }); + const invalidToken = 'not-a-valid-jwt-token'; + + const result = compareAndGetLatestToken(validToken, invalidToken); + + expect(result).toBe(validToken); + }); + + it('should return the second token when both tokens are invalid', () => { + const invalidToken1 = 'invalid.token'; + const invalidToken2 = 'also.invalid'; + + const result = compareAndGetLatestToken(invalidToken1, invalidToken2); + + // First token is invalid, so it returns the second token + expect(result).toBe(invalidToken2); + }); + + it('should handle tokens with expiration times close together', () => { + const token1 = createMockJWTToken({ exp: 1500000001 }); + const token2 = createMockJWTToken({ exp: 1500000000 }); + + const result = compareAndGetLatestToken(token1, token2); + + expect(result).toBe(token1); + }); + }); }); diff --git a/packages/seedless-onboarding-controller/src/utils.ts b/packages/seedless-onboarding-controller/src/utils.ts index b769c9f9f76..84022a4c80b 100644 --- a/packages/seedless-onboarding-controller/src/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils.ts @@ -112,3 +112,37 @@ export function deserializeAuthKeyPair(value: string): KeyPair { pk: base64ToBytes(parsedKeyPair.pk), }; } + +/** + * Compare two JWT tokens and return the latest token. + * + * @param jwtToken1 - The first JWT token to compare. + * @param jwtToken2 - The second JWT token to compare. + * @returns The latest JWT token. + */ +export function compareAndGetLatestToken( + jwtToken1: string, + jwtToken2: string, +): string { + let decodedToken1: DecodedBaseJWTToken; + let decodedToken2: DecodedBaseJWTToken; + + try { + decodedToken1 = decodeJWTToken(jwtToken1); + } catch { + // if the first token is invalid, return the second token + return jwtToken2; + } + + try { + decodedToken2 = decodeJWTToken(jwtToken2); + } catch { + // if the second token is invalid, return the first token + return jwtToken1; + } + + if (decodedToken1.exp > decodedToken2.exp) { + return jwtToken1; + } + return jwtToken2; +} From cdfb9ce49fd74dff69cbbd51257d987d59f9dc60 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 3 Feb 2026 15:03:02 +0800 Subject: [PATCH 09/16] feat: persist new accessToken for the password sync case --- .../src/SeedlessOnboardingController.test.ts | 167 +++++++++++++----- .../src/SeedlessOnboardingController.ts | 34 +++- .../src/utils.test.ts | 29 +-- .../tests/mocks/utils.ts | 27 +++ 4 files changed, 179 insertions(+), 78 deletions(-) create mode 100644 packages/seedless-onboarding-controller/tests/mocks/utils.ts diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index af7480c643d..1274b9cd147 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -72,6 +72,7 @@ import { MULTIPLE_MOCK_SECRET_METADATA, } from '../tests/mocks/toprf'; import { MockToprfEncryptorDecryptor } from '../tests/mocks/toprfEncryptor'; +import { createMockJWTToken } from '../tests/mocks/utils'; import MockVaultEncryptor from '../tests/mocks/vaultEncryptor'; const authConnection = AuthConnection.Google; @@ -3897,6 +3898,7 @@ describe('SeedlessOnboardingController', () => { describe('syncLatestGlobalPassword', () => { const OLD_PASSWORD = 'old-mock-password'; const GLOBAL_PASSWORD = 'new-global-password'; + const mockToprfEncryptor = createMockToprfEncryptor(); let MOCK_VAULT: string; let MOCK_VAULT_ENCRYPTION_KEY: string; let MOCK_VAULT_ENCRYPTION_SALT: string; @@ -3905,10 +3907,12 @@ describe('SeedlessOnboardingController', () => { let initialEncKey: Uint8Array; // Store initial encKey for vault creation let initialPwEncKey: Uint8Array; // Store initial pwEncKey for vault creation let initialEncryptedSeedlessEncryptionKey: Uint8Array; // Store initial encryptedSeedlessEncryptionKey for vault creation + let newEncKey: Uint8Array; + let newPwEncKey: Uint8Array; + let newAuthKeyPair: KeyPair; // Generate initial keys and vault state before tests run beforeAll(async () => { - const mockToprfEncryptor = createMockToprfEncryptor(); initialEncKey = mockToprfEncryptor.deriveEncKey(OLD_PASSWORD); initialPwEncKey = mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(OLD_PASSWORD); @@ -3933,6 +3937,12 @@ describe('SeedlessOnboardingController', () => { }); // Remove beforeEach as setup is done in beforeAll now + beforeEach(() => { + // Mock recoverEncKey for the new global password + newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + newPwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); + newAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + }); it('should successfully sync the latest global password', async () => { const b64EncKey = bytesToBase64(initialEncryptedSeedlessEncryptionKey); @@ -3959,14 +3969,6 @@ describe('SeedlessOnboardingController', () => { const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); - // Mock recoverEncKey for the new global password - const mockToprfEncryptor = createMockToprfEncryptor(); - const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const newPwEncKey = - mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const newAuthKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - recoverEncKeySpy.mockResolvedValueOnce({ encKey: newEncKey, pwEncKey: newPwEncKey, @@ -4039,6 +4041,109 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should persist the latest accessToken when state token is newer than vault token', async () => { + const newerAccessToken = createMockJWTToken({ exp: 2000000000 }); // refreshed accessToken + const b64EncKey = bytesToBase64(initialEncryptedSeedlessEncryptionKey); + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, // Use the base64 encoded key + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + withMockAuthPubKey: true, + encryptedSeedlessEncryptionKey: b64EncKey, + }), + }, + async ({ controller, toprfClient, encryptor, mockRefreshJWTToken }) => { + // Unlock controller first - requires vaultEncryptionKey/Salt or password + // Since we provide key/salt in state, submitPassword isn't strictly needed here + // but we keep it to match the method's requirement of being unlocked + // We'll use the key/salt implicitly by not providing password to unlockVaultAndGetBackupEncKey + await controller.submitPassword(OLD_PASSWORD); // Unlock using the standard method + + const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); + const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); + + recoverEncKeySpy.mockResolvedValueOnce({ + encKey: newEncKey, + pwEncKey: newPwEncKey, + authKeyPair: newAuthKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + // Lock the wallet + await controller.setLocked(); + + // The following mocks are to simulate the token expiry and refresh. + // mock token expiry + jest + .spyOn(controller, 'checkNodeAuthTokenExpired') + .mockReturnValueOnce(true); + // mock token refresh + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: newerAccessToken, + metadataAccessToken: 'new-metadata-access-token', + }); + // mock toprfClient.authenticate which is called to generate new NodeAuthTokens + jest.spyOn(toprfClient, 'authenticate').mockResolvedValueOnce({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + // Mock recoverEncKey for the global password + const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); + const authKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ + encKey, + pwEncKey, + authKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + // Mock toprfClient.recoverPwEncKey + const recoveredPwEncKey = + mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: recoveredPwEncKey, + }); + + await controller.submitGlobalPassword({ + globalPassword: GLOBAL_PASSWORD, + }); + + // assert that the newer access token is set in the state + expect(controller.state.accessToken).toBe(newerAccessToken); + + await controller.syncLatestGlobalPassword({ + globalPassword: GLOBAL_PASSWORD, + }); + + // Check if vault was re-encrypted with the new password and keys + const expectedSerializedVaultData = JSON.stringify({ + toprfEncryptionKey: bytesToBase64(newEncKey), + toprfPwEncryptionKey: bytesToBase64(newPwEncKey), + toprfAuthKeyPair: JSON.stringify({ + sk: bigIntToHex(newAuthKeyPair.sk), + pk: bytesToBase64(newAuthKeyPair.pk), + }), + revokeToken: controller.state.revokeToken, + accessToken: newerAccessToken, + }); + expect(encryptorSpy).toHaveBeenCalledWith( + GLOBAL_PASSWORD, + expectedSerializedVaultData, + ); + }, + ); + }); + it('should throw an error if recovering the encryption key for the global password fails', async () => { await withController( { @@ -4100,13 +4205,6 @@ describe('SeedlessOnboardingController', () => { const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); // Make recoverEncKey succeed - const mockToprfEncryptor = createMockToprfEncryptor(); - const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const newPwEncKey = - mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const newAuthKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - recoverEncKeySpy.mockResolvedValueOnce({ encKey: newEncKey, pwEncKey: newPwEncKey, @@ -4158,13 +4256,6 @@ describe('SeedlessOnboardingController', () => { async ({ controller, toprfClient }) => { // Here we are creating mock keys associated with the new global password // and these values are used as mock return values for the recoverEncKey and recoverPwEncKey calls - const mockToprfEncryptor = createMockToprfEncryptor(); - const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const newPwEncKey = - mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const newAuthKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - const recoverEncKeySpy = jest .spyOn(toprfClient, 'recoverEncKey') .mockResolvedValueOnce({ @@ -5314,15 +5405,9 @@ describe('SeedlessOnboardingController', () => { }); describe('fetchMetadataAccessCreds', () => { - const createMockJWTToken = (exp: number): string => { - const payload = { exp }; - const encodedPayload = btoa(JSON.stringify(payload)); - return `header.${encodedPayload}.signature`; - }; - it('should return the current metadata access token if not expired', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - const validToken = createMockJWTToken(futureExp); + const validToken = createMockJWTToken({ exp: futureExp }); const { messenger } = mockSeedlessOnboardingMessenger(); const controller = new SeedlessOnboardingController({ @@ -5366,7 +5451,7 @@ describe('SeedlessOnboardingController', () => { it('should call refreshAuthTokens if metadataAccessToken is expired', async () => { const pastExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago - const expiredToken = createMockJWTToken(pastExp); + const expiredToken = createMockJWTToken({ exp: pastExp }); const { messenger } = mockSeedlessOnboardingMessenger(); const controller = new SeedlessOnboardingController({ messenger, @@ -5390,15 +5475,9 @@ describe('SeedlessOnboardingController', () => { }); describe('checkMetadataAccessTokenExpired', () => { - const createMockJWTToken = (exp: number): string => { - const payload = { exp }; - const encodedPayload = btoa(JSON.stringify(payload)); - return `header.${encodedPayload}.signature`; - }; - it('should return false if metadata access token is not expired', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - const validToken = createMockJWTToken(futureExp); + const validToken = createMockJWTToken({ exp: futureExp }); await withController( { @@ -5421,7 +5500,7 @@ describe('SeedlessOnboardingController', () => { it('should return true if metadata access token is expired', async () => { const pastExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago - const expiredToken = createMockJWTToken(pastExp); + const expiredToken = createMockJWTToken({ exp: pastExp }); await withController( { @@ -5474,15 +5553,9 @@ describe('SeedlessOnboardingController', () => { }); describe('checkAccessTokenExpired', () => { - const createMockJWTToken = (exp: number): string => { - const payload = { exp }; - const encodedPayload = btoa(JSON.stringify(payload)); - return `header.${encodedPayload}.signature`; - }; - it('should return false if access token is not expired', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - const validToken = createMockJWTToken(futureExp); + const validToken = createMockJWTToken({ exp: futureExp }); await withController( { @@ -5503,7 +5576,7 @@ describe('SeedlessOnboardingController', () => { it('should return true if access token is expired', async () => { const pastExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago - const expiredToken = createMockJWTToken(pastExp); + const expiredToken = createMockJWTToken({ exp: pastExp }); await withController( { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 538601b4bb3..302ab732dc7 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1020,25 +1020,51 @@ export class SeedlessOnboardingController< globalPassword: string; maxKeyChainLength: number; }): Promise { - const { pwEncKey: curPwEncKey, authKeyPair: curAuthKeyPair } = + const { pwEncKey: globalPwEncKey, authKeyPair: globalAuthKeyPair } = await this.#recoverEncKey(globalPassword); try { // Recover vault encryption key. const res = await this.toprfClient.recoverPwEncKey({ targetAuthPubKey, - curPwEncKey, - curAuthKeyPair, + curPwEncKey: globalPwEncKey, + curAuthKeyPair: globalAuthKeyPair, maxPwChainLength: maxKeyChainLength, }); const { pwEncKey } = res; const vaultKey = await this.#loadSeedlessEncryptionKey(pwEncKey); + // accessToken before unlocking vault and flooding the state with values from the decrypted vault + // it might be the new token set from the `refreshAuthTokens` method. + const { accessToken: accessTokenBeforeUnlock } = this.state; + // Unlock the controller - await this.#unlockVaultAndGetVaultData({ + const decryptedVaultData = await this.#unlockVaultAndGetVaultData({ encryptionKey: vaultKey, }); this.#setUnlocked(); + + // accessToken from decrypted vault + const accessTokenFromDecryptedVault = decryptedVaultData.accessToken; + + // compare the two access tokens, take the latest access token if it's different. + if ( + accessTokenBeforeUnlock && + accessTokenFromDecryptedVault && + accessTokenBeforeUnlock !== accessTokenFromDecryptedVault + ) { + const latestAccessToken = compareAndGetLatestToken( + accessTokenBeforeUnlock, + accessTokenFromDecryptedVault, + ); + // update the access token in the state with the latest access token if it's different from the decrypted access token after unlocking + // later when we call `syncLatestGlobalPassword`, the encrypted vault will be updated with the latest access token. + this.update((state) => { + state.accessToken = latestAccessToken; + }); + } + + this.#resetPasswordOutdatedCache(); } catch (error) { if (this.#isAuthTokenError(error)) { throw error; diff --git a/packages/seedless-onboarding-controller/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils.test.ts index 8d456edc58d..5f3c4fda48c 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils.test.ts @@ -1,38 +1,13 @@ import { bytesToBase64 } from '@metamask/utils'; import { utf8ToBytes } from '@noble/ciphers/utils'; -import type { DecodedNodeAuthToken, DecodedBaseJWTToken } from './types'; +import type { DecodedNodeAuthToken } from './types'; import { decodeNodeAuthToken, decodeJWTToken, compareAndGetLatestToken, } from './utils'; - -/** - * Creates a mock JWT token for testing - * - * @param payload - The payload to encode - * @returns The JWT token string - */ -const createMockJWTToken = ( - payload: Partial = {}, -): string => { - const defaultPayload: DecodedBaseJWTToken = { - exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now - iat: Math.floor(Date.now() / 1000), // issued now - aud: 'mock_audience', - iss: 'mock_issuer', - sub: 'mock_subject', - ...payload, - }; - const header = { alg: 'HS256', typ: 'JWT' }; - const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64'); - const encodedPayload = Buffer.from(JSON.stringify(defaultPayload)).toString( - 'base64', - ); - const signature = 'mock_signature'; - return `${encodedHeader}.${encodedPayload}.${signature}`; -}; +import { createMockJWTToken } from '../tests/mocks/utils'; describe('utils', () => { describe('decodeNodeAuthToken', () => { diff --git a/packages/seedless-onboarding-controller/tests/mocks/utils.ts b/packages/seedless-onboarding-controller/tests/mocks/utils.ts new file mode 100644 index 00000000000..dbbd24886b2 --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/mocks/utils.ts @@ -0,0 +1,27 @@ +import { DecodedBaseJWTToken } from 'src/types'; + +/** + * Creates a mock JWT token for testing + * + * @param payload - The payload to encode + * @returns The JWT token string + */ +export function createMockJWTToken( + payload: Partial = {}, +): string { + const defaultPayload: DecodedBaseJWTToken = { + exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + iat: Math.floor(Date.now() / 1000), // issued now + aud: 'mock_audience', + iss: 'mock_issuer', + sub: 'mock_subject', + ...payload, + }; + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64'); + const encodedPayload = Buffer.from(JSON.stringify(defaultPayload)).toString( + 'base64', + ); + const signature = 'mock_signature'; + return `${encodedHeader}.${encodedPayload}.${signature}`; +} From 82290216be88f0ce354a234603bda8a504b61e0a Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 3 Feb 2026 15:59:29 +0800 Subject: [PATCH 10/16] chore: fixed imports and typos --- .../src/SeedlessOnboardingController.test.ts | 3 ++- .../src/SeedlessOnboardingController.ts | 16 ++++++++++------ .../tests/mocks/utils.ts | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 1274b9cd147..7b0aad5eb48 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -4042,7 +4042,8 @@ describe('SeedlessOnboardingController', () => { }); it('should persist the latest accessToken when state token is newer than vault token', async () => { - const newerAccessToken = createMockJWTToken({ exp: 2000000000 }); // refreshed accessToken + const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + const newerAccessToken = createMockJWTToken({ exp: futureExp }); // refreshed accessToken const b64EncKey = bytesToBase64(initialEncryptedSeedlessEncryptionKey); await withController( diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 302ab732dc7..6df3b8703cc 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -2321,12 +2321,10 @@ export class SeedlessOnboardingController< ); } - // update the cached decrypted vault data with the new access token - this.#cachedDecryptedVaultData.accessToken = accessToken; - - const serializedVaultData = serializeVaultData( - this.#cachedDecryptedVaultData, - ); + const serializedVaultData = serializeVaultData({ + ...this.#cachedDecryptedVaultData, + accessToken, + }); const encryptionKey = await this.#vaultEncryptor.importKey(vaultEncryptionKey); @@ -2347,6 +2345,12 @@ export class SeedlessOnboardingController< state.accessToken = accessToken; state.vaultEncryptionKey = vaultEncryptionKey; }); + + // update the cached decrypted vault data with the new access token + this.#cachedDecryptedVaultData = { + ...this.#cachedDecryptedVaultData, + accessToken, + }; } /** diff --git a/packages/seedless-onboarding-controller/tests/mocks/utils.ts b/packages/seedless-onboarding-controller/tests/mocks/utils.ts index dbbd24886b2..7ea8ac61eea 100644 --- a/packages/seedless-onboarding-controller/tests/mocks/utils.ts +++ b/packages/seedless-onboarding-controller/tests/mocks/utils.ts @@ -1,4 +1,4 @@ -import { DecodedBaseJWTToken } from 'src/types'; +import { DecodedBaseJWTToken } from '../../src/types'; /** * Creates a mock JWT token for testing From 3ec2ede838790f7198172ba7c937e1d1554add0e Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 3 Feb 2026 16:10:38 +0800 Subject: [PATCH 11/16] chore: updated ChangeLog --- packages/seedless-onboarding-controller/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 6dd57f5f791..1dbe159ad47 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **BREAKING** The `encryptor` constructor param requires `encryptWithKey` method. ([#7800](https://github.com/MetaMask/core/pull/7800)) + - The method is to encrypt the vault with cached encryption key while the wallet is unlocked. - Added new public method, `getAccessToken`. ([#7800](https://github.com/MetaMask/core/pull/7800)) - Clients can use this method to get `accessToken` from the controller, instead of directly accessing from the state. - This method also adds refresh token mechanism when `accessToken` is expired, hence preventing expired token usage in the clients. @@ -20,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/keyring-controller` from `^25.0.0` to `^25.1.0` ([#7713](https://github.com/MetaMask/core/pull/7713)) - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +### Fixed + +- Fixed new `accessToken` not being persisted in the vault after the token refresh. ([#7800](https://github.com/MetaMask/core/pull/7800)) + ## [7.1.0] ### Added From 40468f94a4a57c660019eba761e5937390a5534a Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 3 Feb 2026 17:58:12 +0800 Subject: [PATCH 12/16] fix: added missing vault lock --- .../src/SeedlessOnboardingController.ts | 115 ++++++++---------- 1 file changed, 53 insertions(+), 62 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 6df3b8703cc..f84e25327f7 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1795,7 +1795,7 @@ export class SeedlessOnboardingController< }: { password: string; vaultData: DeserializedVaultData; - pwEncKey?: Uint8Array; + pwEncKey: Uint8Array; }): Promise { await this.#withVaultLock(async () => { assertIsValidPassword(password); @@ -1814,26 +1814,15 @@ export class SeedlessOnboardingController< serializedVaultData, ); - const updatedState: Partial = { - vault, - vaultEncryptionKey: exportedKeyString, - vaultEncryptionSalt: JSON.parse(vault).salt, - }; - - // Encrypt vault key. - if (pwEncKey) { - const aes = managedNonce(gcm)(pwEncKey); - const encryptedKey = aes.encrypt(utf8ToBytes(exportedKeyString)); - updatedState.encryptedSeedlessEncryptionKey = - bytesToBase64(encryptedKey); - } + const aes = managedNonce(gcm)(pwEncKey); + const encryptedKey = aes.encrypt(utf8ToBytes(exportedKeyString)); + const encryptedSeedlessEncryptionKey = bytesToBase64(encryptedKey); this.update((state) => { - state.vault = updatedState.vault; - state.vaultEncryptionKey = updatedState.vaultEncryptionKey; - state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; - state.encryptedSeedlessEncryptionKey = - updatedState.encryptedSeedlessEncryptionKey; + state.vault = vault; + state.vaultEncryptionKey = exportedKeyString; + state.vaultEncryptionSalt = JSON.parse(vault).salt; + state.encryptedSeedlessEncryptionKey = encryptedSeedlessEncryptionKey; }); }); } @@ -2301,56 +2290,58 @@ export class SeedlessOnboardingController< } async #updateVaultAfterAuthTokenRefresh(accessToken: string): Promise { - if (!this.#isUnlocked) { - // we just temporarily store the access token in the state - // when user attempts to unlock the vault, we will use this access token to update the vault - this.update((state) => { - state.accessToken = accessToken; + await this.#withVaultLock(async () => { + if (!this.#isUnlocked) { + // we just temporarily store the access token in the state + // when user attempts to unlock the vault, we will use this access token to update the vault + this.update((state) => { + state.accessToken = accessToken; + }); + return; + } + + const { vaultEncryptionKey, vaultEncryptionSalt } = this.state; + if ( + !vaultEncryptionKey || + !vaultEncryptionSalt || + !this.#cachedDecryptedVaultData + ) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.MissingCredentials, + ); + } + + const serializedVaultData = serializeVaultData({ + ...this.#cachedDecryptedVaultData, + accessToken, }); - return; - } - const { vaultEncryptionKey, vaultEncryptionSalt } = this.state; - if ( - !vaultEncryptionKey || - !vaultEncryptionSalt || - !this.#cachedDecryptedVaultData - ) { - throw new Error( - SeedlessOnboardingControllerErrorMessage.MissingCredentials, + const encryptionKey = + await this.#vaultEncryptor.importKey(vaultEncryptionKey); + const updatedEncVault = await this.#vaultEncryptor.encryptWithKey( + encryptionKey, + serializedVaultData, ); - } - - const serializedVaultData = serializeVaultData({ - ...this.#cachedDecryptedVaultData, - accessToken, - }); - const encryptionKey = - await this.#vaultEncryptor.importKey(vaultEncryptionKey); - const updatedEncVault = await this.#vaultEncryptor.encryptWithKey( - encryptionKey, - serializedVaultData, - ); + // NOTE: Referenced from keyring-controller! + // We need to include the salt used to derive + // the encryption key, to be able to derive it + // from password again. + updatedEncVault.salt = vaultEncryptionSalt; - // NOTE: Referenced from keyring-controller! - // We need to include the salt used to derive - // the encryption key, to be able to derive it - // from password again. - updatedEncVault.salt = vaultEncryptionSalt; + this.update((state) => { + state.vault = JSON.stringify(updatedEncVault); + state.vaultEncryptionSalt = vaultEncryptionSalt; + state.accessToken = accessToken; + state.vaultEncryptionKey = vaultEncryptionKey; + }); - this.update((state) => { - state.vault = JSON.stringify(updatedEncVault); - state.vaultEncryptionSalt = vaultEncryptionSalt; - state.accessToken = accessToken; - state.vaultEncryptionKey = vaultEncryptionKey; + // update the cached decrypted vault data with the new access token + this.#cachedDecryptedVaultData = { + ...this.#cachedDecryptedVaultData, + accessToken, + }; }); - - // update the cached decrypted vault data with the new access token - this.#cachedDecryptedVaultData = { - ...this.#cachedDecryptedVaultData, - accessToken, - }; } /** From 77d255e6a336320d7050cfda453315e63c574938 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 4 Feb 2026 01:15:06 +0800 Subject: [PATCH 13/16] chore: refactors accessToken comparison and state update --- .../src/SeedlessOnboardingController.ts | 92 ++++++++++--------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index f84e25327f7..53023b84f24 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -873,31 +873,17 @@ export class SeedlessOnboardingController< password, }); - const { accessToken: accessTokenAfterUnlock } = this.state; + const accessTokenFromDecryptedVault = deserializedVaultData.accessToken; - let latestAccessToken = accessTokenAfterUnlock; - - // compare the access token from state before unlocking and after unlocking, take the latest access token if it's different. - if ( - accessTokenBeforeUnlock && - accessTokenAfterUnlock && - accessTokenBeforeUnlock !== accessTokenAfterUnlock - ) { - // If there was an access token in the state before unlocking, it might be the token set from the `refreshAuthTokens` method. - // Compare it with the access token value from decrypted vault after unlocking and take the latest access token. - latestAccessToken = compareAndGetLatestToken( - accessTokenBeforeUnlock, - accessTokenAfterUnlock, - ); - } - - // update the state and vault with the latest access token if it's different from the current access token in the state. - if (latestAccessToken && latestAccessToken !== accessTokenAfterUnlock) { - // update the access token in the state with the latest access token if it's different from the decrypted access token after unlocking - this.update((state) => { - state.accessToken = latestAccessToken; - }); + // Pick the latest access token - the token from state might be newer (from refreshAuthTokens) + // than the token stored in the vault. + const latestAccessToken = this.#pickLatestAccessToken( + accessTokenBeforeUnlock, + accessTokenFromDecryptedVault, + ); + // update the state and vault with the latest access token `ONLY` if it's different from the current access token in the state. + if (latestAccessToken !== accessTokenFromDecryptedVault) { const updatedVaultData = { ...deserializedVaultData, accessToken: latestAccessToken, @@ -1044,27 +1030,12 @@ export class SeedlessOnboardingController< }); this.#setUnlocked(); - // accessToken from decrypted vault - const accessTokenFromDecryptedVault = decryptedVaultData.accessToken; - - // compare the two access tokens, take the latest access token if it's different. - if ( - accessTokenBeforeUnlock && - accessTokenFromDecryptedVault && - accessTokenBeforeUnlock !== accessTokenFromDecryptedVault - ) { - const latestAccessToken = compareAndGetLatestToken( - accessTokenBeforeUnlock, - accessTokenFromDecryptedVault, - ); - // update the access token in the state with the latest access token if it's different from the decrypted access token after unlocking - // later when we call `syncLatestGlobalPassword`, the encrypted vault will be updated with the latest access token. - this.update((state) => { - state.accessToken = latestAccessToken; - }); - } - - this.#resetPasswordOutdatedCache(); + // Pick the latest access token - the token from state might be newer (from refreshAuthTokens) + // than the token stored in the vault. The vault will be updated later by syncLatestGlobalPassword. + this.#pickLatestAccessToken( + accessTokenBeforeUnlock, + decryptedVaultData.accessToken, + ); } catch (error) { if (this.#isAuthTokenError(error)) { throw error; @@ -1181,6 +1152,39 @@ export class SeedlessOnboardingController< this.#isUnlocked = true; } + /** + * Compares two access tokens and picks the latest one based on JWT expiration. + * If the tokens are different, the state is updated with the latest token. + * + * @param tokenBeforeUnlock - The access token from state before unlocking (may have been set by refreshAuthTokens). + * @param tokenAfterUnlock - The access token from the decrypted vault after unlocking. + * @returns The latest access token, or the token after unlock if no reconciliation was needed. + */ + #pickLatestAccessToken( + tokenBeforeUnlock: string | undefined, + tokenAfterUnlock: string, + ): string { + let latestToken = tokenAfterUnlock; + + if ( + tokenBeforeUnlock && + tokenAfterUnlock && + tokenBeforeUnlock !== tokenAfterUnlock + ) { + latestToken = compareAndGetLatestToken( + tokenBeforeUnlock, + tokenAfterUnlock, + ); + + // Update the access token in the state with the latest access token + this.update((state) => { + state.accessToken = latestToken; + }); + } + + return latestToken; + } + /** * Clears the current state of the SeedlessOnboardingController. */ From 39708a097ddf16105ed7f04985674954bcd2a6c5 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 4 Feb 2026 12:48:51 +0800 Subject: [PATCH 14/16] chore: refactored updateVault method --- .../src/SeedlessOnboardingController.test.ts | 2 +- .../src/SeedlessOnboardingController.ts | 184 +++++++++--------- .../src/assertions.test.ts | 37 ++++ .../src/assertions.ts | 20 ++ 4 files changed, 148 insertions(+), 95 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 7b0aad5eb48..9eb65c9ac7f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -3195,7 +3195,7 @@ describe('SeedlessOnboardingController', () => { MOCK_KEYRING_ID, ), ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, + SeedlessOnboardingControllerErrorMessage.MissingCredentials, ); expect(mockSecretDataAdd.isDone()).toBe(true); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 53023b84f24..34adc706dcc 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -33,6 +33,7 @@ import { Mutex } from 'async-mutex'; import { assertIsPasswordOutdatedCacheValid, assertIsSeedlessOnboardingUserAuthenticated, + assertIsValidPassword, assertIsValidVaultData, } from './assertions'; import type { AuthConnection } from './constants'; @@ -1787,7 +1788,7 @@ export class SeedlessOnboardingController< * Encrypt and update the vault with the given authentication data. * * @param params - The parameters for updating the vault. - * @param params.password - The password to encrypt the vault. + * @param params.password - The optional password to encrypt the vault. If not provided, the vault will be encrypted with the encryption key in the state. * @param params.vaultData - The raw vault data to update the vault with. * @param params.pwEncKey - The global password encryption key. * @returns A promise that resolves to the updated vault. @@ -1797,40 +1798,97 @@ export class SeedlessOnboardingController< vaultData, pwEncKey, }: { - password: string; + password?: string; vaultData: DeserializedVaultData; pwEncKey: Uint8Array; }): Promise { await this.#withVaultLock(async () => { - assertIsValidPassword(password); + const serializedVaultData = serializeVaultData(vaultData); - // cache the vault data to avoid decrypting the vault data multiple times - this.#cachedDecryptedVaultData = vaultData; + const { vaultEncryptionKey, vaultEncryptionSalt, vault } = this.state; - const serializedVaultData = serializeVaultData(vaultData); + const updatedState: Partial = { + vault, + vaultEncryptionKey, + vaultEncryptionSalt, + encryptedSeedlessEncryptionKey: + this.state.encryptedSeedlessEncryptionKey, + }; - // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key - // from the password using an intentionally slow key derivation function. - // We should make sure that we only call it very intentionally. - const { vault, exportedKeyString } = - await this.#vaultEncryptor.encryptWithDetail( - password, + // if the password is provided, encrypt the vault with the password + // We gonna prioritize the password encryption here, in case of the operation is `Change Password`. + // We don't wanna re-use the old encryption key from the state. + if (password) { + assertIsValidPassword(password); + + // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key + // from the password using an intentionally slow key derivation function. + // We should make sure that we only call it very intentionally. + const { vault: updatedEncVault, exportedKeyString } = + await this.#vaultEncryptor.encryptWithDetail( + password, + serializedVaultData, + ); + + updatedState.vault = updatedEncVault; + updatedState.vaultEncryptionKey = exportedKeyString; + updatedState.vaultEncryptionSalt = JSON.parse(updatedEncVault).salt; + + // encrypt the seedless encryption key with the password encryption key from TOPRF network + updatedState.encryptedSeedlessEncryptionKey = + this.#encryptSeedlessEncryptionKey(exportedKeyString, pwEncKey); + } else if (vaultEncryptionKey && vaultEncryptionSalt) { + const encryptionKey = + await this.#vaultEncryptor.importKey(vaultEncryptionKey); + const updatedEncVault = await this.#vaultEncryptor.encryptWithKey( + encryptionKey, serializedVaultData, ); - const aes = managedNonce(gcm)(pwEncKey); - const encryptedKey = aes.encrypt(utf8ToBytes(exportedKeyString)); - const encryptedSeedlessEncryptionKey = bytesToBase64(encryptedKey); + // NOTE: Referenced from keyring-controller! + // We need to include the salt used to derive the encryption key, to be able to derive it from password again. + updatedEncVault.salt = vaultEncryptionSalt; + updatedState.vault = JSON.stringify(updatedEncVault); + updatedState.vaultEncryptionKey = vaultEncryptionKey; + updatedState.vaultEncryptionSalt = vaultEncryptionSalt; + } else { + // neither password nor encryption key is provided + throw new Error( + SeedlessOnboardingControllerErrorMessage.MissingCredentials, + ); + } + + // update the state with the updated vault data this.update((state) => { - state.vault = vault; - state.vaultEncryptionKey = exportedKeyString; - state.vaultEncryptionSalt = JSON.parse(vault).salt; - state.encryptedSeedlessEncryptionKey = encryptedSeedlessEncryptionKey; + state.vault = updatedState.vault; + state.vaultEncryptionKey = updatedState.vaultEncryptionKey; + state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; + state.encryptedSeedlessEncryptionKey = + updatedState.encryptedSeedlessEncryptionKey; }); + + // cache the vault data to avoid decrypting the vault data multiple times + this.#cachedDecryptedVaultData = vaultData; }); } + /** + * Encrypt the seedless encryption key with the password encryption key from TOPRF network. + * + * @param vaultEncryptionKey - The key which is used to encrypt the vault. + * @param pwEncKey - The password encryption key from TOPRF network. + * @returns The encrypted seedless encryption key. + */ + #encryptSeedlessEncryptionKey( + vaultEncryptionKey: string, + pwEncKey: Uint8Array, + ): string { + const aes = managedNonce(gcm)(pwEncKey); + const encryptedKey = aes.encrypt(utf8ToBytes(vaultEncryptionKey)); + return bytesToBase64(encryptedKey); + } + /** * Get the access token and revoke token from the state or the vault. * @@ -2058,8 +2116,19 @@ export class SeedlessOnboardingController< skipLock: true, }); - // update the vault with new access token - await this.#updateVaultAfterAuthTokenRefresh(accessToken); + // update the vault with new access token if wallet is unlocked + if (this.#isUnlocked && this.#cachedDecryptedVaultData) { + const updatedVaultData = { + ...this.#cachedDecryptedVaultData, + accessToken, + }; + const pwEncKey = this.#cachedDecryptedVaultData.toprfPwEncryptionKey; + + await this.#updateVault({ + vaultData: updatedVaultData, + pwEncKey, + }); + } } catch (error) { log('Error refreshing node auth tokens', error); throw new Error( @@ -2293,61 +2362,6 @@ export class SeedlessOnboardingController< } } - async #updateVaultAfterAuthTokenRefresh(accessToken: string): Promise { - await this.#withVaultLock(async () => { - if (!this.#isUnlocked) { - // we just temporarily store the access token in the state - // when user attempts to unlock the vault, we will use this access token to update the vault - this.update((state) => { - state.accessToken = accessToken; - }); - return; - } - - const { vaultEncryptionKey, vaultEncryptionSalt } = this.state; - if ( - !vaultEncryptionKey || - !vaultEncryptionSalt || - !this.#cachedDecryptedVaultData - ) { - throw new Error( - SeedlessOnboardingControllerErrorMessage.MissingCredentials, - ); - } - - const serializedVaultData = serializeVaultData({ - ...this.#cachedDecryptedVaultData, - accessToken, - }); - - const encryptionKey = - await this.#vaultEncryptor.importKey(vaultEncryptionKey); - const updatedEncVault = await this.#vaultEncryptor.encryptWithKey( - encryptionKey, - serializedVaultData, - ); - - // NOTE: Referenced from keyring-controller! - // We need to include the salt used to derive - // the encryption key, to be able to derive it - // from password again. - updatedEncVault.salt = vaultEncryptionSalt; - - this.update((state) => { - state.vault = JSON.stringify(updatedEncVault); - state.vaultEncryptionSalt = vaultEncryptionSalt; - state.accessToken = accessToken; - state.vaultEncryptionKey = vaultEncryptionKey; - }); - - // update the cached decrypted vault data with the new access token - this.#cachedDecryptedVaultData = { - ...this.#cachedDecryptedVaultData, - accessToken, - }; - }); - } - /** * Check if the tokens are expired. * @@ -2426,24 +2440,6 @@ export class SeedlessOnboardingController< } } -/** - * Assert that the provided password is a valid non-empty string. - * - * @param password - The password to check. - * @throws If the password is not a valid string. - */ -function assertIsValidPassword(password: unknown): asserts password is string { - if (typeof password !== 'string') { - throw new Error(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); - } - - if (!password?.length) { - throw new Error( - SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, - ); - } -} - /** * Lock the given mutex before executing the given function, * and release it after the function is resolved or after an diff --git a/packages/seedless-onboarding-controller/src/assertions.test.ts b/packages/seedless-onboarding-controller/src/assertions.test.ts index 7a36a7c8a02..270f53adc2f 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/assertions.test.ts @@ -1,10 +1,47 @@ import { assertIsPasswordOutdatedCacheValid, + assertIsValidPassword, assertIsValidVaultData, } from './assertions'; import { SeedlessOnboardingControllerErrorMessage } from './constants'; import { VaultData } from './types'; +describe('assertIsValidPassword', () => { + it('should throw when password is not a string', () => { + expect(() => { + assertIsValidPassword(null); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + + expect(() => { + assertIsValidPassword(undefined); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + + expect(() => { + assertIsValidPassword(123); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + + expect(() => { + assertIsValidPassword({}); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + + expect(() => { + assertIsValidPassword([]); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + }); + + it('should throw when password is an empty string', () => { + expect(() => { + assertIsValidPassword(''); + }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword); + }); + + it('should not throw for valid non-empty string', () => { + expect(() => { + assertIsValidPassword('password123'); + }).not.toThrow(); + }); +}); + describe('assertIsValidVaultData', () => { /** * Helper function to create valid vault data for testing diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/assertions.ts index e8a35539340..c9929e1a2b4 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -1,6 +1,26 @@ import { SeedlessOnboardingControllerErrorMessage } from './constants'; import type { AuthenticatedUserDetails, VaultData } from './types'; +/** + * Assert that the provided password is a valid non-empty string. + * + * @param password - The password to check. + * @throws If the password is not a valid string. + */ +export function assertIsValidPassword( + password: unknown, +): asserts password is string { + if (typeof password !== 'string') { + throw new Error(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + } + + if (!password?.length) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, + ); + } +} + /** * Check if the provided value is a valid authenticated user. * From f681e86327c81dd4ee4c973ce7492a0ade8c1b61 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 4 Feb 2026 13:11:07 +0800 Subject: [PATCH 15/16] chore: lint --- .../src/SeedlessOnboardingController.ts | 4 ++-- packages/seedless-onboarding-controller/tests/mocks/utils.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 34adc706dcc..0b7f9c54e09 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -6,7 +6,7 @@ import type { StateMetadata, } from '@metamask/base-controller'; import type * as encryptionUtils from '@metamask/browser-passworder'; -import { Messenger } from '@metamask/messenger'; +import type { Messenger } from '@metamask/messenger'; import type { AuthenticateResult, ChangeEncryptionKeyResult, @@ -437,7 +437,7 @@ export class SeedlessOnboardingController< this.#renewRefreshToken = renewRefreshToken; this.messenger.registerActionHandler( - 'SeedlessOnboardingController:getAccessToken', + `${controllerName}:getAccessToken`, this.getAccessToken.bind(this), ); } diff --git a/packages/seedless-onboarding-controller/tests/mocks/utils.ts b/packages/seedless-onboarding-controller/tests/mocks/utils.ts index 7ea8ac61eea..b49f719f42c 100644 --- a/packages/seedless-onboarding-controller/tests/mocks/utils.ts +++ b/packages/seedless-onboarding-controller/tests/mocks/utils.ts @@ -1,4 +1,4 @@ -import { DecodedBaseJWTToken } from '../../src/types'; +import type { DecodedBaseJWTToken } from '../../src/types'; /** * Creates a mock JWT token for testing From 765066629e6fb4805112fc2f9ff13653c81c2c50 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 4 Feb 2026 13:33:24 +0800 Subject: [PATCH 16/16] fix: update password validation --- .../src/SeedlessOnboardingController.test.ts | 2 +- .../src/SeedlessOnboardingController.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 9eb65c9ac7f..7b0aad5eb48 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -3195,7 +3195,7 @@ describe('SeedlessOnboardingController', () => { MOCK_KEYRING_ID, ), ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.MissingCredentials, + SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, ); expect(mockSecretDataAdd.isDone()).toBe(true); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 0b7f9c54e09..5d9bf9d270f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1815,10 +1815,10 @@ export class SeedlessOnboardingController< this.state.encryptedSeedlessEncryptionKey, }; - // if the password is provided, encrypt the vault with the password + // if the password is provided (not undefined), encrypt the vault with the password // We gonna prioritize the password encryption here, in case of the operation is `Change Password`. // We don't wanna re-use the old encryption key from the state. - if (password) { + if (password !== undefined) { assertIsValidPassword(password); // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key