diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 5293235b901..1dbe159ad47 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ### Changed - Update StateMetadata's `includeInStateLogs` property. ([#7750](https://github.com/MetaMask/core/pull/7750)) @@ -14,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 diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index f6f1b1f9fdc..7b0aad5eb48 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 { @@ -48,10 +49,10 @@ import { SecretMetadata } from './SecretMetadata'; import { getInitialSeedlessOnboardingControllerStateWithDefaults, SeedlessOnboardingController, -} from './SeedlessOnboardingController'; -import type { SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, +} from './SeedlessOnboardingController'; +import type { SeedlessOnboardingControllerState, VaultEncryptor, } from './types'; @@ -71,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; @@ -171,6 +173,7 @@ function getDefaultSeedlessOnboardingVaultEncryptor(): VaultEncryptor< exportKey: exportKeyBrowserPassworder, generateSalt: generateSaltBrowserPassworder, keyFromPassword: keyFromPasswordBrowserPassworder, + encryptWithKey: encryptWithKeyBrowserPassworder, }; } @@ -3895,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; @@ -3903,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); @@ -3931,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); @@ -3957,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, @@ -4037,6 +4041,110 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should persist the latest accessToken when state token is newer than vault token', async () => { + 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( + { + 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( { @@ -4098,13 +4206,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, @@ -4156,13 +4257,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({ @@ -5058,22 +5152,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 +5192,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,19 +5276,139 @@ 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, + ); + }, + ); + }); }); }); 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({ @@ -5244,7 +5452,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, @@ -5268,15 +5476,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( { @@ -5299,7 +5501,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( { @@ -5352,15 +5554,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( { @@ -5381,7 +5577,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( { @@ -5447,6 +5643,168 @@ 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', 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 accessToken is not set', async () => { + await withController( + { + state: { + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + metadataAccessToken, + userId, + authConnectionId, + groupedAuthConnectionId, + authConnection, + refreshToken, + }, + }, + async ({ controller }) => { + const result = await controller.getAccessToken(); + expect(result).toBeUndefined(); + }, + ); + }); + + 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({ + withMockAuthenticatedUser: true, + withoutMockAccessToken: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + jest + .spyOn(controller, 'checkNodeAuthTokenExpired') + .mockReturnValueOnce(true); + jest + .spyOn(controller, 'authenticate') + .mockRejectedValueOnce( + new Error( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ), + ); + + await expect(controller.getAccessToken()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + }, + ); + }); + + 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..5d9bf9d270f 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 type { Messenger } from '@metamask/messenger'; import type { AuthenticateResult, ChangeEncryptionKeyResult, @@ -28,6 +33,7 @@ import { Mutex } from 'async-mutex'; import { assertIsPasswordOutdatedCacheValid, assertIsSeedlessOnboardingUserAuthenticated, + assertIsValidPassword, assertIsValidVaultData, } from './assertions'; import type { AuthConnection } from './constants'; @@ -43,8 +49,6 @@ import { projectLogger, createModuleLogger } from './logger'; import { SecretMetadata } from './SecretMetadata'; import type { MutuallyExclusiveCallback, - SeedlessOnboardingControllerMessenger, - SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerState, AuthenticatedUserDetails, SocialBackupsMetadata, @@ -54,8 +58,10 @@ import type { RenewRefreshToken, VaultData, DeserializedVaultData, + ToprfKeyDeriver, } from './types'; import { + compareAndGetLatestToken, decodeJWTToken, decodeNodeAuthToken, deserializeVaultData, @@ -64,6 +70,117 @@ import { const log = createModuleLogger(projectLogger, controllerName); +// Actions +export type SeedlessOnboardingControllerGetStateAction = + ControllerGetStateAction< + 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< + 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. * @@ -318,6 +435,11 @@ export class SeedlessOnboardingController< this.#refreshJWTToken = refreshJWTToken; this.#revokeRefreshToken = revokeRefreshToken; this.#renewRefreshToken = renewRefreshToken; + + this.messenger.registerActionHandler( + `${controllerName}:getAccessToken`, + this.getAccessToken.bind(this), + ); } async fetchMetadataAccessCreds(): Promise<{ @@ -735,7 +857,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 @@ -745,7 +867,36 @@ 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 accessTokenFromDecryptedVault = deserializedVaultData.accessToken; + + // 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, + }; + + await this.#updateVault({ + password, + vaultData: updatedVaultData, + pwEncKey: deserializedVaultData.toprfPwEncryptionKey, + }); + } + this.#setUnlocked(); }); } @@ -856,25 +1007,36 @@ 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(); + + // 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; @@ -991,6 +1153,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. */ @@ -1593,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. @@ -1603,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 (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 !== undefined) { + 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, ); - // Encrypt vault key. - const aes = managedNonce(gcm)(pwEncKey); - const encryptedKey = aes.encrypt(utf8ToBytes(exportedKeyString)); + // 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 = bytesToBase64(encryptedKey); + 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. * @@ -1863,6 +2115,20 @@ export class SeedlessOnboardingController< refreshToken, skipLock: true, }); + + // 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( @@ -1970,6 +2236,23 @@ 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 () => { + this.#assertIsAuthenticatedUser(this.state); + + return this.#executeWithTokenRefresh(async () => { + return this.state.accessToken; + }, 'getAccessToken'); + }); + } + /** * Add a pending refresh, revoke token to the state to be revoked later. * @@ -2047,22 +2330,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 +2362,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. * @@ -2149,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. * 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..b1713f04882 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,42 +177,11 @@ 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. */ -export type VaultEncryptor = Omit< - Encryptor, - 'encryptWithKey' ->; +export type VaultEncryptor = + Encryptor; /** * Additional key deriver for the TOPRF client. @@ -263,73 +225,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/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils.test.ts index 497c8e8c896..5f3c4fda48c 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils.test.ts @@ -1,8 +1,13 @@ import { bytesToBase64 } from '@metamask/utils'; import { utf8ToBytes } from '@noble/ciphers/utils'; -import type { DecodedNodeAuthToken, DecodedBaseJWTToken } from './types'; -import { decodeNodeAuthToken, decodeJWTToken } from './utils'; +import type { DecodedNodeAuthToken } from './types'; +import { + decodeNodeAuthToken, + decodeJWTToken, + compareAndGetLatestToken, +} from './utils'; +import { createMockJWTToken } from '../tests/mocks/utils'; describe('utils', () => { describe('decodeNodeAuthToken', () => { @@ -75,34 +80,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 +152,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; +} 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; 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..b49f719f42c --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/mocks/utils.ts @@ -0,0 +1,27 @@ +import type { 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}`; +}