diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 305bd5c110f..af5176d4e34 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - renamed `fetchAllSeedPhrases` method to `fetchAllSecretData` and updated the return value to `Record`. - added new error message, `MissingKeyringId` which will throw if no `keyringId` is provided during seed phrase (Mnemonic) backup. +### Changed + +- Refresh and revoke token handling ([#5917](https://github.com/MetaMask/core/pull/5917)) + - **BREAKING:** `authenticate` need extra `refreshToken` and `revokeToken` params, persist refresh token in state and store revoke token temporarily for user in next step + - `createToprfKeyAndBackupSeedPhrase`, `fetchAllSecretData` store revoke token in vault + - check for token expired in toprf call, refresh token and retry if expired + - `submitPassword` revoke refresh token and replace with new one after password submit to prevent malicious use if refresh token leak in persisted state + ## [1.0.0] ### Added diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 074a0589324..e36d9431d23 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -70,6 +70,8 @@ const authConnectionId = 'seedless-onboarding'; const groupedAuthConnectionId = 'auth-server'; const userId = 'user-test@gmail.com'; const idTokens = ['idToken']; +const refreshToken = 'refreshToken'; +const revokeToken = 'revokeToken'; const MOCK_NODE_AUTH_TOKENS = [ { @@ -111,6 +113,8 @@ type WithControllerCallback = ({ messenger: SeedlessOnboardingControllerMessenger; baseMessenger: Messenger; toprfClient: ToprfSecureBackup; + mockRefreshJWTToken: jest.Mock; + mockRevokeRefreshToken: jest.Mock; }) => Promise | ReturnValue; type WithControllerOptions = Partial< @@ -168,12 +172,26 @@ async function withController( const encryptor = new MockVaultEncryptor(); const { messenger, baseMessenger } = mockSeedlessOnboardingMessenger(); + const mockRefreshJWTToken = jest.fn().mockResolvedValue({ + idTokens: ['newIdToken'], + }); + const mockRevokeRefreshToken = jest.fn().mockResolvedValue({ + newRevokeToken: 'newRevokeToken', + newRefreshToken: 'newRefreshToken', + }); + const controller = new SeedlessOnboardingController({ encryptor, messenger, network: Web3AuthNetwork.Devnet, + refreshJWTToken: mockRefreshJWTToken, + revokeRefreshToken: mockRevokeRefreshToken, ...rest, }); + + // default node auth token not expired for testing + jest.spyOn(controller, 'checkNodeAuthTokenExpired').mockReturnValue(false); + const { toprfClient } = controller; return await fn({ controller, @@ -182,6 +200,8 @@ async function withController( messenger, baseMessenger, toprfClient, + mockRefreshJWTToken, + mockRevokeRefreshToken, }); } @@ -194,6 +214,17 @@ function createMockToprfEncryptor() { return new MockToprfEncryptorDecryptor(); } +/** + * Creates a mock node auth token. + * + * @param params - The parameters for the mock node auth token. + * @param params.exp - The expiration time of the node auth token. + * @returns The mock node auth token. + */ +function createMockNodeAuthToken(params: { exp: number }) { + return btoa(JSON.stringify(params)); +} + /** * Mocks the createLocalKey method of the ToprfSecureBackup instance. * @@ -340,6 +371,7 @@ async function mockCreateToprfKeyAndBackupSeedPhrase( * @param authKeyPair - The authentication key pair. * @param MOCK_PASSWORD - The mock password. * @param authTokens - The authentication tokens. + * @param mockRevokeToken - The revoke token. * * @returns The mock vault data. */ @@ -348,6 +380,7 @@ async function createMockVault( authKeyPair: KeyPair, MOCK_PASSWORD: string, authTokens: NodeAuthTokens, + mockRevokeToken: string = revokeToken, ) { const encryptor = createMockVaultEncryptor(); @@ -358,6 +391,7 @@ async function createMockVault( sk: `0x${authKeyPair.sk.toString(16)}`, pk: bytesToBase64(authKeyPair.pk), }), + revokeToken: mockRevokeToken, }); const { vault: encryptedMockVault, exportedKeyString } = @@ -367,6 +401,7 @@ async function createMockVault( encryptedMockVault, vaultEncryptionKey: exportedKeyString, vaultEncryptionSalt: JSON.parse(encryptedMockVault).salt, + revokeToken: mockRevokeToken, }; } @@ -405,6 +440,7 @@ async function decryptVault(vault: string, password: string) { * * @param options - The options. * @param options.withMockAuthenticatedUser - Whether to skip the authenticate method and use the mock authenticated user. + * @param options.withoutMockRevokeToken - Whether to skip the revokeToken in authenticated user state. * @param options.withMockAuthPubKey - Whether to skip the checkPasswordOutdated method and use the mock authPubKey. * @param options.authPubKey - The mock authPubKey. * @param options.vault - The mock vault data. @@ -415,6 +451,7 @@ async function decryptVault(vault: string, password: string) { */ function getMockInitialControllerState(options?: { withMockAuthenticatedUser?: boolean; + withoutMockRevokeToken?: boolean; withMockAuthPubKey?: boolean; authPubKey?: string; vault?: string; @@ -437,14 +474,15 @@ function getMockInitialControllerState(options?: { } if (options?.withMockAuthenticatedUser) { + state.authConnection = authConnection; state.nodeAuthTokens = MOCK_NODE_AUTH_TOKENS; state.authConnectionId = authConnectionId; state.groupedAuthConnectionId = groupedAuthConnectionId; state.userId = userId; - } - - if (options?.withMockAuthPubKey || options?.authPubKey) { - state.authPubKey = options.authPubKey ?? MOCK_AUTH_PUB_KEY; + state.refreshToken = refreshToken; + if (!options?.withoutMockRevokeToken) { + state.revokeToken = revokeToken; + } } if (options?.withMockAuthPubKey || options?.authPubKey) { @@ -461,10 +499,19 @@ function getMockInitialControllerState(options?: { describe('SeedlessOnboardingController', () => { describe('constructor', () => { it('should be able to instantiate', () => { + const mockRefreshJWTToken = jest.fn().mockResolvedValue({ + idTokens: ['newIdToken'], + }); + const mockRevokeRefreshToken = jest.fn().mockResolvedValue({ + newRevokeToken: 'newRevokeToken', + newRefreshToken: 'newRefreshToken', + }); const { messenger } = mockSeedlessOnboardingMessenger(); const controller = new SeedlessOnboardingController({ messenger, encryptor: getDefaultSeedlessOnboardingVaultEncryptor(), + refreshJWTToken: mockRefreshJWTToken, + revokeRefreshToken: mockRevokeRefreshToken, }); expect(controller).toBeDefined(); expect(controller.state).toStrictEqual( @@ -473,6 +520,13 @@ describe('SeedlessOnboardingController', () => { }); it('should be able to instantiate with an encryptor', () => { + const mockRefreshJWTToken = jest.fn().mockResolvedValue({ + idTokens: ['newIdToken'], + }); + const mockRevokeRefreshToken = jest.fn().mockResolvedValue({ + newRevokeToken: 'newRevokeToken', + newRefreshToken: 'newRefreshToken', + }); const { messenger } = mockSeedlessOnboardingMessenger(); const encryptor = createMockVaultEncryptor(); @@ -481,6 +535,8 @@ describe('SeedlessOnboardingController', () => { new SeedlessOnboardingController({ messenger, encryptor, + refreshJWTToken: mockRefreshJWTToken, + revokeRefreshToken: mockRevokeRefreshToken, }), ).not.toThrow(); }); @@ -536,6 +592,8 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, + revokeToken, }); expect(authResult).toBeDefined(); @@ -566,6 +624,7 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, }); expect(authResult).toBeDefined(); @@ -598,6 +657,8 @@ describe('SeedlessOnboardingController', () => { groupedAuthConnectionId, authConnection, socialLoginEmail, + refreshToken, + revokeToken, }); expect(authResult).toBeDefined(); @@ -642,6 +703,8 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, + revokeToken, }), ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.AuthenticationError, @@ -821,6 +884,39 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should throw error if revokeToken is missing when creating new vault', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + withoutMockRevokeToken: true, + }), + }, + async ({ controller, toprfClient }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + + await expect( + controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidRevokeToken, + ); + + // Verify that persistLocalKey was called + expect(toprfClient.persistLocalKey).toHaveBeenCalledTimes(1); + }, + ); + }); + it('should be able to create a seed phrase backup without groupedAuthConnectionId', async () => { await withController( async ({ controller, toprfClient, encryptor, initialState }) => { @@ -835,6 +931,8 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, + revokeToken, }); const { encKey, authKeyPair } = mockcreateLocalKey( @@ -928,6 +1026,30 @@ describe('SeedlessOnboardingController', () => { }); }); + it('should throw error if authenticated user but refreshToken is missing', async () => { + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + refreshToken: undefined, + }, + }, + async ({ controller }) => { + await expect( + controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidRefreshToken, + ); + }, + ); + }); + it('should throw an error if user does not have the AuthToken', async () => { await withController( { state: { userId, authConnectionId, groupedAuthConnectionId } }, @@ -1693,6 +1815,8 @@ describe('SeedlessOnboardingController', () => { nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, userId, authConnectionId, + refreshToken, + revokeToken, }, }, async ({ controller, toprfClient, initialState, encryptor }) => { @@ -2280,6 +2404,8 @@ describe('SeedlessOnboardingController', () => { userId, authConnectionId, authPubKey: MOCK_AUTH_PUB_KEY, + refreshToken, + revokeToken, }, }, async ({ controller, toprfClient }) => { @@ -3095,6 +3221,7 @@ describe('SeedlessOnboardingController', () => { sk: bigIntToHex(newAuthKeyPair.sk), pk: bytesToBase64(newAuthKeyPair.pk), }), + revokeToken: controller.state.revokeToken, }); expect(encryptorSpy).toHaveBeenCalledWith( GLOBAL_PASSWORD, @@ -3243,4 +3370,926 @@ describe('SeedlessOnboardingController', () => { ); }); }); + + describe('token refresh functionality', () => { + const MOCK_PASSWORD = 'mock-password'; + const NEW_MOCK_PASSWORD = 'new-mock-password'; + + describe('checkNodeAuthTokenExpired with token refresh', () => { + it('should return true if the node auth token is expired', async () => { + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + nodeAuthTokens: [ + { + authToken: createMockNodeAuthToken({ + exp: Date.now() / 1000 - 1000, + }), + nodeIndex: 0, + nodePubKey: 'mock-node-pub-key', + }, + ], + }, + }, + async ({ controller }) => { + const isExpired = controller.checkNodeAuthTokenExpired(); + expect(isExpired).toBe(false); + }, + ); + }); + + it('should return false if the node auth token is not expired', async () => { + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + nodeAuthTokens: [ + { + authToken: createMockNodeAuthToken({ + exp: Date.now() / 1000 + 1000, + }), + nodeIndex: 0, + nodePubKey: 'mock-node-pub-key', + }, + ], + }, + }, + async ({ controller }) => { + const isExpired = controller.checkNodeAuthTokenExpired(); + expect(isExpired).toBe(false); + }, + ); + }); + }); + + describe('checkIsPasswordOutdated with token refresh', () => { + it('should retry checkIsPasswordOutdated after refreshing expired tokens', async () => { + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS.map((v) => ({ + ...v, + authToken: createMockNodeAuthToken({ + exp: Date.now() / 1000 - 1000, + }), + })), + }, + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + jest.spyOn(controller, 'checkNodeAuthTokenExpired').mockRestore(); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.checkIsPasswordOutdated(); + + expect(mockRefreshJWTToken).toHaveBeenCalled(); + }, + ); + }); + }); + + describe('changePassword with token refresh', () => { + it('should retry changePassword after refreshing expired tokens', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + // Mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // Mock changeEncKey to fail first with token expired error, then succeed + const mockToprfEncryptor = createMockToprfEncryptor(); + const newEncKey = + mockToprfEncryptor.deriveEncKey(NEW_MOCK_PASSWORD); + const newAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(NEW_MOCK_PASSWORD); + + jest + .spyOn(toprfClient, 'changeEncKey') + .mockImplementationOnce(() => { + // Mock the recover enc key for second time + mockRecoverEncKey(toprfClient, NEW_MOCK_PASSWORD); + + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce({ + encKey: newEncKey, + authKeyPair: newAuthKeyPair, + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD); + + // Verify that getNewRefreshToken was called + expect(mockRefreshJWTToken).toHaveBeenCalledWith({ + connection: controller.state.authConnection, + refreshToken, + }); + + // Verify that changeEncKey was called twice (once failed, once succeeded) + expect(toprfClient.changeEncKey).toHaveBeenCalledTimes(2); + + // Verify that authenticate was called during token refresh + expect(toprfClient.authenticate).toHaveBeenCalled(); + }, + ); + }); + + it('should fail if token refresh fails during changePassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + // Mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // Mock changeEncKey to always fail with token expired error + jest + .spyOn(toprfClient, 'changeEncKey') + .mockImplementationOnce(() => { + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }); + + // Mock getNewRefreshToken to fail + mockRefreshJWTToken.mockRejectedValueOnce( + new Error('Failed to get new refresh token'), + ); + + await expect( + controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, + ); + + // Verify that getNewRefreshToken was called + expect(mockRefreshJWTToken).toHaveBeenCalled(); + }, + ); + }); + + it('should not retry on non-token-related errors during changePassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + // Mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // Mock changeEncKey to fail with a non-token error + jest + .spyOn(toprfClient, 'changeEncKey') + .mockRejectedValue(new Error('Some other error')); + + await expect( + controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, + ); + + // Verify that getNewRefreshToken was NOT called + expect(mockRefreshJWTToken).not.toHaveBeenCalled(); + + // Verify that changeEncKey was only called once (no retry) + expect(toprfClient.changeEncKey).toHaveBeenCalledTimes(1); + }, + ); + }); + }); + + describe('syncLatestGlobalPassword with token refresh', () => { + const OLD_PASSWORD = 'old-mock-password'; + const GLOBAL_PASSWORD = 'new-global-password'; + let MOCK_VAULT: string; + let MOCK_VAULT_ENCRYPTION_KEY: string; + let MOCK_VAULT_ENCRYPTION_SALT: string; + let INITIAL_AUTH_PUB_KEY: string; + let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation + let initialEncKey: Uint8Array; // Store initial encKey for vault creation + + // Generate initial keys and vault state before tests run + beforeAll(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + initialEncKey = mockToprfEncryptor.deriveEncKey(OLD_PASSWORD); + initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(OLD_PASSWORD); + INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); + + const mockResult = await createMockVault( + initialEncKey, + initialAuthKeyPair, + OLD_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should retry syncLatestGlobalPassword after refreshing expired tokens', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ + controller, + toprfClient, + encryptor, + mockRefreshJWTToken, + }) => { + // Unlock controller first + await controller.submitPassword(OLD_PASSWORD); + + const verifyPasswordSpy = jest.spyOn( + controller, + 'verifyVaultPassword', + ); + 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 newAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + + // Mock recoverEncKey to fail first with token expired error, then succeed + recoverEncKeySpy + .mockImplementationOnce(() => { + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce({ + encKey: newEncKey, + authKeyPair: newAuthKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.syncLatestGlobalPassword({ + oldPassword: OLD_PASSWORD, + globalPassword: GLOBAL_PASSWORD, + }); + + // Verify that getNewRefreshToken was called + expect(mockRefreshJWTToken).toHaveBeenCalledWith({ + connection: controller.state.authConnection, + refreshToken: 'newRefreshToken', + }); + + // Verify that recoverEncKey was called twice (once failed, once succeeded) + expect(recoverEncKeySpy).toHaveBeenCalledTimes(2); + + // Verify that authenticate was called during token refresh + expect(toprfClient.authenticate).toHaveBeenCalled(); + + // Verify that verifyPassword was called + expect(verifyPasswordSpy).toHaveBeenCalledWith(OLD_PASSWORD, { + skipLock: true, + }); + + // Check if vault was re-encrypted with the new password and keys + const expectedSerializedVaultData = JSON.stringify({ + authTokens: controller.state.nodeAuthTokens, + toprfEncryptionKey: bytesToBase64(newEncKey), + toprfAuthKeyPair: JSON.stringify({ + sk: bigIntToHex(newAuthKeyPair.sk), + pk: bytesToBase64(newAuthKeyPair.pk), + }), + revokeToken: controller.state.revokeToken, + }); + expect(encryptorSpy).toHaveBeenCalledWith( + GLOBAL_PASSWORD, + expectedSerializedVaultData, + ); + + // Check if authPubKey was updated in state + expect(controller.state.authPubKey).toBe( + bytesToBase64(newAuthKeyPair.pk), + ); + }, + ); + }); + + it('should fail if token refresh fails during syncLatestGlobalPassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + // Unlock controller first + await controller.submitPassword(OLD_PASSWORD); + + // Mock recoverEncKey to fail with token expired error + jest + .spyOn(toprfClient, 'recoverEncKey') + .mockImplementationOnce(() => { + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }); + + // Mock getNewRefreshToken to fail + mockRefreshJWTToken.mockRejectedValueOnce( + new Error('Failed to get new refresh token'), + ); + + await expect( + controller.syncLatestGlobalPassword({ + oldPassword: OLD_PASSWORD, + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + + // Verify that getNewRefreshToken was called + expect(mockRefreshJWTToken).toHaveBeenCalled(); + }, + ); + }); + + it('should not retry on non-token-related errors during syncLatestGlobalPassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + // Unlock controller first + await controller.submitPassword(OLD_PASSWORD); + + // Mock recoverEncKey to fail with a non-token error + jest + .spyOn(toprfClient, 'recoverEncKey') + .mockRejectedValue(new Error('Some other error')); + + await expect( + controller.syncLatestGlobalPassword({ + oldPassword: OLD_PASSWORD, + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.LoginFailedError, + ); + + // Verify that getNewRefreshToken was NOT called + expect(mockRefreshJWTToken).not.toHaveBeenCalled(); + + // Verify that recoverEncKey was only called once (no retry) + expect(toprfClient.recoverEncKey).toHaveBeenCalledTimes(1); + }, + ); + }); + }); + + describe('addNewSeedPhraseBackup with token refresh', () => { + const NEW_KEY_RING = { + id: 'new-keyring-1', + seedPhrase: stringToBytes('new mock seed phrase 1'), + }; + + it('should retry addNewSeedPhraseBackup after refreshing expired tokens', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + const { encryptedMockVault, vaultEncryptionKey, vaultEncryptionSalt } = + await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: encryptedMockVault, + vaultEncryptionKey, + vaultEncryptionSalt, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + await controller.submitPassword(MOCK_PASSWORD); + + jest + .spyOn(toprfClient, 'addSecretDataItem') + .mockImplementationOnce(() => { + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce(); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.addNewSecretData( + NEW_KEY_RING.seedPhrase, + SecretType.Mnemonic, + { + keyringId: NEW_KEY_RING.id, + }, + ); + + // Verify that getNewRefreshToken was called + expect(mockRefreshJWTToken).toHaveBeenCalled(); + + // Verify that addSecretDataItem was called twice + expect(toprfClient.addSecretDataItem).toHaveBeenCalledTimes(2); + }, + ); + }); + }); + + describe('fetchAllSeedPhrases with token refresh', () => { + it('should retry fetchAllSeedPhrases after refreshing expired tokens', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // Mock recoverEncKey + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + jest + .spyOn(toprfClient, 'fetchAllSecretDataItems') + .mockImplementationOnce(() => { + // Mock the recover enc key for second time + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce([]); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.submitPassword(MOCK_PASSWORD); + + const result = await controller.fetchAllSecretData(MOCK_PASSWORD); + + expect(result).toStrictEqual({ mnemonic: [], privateKey: [] }); + expect(mockRefreshJWTToken).toHaveBeenCalled(); + expect(toprfClient.fetchAllSecretDataItems).toHaveBeenCalledTimes( + 2, + ); + }, + ); + }); + }); + + describe('createToprfKeyAndBackupSeedPhrase with token refresh', () => { + it('should retry createToprfKeyAndBackupSeedPhrase after refreshing expired tokens', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + await controller.submitPassword(MOCK_PASSWORD); + // Mock createLocalKey + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // Mock addSecretDataItem + jest + .spyOn(toprfClient, 'addSecretDataItem') + .mockImplementationOnce(() => { + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce(); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValueOnce({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + expect(mockRefreshJWTToken).toHaveBeenCalled(); + expect(toprfClient.persistLocalKey).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('should retry createToprfKeyAndBackupSeedPhrase after refreshing expired tokens in persistOprfKey', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + await controller.submitPassword(MOCK_PASSWORD); + // Mock createLocalKey + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // Mock addSecretDataItem + jest.spyOn(toprfClient, 'addSecretDataItem').mockResolvedValue(); + + // persist the local enc key + jest + .spyOn(toprfClient, 'persistLocalKey') + .mockImplementationOnce(() => { + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce(); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValueOnce({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + expect(mockRefreshJWTToken).toHaveBeenCalled(); + expect(toprfClient.persistLocalKey).toHaveBeenCalledTimes(3); + }, + ); + }); + }); + + describe('recoverCurrentDevicePassword with token refresh', () => { + // const OLD_PASSWORD = 'old-mock-password'; + // const GLOBAL_PASSWORD = 'new-global-password'; + let MOCK_VAULT: string; + let MOCK_VAULT_ENCRYPTION_KEY: string; + let MOCK_VAULT_ENCRYPTION_SALT: string; + let INITIAL_AUTH_PUB_KEY: string; + let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation + let initialEncKey: Uint8Array; // Store initial encKey for vault creation + + // Generate initial keys and vault state before tests run + beforeAll(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + initialEncKey = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + initialAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); + + const mockResult = await createMockVault( + initialEncKey, + initialAuthKeyPair, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should retry recoverCurrentDevicePassword after refreshing expired tokens', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + await controller.submitPassword(MOCK_PASSWORD); + + // Mock recoverEncKey + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + // second call after refresh token + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // Mock recoverPassword + jest + .spyOn(toprfClient, 'recoverPassword') + .mockImplementationOnce(() => { + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce({ + password: MOCK_PASSWORD, + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValueOnce({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.recoverCurrentDevicePassword({ + globalPassword: MOCK_PASSWORD, + }); + + expect(mockRefreshJWTToken).toHaveBeenCalled(); + expect(toprfClient.recoverPassword).toHaveBeenCalledTimes(2); + }, + ); + }); + }); + + describe('refreshNodeAuthTokens', () => { + it('should successfully refresh node auth tokens', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + const { encryptedMockVault, vaultEncryptionKey, vaultEncryptionSalt } = + await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: encryptedMockVault, + vaultEncryptionKey, + vaultEncryptionSalt, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + await controller.submitPassword(MOCK_PASSWORD); + + // 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', + }, + ], + isNewUser: false, + }); + + await controller.refreshNodeAuthTokens(); + + expect(mockRefreshJWTToken).toHaveBeenCalledWith({ + connection: controller.state.authConnection, + refreshToken: 'newRefreshToken', + }); + + expect(toprfClient.authenticate).toHaveBeenCalledWith({ + authConnectionId: controller.state.authConnectionId, + userId: controller.state.userId, + idTokens: ['newIdToken'], + groupedAuthConnectionId: controller.state.groupedAuthConnectionId, + }); + }, + ); + }); + + it('should throw error if controller not authenticated', async () => { + await withController(async ({ controller }) => { + await expect(controller.refreshNodeAuthTokens()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo, + ); + }); + }); + + it('should throw error when token refresh fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, mockRefreshJWTToken }) => { + // Mock token refresh to fail + mockRefreshJWTToken.mockRejectedValueOnce( + new Error('Refresh failed'), + ); + + // Call refreshNodeAuthTokens and expect it to throw + await expect(controller.refreshNodeAuthTokens()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + + expect(mockRefreshJWTToken).toHaveBeenCalledTimes(1); + expect(mockRefreshJWTToken).toHaveBeenCalledWith({ + connection: controller.state.authConnection, + refreshToken: controller.state.refreshToken, + }); + }, + ); + }); + + it('should throw error when re-authentication fails after token refresh', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, mockRefreshJWTToken, toprfClient }) => { + // Mock token refresh to succeed + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['new-token'], + }); + + // Mock authenticate to fail + jest + .spyOn(toprfClient, 'authenticate') + .mockRejectedValueOnce(new Error('Authentication failed')); + + // Call refreshNodeAuthTokens and expect it to throw + await expect(controller.refreshNodeAuthTokens()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + + expect(mockRefreshJWTToken).toHaveBeenCalledTimes(1); + expect(toprfClient.authenticate).toHaveBeenCalledTimes(1); + }, + ); + }); + }); + }); }); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index b33cfadc010..a97aa4cfb19 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -7,13 +7,17 @@ import type { RecoverEncryptionKeyResult, SEC1EncodedPublicKey, } from '@metamask/toprf-secure-backup'; -import { ToprfSecureBackup } from '@metamask/toprf-secure-backup'; +import { + ToprfSecureBackup, + TOPRFErrorCode, + TOPRFError, +} from '@metamask/toprf-secure-backup'; import { base64ToBytes, bytesToBase64, bigIntToHex } from '@metamask/utils'; import { secp256k1 } from '@noble/curves/secp256k1'; import { Mutex } from 'async-mutex'; +import type { AuthConnection } from './constants'; import { - type AuthConnection, controllerName, PASSWORD_OUTDATED_CACHE_TTL_MS, SecretType, @@ -33,6 +37,9 @@ import type { SocialBackupsMetadata, SRPBackedUpUserDetails, VaultEncryptor, + RefreshJWTToken, + RevokeRefreshToken, + DecodedNodeAuthToken, } from './types'; const log = createModuleLogger(projectLogger, controllerName); @@ -109,6 +116,14 @@ const seedlessOnboardingMetadata: StateMetadata extends BaseController< @@ -124,6 +139,10 @@ export class SeedlessOnboardingController extends BaseController< readonly toprfClient: ToprfSecureBackup; + readonly #refreshJWTToken: RefreshJWTToken; + + readonly #revokeRefreshToken: RevokeRefreshToken; + /** * Controller lock state. * @@ -140,6 +159,8 @@ export class SeedlessOnboardingController extends BaseController< * @param options.encryptor - An optional encryptor to use for encrypting and decrypting seedless onboarding vault. * @param options.toprfKeyDeriver - An optional key derivation interface for the TOPRF client. * @param options.network - The network to be used for the Seedless Onboarding flow. + * @param options.refreshJWTToken - A function to get a new jwt token using refresh token. + * @param options.revokeRefreshToken - A function to revoke the refresh token. */ constructor({ messenger, @@ -147,6 +168,8 @@ export class SeedlessOnboardingController extends BaseController< encryptor, toprfKeyDeriver, network = Web3AuthNetwork.Mainnet, + refreshJWTToken, + revokeRefreshToken, }: SeedlessOnboardingControllerOptions) { super({ name: controllerName, @@ -163,6 +186,8 @@ export class SeedlessOnboardingController extends BaseController< network, keyDeriver: toprfKeyDeriver, }); + this.#refreshJWTToken = refreshJWTToken; + this.#revokeRefreshToken = revokeRefreshToken; // setup subscriptions to the keyring lock event // when the keyring is locked (wallet is locked), the controller will be cleared of its credentials @@ -185,7 +210,9 @@ export class SeedlessOnboardingController extends BaseController< * @param params.userId - user email or id from Social login * @param params.groupedAuthConnectionId - Optional grouped authConnectionId to be used for the authenticate request. * @param params.socialLoginEmail - The user email from Social login. - * You can pass this to use aggregate multiple OAuth connections. Useful when you want user to have same account while using different OAuth connections. + * @param params.refreshToken - refresh token for refreshing expired nodeAuthTokens. + * @param params.revokeToken - revoke token for revoking refresh token and get new refresh token and new revoke token. + * @param params.skipLock - Optional flag to skip acquiring the controller lock. (to prevent deadlock in case the caller already acquired the lock) * @returns A promise that resolves to the authentication result. */ async authenticate(params: { @@ -195,8 +222,11 @@ export class SeedlessOnboardingController extends BaseController< userId: string; groupedAuthConnectionId?: string; socialLoginEmail?: string; + refreshToken?: string; + revokeToken?: string; + skipLock?: boolean; }) { - return await this.#withControllerLock(async () => { + const doAuthenticateWithNodes = async () => { try { const { idTokens, @@ -205,6 +235,8 @@ export class SeedlessOnboardingController extends BaseController< userId, authConnection, socialLoginEmail, + refreshToken, + revokeToken, } = params; const authenticationResult = await this.toprfClient.authenticate({ @@ -221,7 +253,15 @@ export class SeedlessOnboardingController extends BaseController< state.userId = userId; state.authConnection = authConnection; state.socialLoginEmail = socialLoginEmail; + if (refreshToken) { + state.refreshToken = refreshToken; + } + if (revokeToken) { + // Temporarily store revoke token in state for later vault creation + state.revokeToken = revokeToken; + } }); + return authenticationResult; } catch (error) { log('Error authenticating user', error); @@ -229,7 +269,10 @@ export class SeedlessOnboardingController extends BaseController< SeedlessOnboardingControllerErrorMessage.AuthenticationError, ); } - }); + }; + return params.skipLock + ? await doAuthenticateWithNodes() + : await this.#withControllerLock(doAuthenticateWithNodes); } /** @@ -245,42 +288,48 @@ export class SeedlessOnboardingController extends BaseController< seedPhrase: Uint8Array, keyringId: string, ): Promise { - // to make sure that fail fast, - // assert that the user is authenticated before creating the TOPRF key and backing up the seed phrase - this.#assertIsAuthenticatedUser(this.state); - return await this.#withControllerLock(async () => { + // to make sure that fail fast, + // assert that the user is authenticated before creating the TOPRF key and backing up the seed phrase + this.#assertIsAuthenticatedUser(this.state); + // locally evaluate the encryption key from the password const { encKey, authKeyPair, oprfKey } = await this.toprfClient.createLocalKey({ password, }); + const performKeyCreationAndBackup = async (): Promise => { + // encrypt and store the seed phrase backup + await this.#encryptAndStoreSecretData({ + data: seedPhrase, + type: SecretType.Mnemonic, + encKey, + authKeyPair, + options: { + keyringId, + }, + }); - // encrypt and store the seed phrase backup - await this.#encryptAndStoreSecretData({ - data: seedPhrase, - type: SecretType.Mnemonic, - encKey, - authKeyPair, - options: { - keyringId, - }, - }); + // store/persist the encryption key shares + // We store the seed phrase metadata in the metadata store first. If this operation fails, + // we avoid persisting the encryption key shares to prevent a situation where a user appears + // to have an account but with no associated data. + await this.#persistOprfKey(oprfKey, authKeyPair.pk); + // create a new vault with the resulting authentication data + await this.#createNewVaultWithAuthData({ + password, + rawToprfEncryptionKey: encKey, + rawToprfAuthKeyPair: authKeyPair, + }); + this.#persistAuthPubKey({ + authPubKey: authKeyPair.pk, + }); + }; - // store/persist the encryption key shares - // We store the seed phrase metadata in the metadata store first. If this operation fails, - // we avoid persisting the encryption key shares to prevent a situation where a user appears - // to have an account but with no associated data. - await this.#persistOprfKey(oprfKey, authKeyPair.pk); - // create a new vault with the resulting authentication data - await this.#createNewVaultWithAuthData({ - password, - rawToprfEncryptionKey: encKey, - rawToprfAuthKeyPair: authKeyPair, - }); - this.#persistAuthPubKey({ - authPubKey: authKeyPair.pk, - }); + await this.#executeWithTokenRefresh( + performKeyCreationAndBackup, + 'createToprfKeyAndBackupSeedPhrase', + ); }); } @@ -308,18 +357,25 @@ export class SeedlessOnboardingController extends BaseController< skipLock: true, // skip lock since we already have the lock }); - // verify the password and unlock the vault - const { toprfEncryptionKey, toprfAuthKeyPair } = - await this.#unlockVaultAndGetBackupEncKey(); - - // encrypt and store the seed phrase backup - await this.#encryptAndStoreSecretData({ - data, - type, - encKey: toprfEncryptionKey, - authKeyPair: toprfAuthKeyPair, - options, - }); + const performBackup = async (): Promise => { + // verify the password and unlock the vault + const { toprfEncryptionKey, toprfAuthKeyPair } = + await this.#unlockVaultAndGetBackupEncKey(); + + // encrypt and store the seed phrase backup + await this.#encryptAndStoreSecretData({ + data, + type, + encKey: toprfEncryptionKey, + authKeyPair: toprfAuthKeyPair, + options, + }); + }; + + await this.#executeWithTokenRefresh( + performBackup, + 'addNewSeedPhraseBackup', + ); }); } @@ -334,10 +390,10 @@ export class SeedlessOnboardingController extends BaseController< async fetchAllSecretData( password?: string, ): Promise> { - // assert that the user is authenticated before fetching the seed phrases - this.#assertIsAuthenticatedUser(this.state); - return await this.#withControllerLock(async () => { + // assert that the user is authenticated before fetching the seed phrases + this.#assertIsAuthenticatedUser(this.state); + let encKey: Uint8Array; let authKeyPair: KeyPair; @@ -354,36 +410,45 @@ export class SeedlessOnboardingController extends BaseController< } try { - const secretData = await this.toprfClient.fetchAllSecretDataItems({ - decKey: encKey, - authKeyPair, - }); - - if (secretData?.length > 0 && password) { - // if password is provided, we need to create a new vault with the auth data. (supposedly the user is trying to rehydrate the wallet) - await this.#createNewVaultWithAuthData({ - password, - rawToprfEncryptionKey: encKey, - rawToprfAuthKeyPair: authKeyPair, + const performFetch = async (): Promise< + Record + > => { + const secretData = await this.toprfClient.fetchAllSecretDataItems({ + decKey: encKey, + authKeyPair, }); - this.#persistAuthPubKey({ - authPubKey: authKeyPair.pk, + if (secretData?.length > 0 && password) { + // if password is provided, we need to create a new vault with the auth data. (supposedly the user is trying to rehydrate the wallet) + await this.#createNewVaultWithAuthData({ + password, + rawToprfEncryptionKey: encKey, + rawToprfAuthKeyPair: authKeyPair, + }); + + this.#persistAuthPubKey({ + authPubKey: authKeyPair.pk, + }); + } + + const result: Record = { + mnemonic: [], + privateKey: [], + }; + const secrets = + SecretMetadata.parseSecretsFromMetadataStore(secretData); + + secrets.forEach((secret) => { + result[secret.type].push(secret.data); }); - } - const result: Record = { - mnemonic: [], - privateKey: [], + return result; }; - const secrets = - SecretMetadata.parseSecretsFromMetadataStore(secretData); - - secrets.forEach((secret) => { - result[secret.type].push(secret.data); - }); - return result; + return await this.#executeWithTokenRefresh( + performFetch, + 'fetchAllSecretData', + ); } catch (error) { log('Error fetching seed phrase metadata', error); throw new Error( @@ -414,7 +479,7 @@ export class SeedlessOnboardingController extends BaseController< skipLock: true, // skip lock since we already have the lock }); - try { + const attemptChangePassword = async (): Promise => { // update the encryption key with new password and update the Metadata Store const { encKey: newEncKey, authKeyPair: newAuthKeyPair } = await this.#changeEncryptionKey(newPassword, oldPassword); @@ -430,6 +495,13 @@ export class SeedlessOnboardingController extends BaseController< authPubKey: newAuthKeyPair.pk, }); this.#resetPasswordOutdatedCache(); + }; + + try { + await this.#executeWithTokenRefresh( + attemptChangePassword, + 'changePassword', + ); } catch (error) { log('Error changing password', error); throw new Error( @@ -467,7 +539,7 @@ export class SeedlessOnboardingController extends BaseController< * * @param password - The password to verify. * @param options - Optional options object. - * @param options.skipLock - Whether to skip the lock acquisition. + * @param options.skipLock - Whether to skip the lock acquisition. (to prevent deadlock in case the caller already acquired the lock) * @returns A promise that resolves to the success of the operation. * @throws {Error} If the password is invalid or the vault is not initialized. */ @@ -520,6 +592,8 @@ export class SeedlessOnboardingController extends BaseController< return await this.#withControllerLock(async () => { await this.#unlockVaultAndGetBackupEncKey(password); this.#setUnlocked(); + // revoke and recyle refresh token after unlock to keep refresh token fresh, avoid malicious use of leaked refresh token + await this.revokeRefreshToken(password); }); } @@ -532,6 +606,7 @@ export class SeedlessOnboardingController extends BaseController< this.update((state) => { delete state.vaultEncryptionKey; delete state.vaultEncryptionSalt; + delete state.revokeToken; }); this.#isUnlocked = false; @@ -555,23 +630,30 @@ export class SeedlessOnboardingController extends BaseController< globalPassword: string; }) { return await this.#withControllerLock(async () => { - // verify correct old password - await this.verifyVaultPassword(oldPassword, { - skipLock: true, // skip lock since we already have the lock - }); - // update vault with latest globalPassword - const { encKey, authKeyPair } = await this.#recoverEncKey(globalPassword); - // update and encrypt the vault with new password - await this.#createNewVaultWithAuthData({ - password: globalPassword, - rawToprfEncryptionKey: encKey, - rawToprfAuthKeyPair: authKeyPair, - }); - // persist the latest global password authPubKey - this.#persistAuthPubKey({ - authPubKey: authKeyPair.pk, - }); - this.#resetPasswordOutdatedCache(); + const doSyncPassword = async () => { + // verify correct old password + await this.verifyVaultPassword(oldPassword, { + skipLock: true, // skip lock since we already have the lock + }); + // update vault with latest globalPassword + const { encKey, authKeyPair } = + await this.#recoverEncKey(globalPassword); + // update and encrypt the vault with new password + await this.#createNewVaultWithAuthData({ + password: globalPassword, + rawToprfEncryptionKey: encKey, + rawToprfAuthKeyPair: authKeyPair, + }); + // persist the latest global password authPubKey + this.#persistAuthPubKey({ + authPubKey: authKeyPair.pk, + }); + this.#resetPasswordOutdatedCache(); + }; + return await this.#executeWithTokenRefresh( + doSyncPassword, + 'syncLatestGlobalPassword', + ); }); } @@ -589,14 +671,18 @@ export class SeedlessOnboardingController extends BaseController< globalPassword: string; }): Promise<{ password: string }> { return await this.#withControllerLock(async () => { - const currentDeviceAuthPubKey = this.#recoverAuthPubKey(); - const { password: currentDevicePassword } = await this.#recoverPassword({ - targetPwPubKey: currentDeviceAuthPubKey, - globalPassword, - }); - return { - password: currentDevicePassword, - }; + return await this.#executeWithTokenRefresh(async () => { + const currentDeviceAuthPubKey = this.#recoverAuthPubKey(); + const { password: currentDevicePassword } = await this.#recoverPassword( + { + targetPwPubKey: currentDeviceAuthPubKey, + globalPassword, + }, + ); + return { + password: currentDevicePassword, + }; + }, 'recoverCurrentDevicePassword'); }); } @@ -626,6 +712,9 @@ export class SeedlessOnboardingController extends BaseController< }); return res; } catch (error) { + if (this.#isTokenExpiredError(error)) { + throw error; + } throw PasswordSyncError.getInstance(error); } } @@ -635,29 +724,31 @@ export class SeedlessOnboardingController extends BaseController< * * @param options - Optional options object. * @param options.skipCache - If true, bypass the cache and force a fresh check. - * @param options.skipLock - Whether to skip the lock acquisition. + * @param options.skipLock - Whether to skip the lock acquisition. (to prevent deadlock in case the caller already acquired the lock) * @returns A promise that resolves to true if the password is outdated, false otherwise. */ async checkIsPasswordOutdated(options?: { skipCache?: boolean; skipLock?: boolean; }): Promise { - // cache result to reduce load on infra - // Check cache first unless skipCache is true - if (!options?.skipCache) { - const { passwordOutdatedCache } = this.state; - const now = Date.now(); - const isCacheValid = - passwordOutdatedCache && - now - passwordOutdatedCache.timestamp < PASSWORD_OUTDATED_CACHE_TTL_MS; - - if (isCacheValid) { - return passwordOutdatedCache.isExpiredPwd; + const doCheckIsPasswordExpired = async () => { + this.#assertIsAuthenticatedUser(this.state); + + // cache result to reduce load on infra + // Check cache first unless skipCache is true + if (!options?.skipCache) { + const { passwordOutdatedCache } = this.state; + const now = Date.now(); + const isCacheValid = + passwordOutdatedCache && + now - passwordOutdatedCache.timestamp < + PASSWORD_OUTDATED_CACHE_TTL_MS; + + if (isCacheValid) { + return passwordOutdatedCache.isExpiredPwd; + } } - } - const doCheck = async () => { - this.#assertIsAuthenticatedUser(this.state); const { nodeAuthTokens, authConnectionId, @@ -686,9 +777,13 @@ export class SeedlessOnboardingController extends BaseController< return isExpiredPwd; }; - return options?.skipLock - ? await doCheck() - : await this.#withControllerLock(doCheck); + return await this.#executeWithTokenRefresh( + async () => + options?.skipLock + ? await doCheckIsPasswordExpired() + : await this.#withControllerLock(doCheckIsPasswordExpired), + 'checkIsPasswordOutdated', + ); } #setUnlocked(): void { @@ -726,6 +821,9 @@ export class SeedlessOnboardingController extends BaseController< authPubKey, }); } catch (error) { + if (this.#isTokenExpiredError(error)) { + throw error; + } log('Error persisting local encryption key', error); throw new Error( SeedlessOnboardingControllerErrorMessage.FailedToPersistOprfKey, @@ -871,6 +969,9 @@ export class SeedlessOnboardingController extends BaseController< authKeyPair, }); } catch (error) { + if (this.#isTokenExpiredError(error)) { + throw error; + } log('Error encrypting and storing seed phrase backup', error); throw new Error( SeedlessOnboardingControllerErrorMessage.FailedToEncryptAndStoreSeedPhraseBackup, @@ -897,6 +998,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; + revokeToken: string; }> { return this.#withVaultLock(async () => { const { @@ -954,17 +1056,27 @@ export class SeedlessOnboardingController extends BaseController< updatedState.vaultEncryptionSalt = vaultEncryptionSalt; } - const { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair } = - this.#parseVaultData(decryptedVaultData); + const { + nodeAuthTokens, + toprfEncryptionKey, + toprfAuthKeyPair, + revokeToken, + } = this.#parseVaultData(decryptedVaultData); // update the state with the restored nodeAuthTokens this.update((state) => { state.nodeAuthTokens = nodeAuthTokens; state.vaultEncryptionKey = updatedState.vaultEncryptionKey; state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; + state.revokeToken = revokeToken; }); - return { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair }; + return { + nodeAuthTokens, + toprfEncryptionKey, + toprfAuthKeyPair, + revokeToken, + }; }); } @@ -1078,6 +1190,13 @@ export class SeedlessOnboardingController extends BaseController< rawToprfAuthKeyPair: KeyPair; }): Promise { this.#assertIsAuthenticatedUser(this.state); + + if (!this.state.revokeToken) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidRevokeToken, + ); + } + this.#setUnlocked(); const { toprfEncryptionKey, toprfAuthKeyPair } = this.#serializeKeyData( @@ -1089,6 +1208,7 @@ export class SeedlessOnboardingController extends BaseController< authTokens: this.state.nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair, + revokeToken: this.state.revokeToken, }); await this.#updateVault({ @@ -1204,6 +1324,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; + revokeToken: string; } { if (typeof data !== 'string') { throw new Error( @@ -1235,6 +1356,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: parsedVaultData.authTokens, toprfEncryptionKey: rawToprfEncryptionKey, toprfAuthKeyPair: rawToprfAuthKeyPair, + revokeToken: parsedVaultData.revokeToken, }; } @@ -1284,6 +1406,12 @@ export class SeedlessOnboardingController extends BaseController< SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, ); } + + if (!('refreshToken' in value) || typeof value.refreshToken !== 'string') { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidRefreshToken, + ); + } } #assertIsSRPBackedUpUser( @@ -1320,6 +1448,11 @@ export class SeedlessOnboardingController extends BaseController< return result; } catch (error) { + // throw token expired error for token refresh handler + if (this.#isTokenExpiredError(error)) { + throw error; + } + const recoveryError = RecoveryError.getInstance(error, { numberOfAttempts: updatedRecoveryAttempts, remainingTime: updatedRemainingTime, @@ -1349,7 +1482,7 @@ export class SeedlessOnboardingController extends BaseController< * * @param options - The options for asserting the password is in sync. * @param options.skipCache - Whether to skip the cache check. - * @param options.skipLock - Whether to skip the lock acquisition. + * @param options.skipLock - Whether to skip the lock acquisition. (to prevent deadlock in case the caller already acquired the lock) * @throws If the password is outdated. */ async #assertPasswordInSync(options?: { @@ -1386,11 +1519,175 @@ export class SeedlessOnboardingController extends BaseController< !('toprfEncryptionKey' in value) || // toprfEncryptionKey is not defined typeof value.toprfEncryptionKey !== 'string' || // toprfEncryptionKey is not a string !('toprfAuthKeyPair' in value) || // toprfAuthKeyPair is not defined - typeof value.toprfAuthKeyPair !== 'string' // toprfAuthKeyPair is not a string + typeof value.toprfAuthKeyPair !== 'string' || // toprfAuthKeyPair is not a string + !('revokeToken' in value) || // revokeToken is not defined + typeof value.revokeToken !== 'string' // revokeToken is not a string ) { throw new Error(SeedlessOnboardingControllerErrorMessage.VaultDataError); } } + + /** + * Refresh expired nodeAuthTokens using the stored refresh token. + * + * This method retrieves the refresh token from the vault and uses it to obtain + * new nodeAuthTokens when the current ones have expired. + * + * @returns A promise that resolves to the new nodeAuthTokens. + */ + async refreshNodeAuthTokens(): Promise { + this.#assertIsAuthenticatedUser(this.state); + const { refreshToken } = this.state; + + try { + const res = await this.#refreshJWTToken({ + connection: this.state.authConnection, + refreshToken, + }); + const { idTokens } = res; + // re-authenticate with the new id tokens to set new node auth tokens + await this.authenticate({ + idTokens, + authConnection: this.state.authConnection, + authConnectionId: this.state.authConnectionId, + groupedAuthConnectionId: this.state.groupedAuthConnectionId, + userId: this.state.userId, + skipLock: true, + }); + } catch (error) { + log('Error refreshing node auth tokens', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + } + } + + /** + * Revoke the refresh token and get new refresh token and new revoke token. + * This method is to be called after unlock + * + * @param password - The password to re-encrypt new token in the vault. + */ + async revokeRefreshToken(password: string) { + this.#assertIsUnlocked(); + this.#assertIsAuthenticatedUser(this.state); + // get revoke token and backup encryption key from vault (should be unlocked already) + const { revokeToken, toprfEncryptionKey, toprfAuthKeyPair } = + await this.#unlockVaultAndGetBackupEncKey(); + + const { newRevokeToken, newRefreshToken } = await this.#revokeRefreshToken({ + connection: this.state.authConnection, + revokeToken, + }); + + this.update((state) => { + // set new revoke token in state temporarily for persisting in vault + state.revokeToken = newRevokeToken; + // set new refresh token to persist in state + state.refreshToken = newRefreshToken; + }); + + await this.#createNewVaultWithAuthData({ + password, + rawToprfEncryptionKey: toprfEncryptionKey, + rawToprfAuthKeyPair: toprfAuthKeyPair, + }); + } + + /** + * Check if the provided error is a token expiration error. + * + * This method checks if the error is a TOPRF error with AuthTokenExpired code. + * + * @param error - The error to check. + * @returns True if the error indicates token expiration, false otherwise. + */ + #isTokenExpiredError(error: unknown): boolean { + if (error instanceof TOPRFError) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + return error.code === TOPRFErrorCode.AuthTokenExpired; + } + + return false; + } + + /** + * Executes an operation with automatic token refresh on expiration. + * + * This wrapper method automatically handles token expiration by refreshing tokens + * and retrying the operation. It can be used by any method that might encounter + * token expiration errors. + * + * @param operation - The operation to execute that might require valid tokens. + * @param operationName - A descriptive name for the operation (used in error messages). + * @returns A promise that resolves to the result of the operation. + * @throws The original error if it's not token-related, or refresh error if token refresh fails. + */ + async #executeWithTokenRefresh( + operation: () => Promise, + operationName: string, + ): Promise { + try { + // proactively check for expired tokens and refresh them if needed + const isNodeAuthTokenExpired = this.checkNodeAuthTokenExpired(); + if (isNodeAuthTokenExpired) { + log( + `JWT token expired during ${operationName}, attempting to refresh tokens`, + 'node auth token exp check', + ); + await this.refreshNodeAuthTokens(); + } + + return await operation(); + } catch (error) { + // Check if this is a token expiration error + if (this.#isTokenExpiredError(error)) { + log( + `Token expired during ${operationName}, attempting to refresh tokens`, + error, + ); + try { + // Refresh the tokens + await this.refreshNodeAuthTokens(); + // Retry the operation with fresh tokens + return await operation(); + } catch (refreshError) { + log(`Error refreshing tokens during ${operationName}`, refreshError); + throw refreshError; + } + } else { + // Re-throw non-token-related errors + throw error; + } + } + } + + /** + * Check if the current node auth token is expired. + * + * @returns True if the current node auth token is expired, false otherwise. + */ + public checkNodeAuthTokenExpired(): boolean { + this.#assertIsAuthenticatedUser(this.state); + + const { nodeAuthTokens } = this.state; + // all auth tokens should be expired at the same time so we can check the first one + const firstAuthToken = nodeAuthTokens[0]?.authToken; + // node auth token is base64 encoded json object + const decodedToken = this.decodeNodeAuthToken(firstAuthToken); + // check if the token is expired + return decodedToken.exp < Date.now() / 1000; + } + + /** + * Decode the node auth token from base64 to json object. + * + * @param token - The node auth token to decode. + * @returns The decoded node auth token. + */ + decodeNodeAuthToken(token: string): DecodedNodeAuthToken { + return JSON.parse(Buffer.from(token, 'base64').toString()); + } } /** diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 1fa5fc1914f..ffe43483a91 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -31,6 +31,8 @@ export enum SeedlessOnboardingControllerErrorMessage { FailedToPersistOprfKey = `${controllerName} - Failed to persist OPRF key`, LoginFailedError = `${controllerName} - Login failed`, InsufficientAuthToken = `${controllerName} - Insufficient auth token`, + InvalidRefreshToken = `${controllerName} - Invalid refresh token`, + InvalidRevokeToken = `${controllerName} - Invalid revoke token`, MissingCredentials = `${controllerName} - Cannot unlock vault without password and encryption key`, ExpiredCredentials = `${controllerName} - Encryption key and salt provided are expired`, InvalidEmptyPassword = `${controllerName} - Password cannot be empty.`, diff --git a/packages/seedless-onboarding-controller/src/errors.test.ts b/packages/seedless-onboarding-controller/src/errors.test.ts new file mode 100644 index 00000000000..0011a44c87f --- /dev/null +++ b/packages/seedless-onboarding-controller/src/errors.test.ts @@ -0,0 +1,51 @@ +import { TOPRFErrorCode } from '@metamask/toprf-secure-backup'; + +import { SeedlessOnboardingControllerErrorMessage } from './constants'; +import { getErrorMessageFromTOPRFErrorCode } from './errors'; + +describe('getErrorMessageFromTOPRFErrorCode', () => { + it('returns TooManyLoginAttempts for RateLimitExceeded', () => { + expect( + getErrorMessageFromTOPRFErrorCode( + TOPRFErrorCode.RateLimitExceeded, + 'default', + ), + ).toBe(SeedlessOnboardingControllerErrorMessage.TooManyLoginAttempts); + }); + + it('returns IncorrectPassword for CouldNotDeriveEncryptionKey', () => { + expect( + getErrorMessageFromTOPRFErrorCode( + TOPRFErrorCode.CouldNotDeriveEncryptionKey, + 'default', + ), + ).toBe(SeedlessOnboardingControllerErrorMessage.IncorrectPassword); + }); + + it('returns CouldNotRecoverPassword for CouldNotFetchPassword', () => { + expect( + getErrorMessageFromTOPRFErrorCode( + TOPRFErrorCode.CouldNotFetchPassword, + 'default', + ), + ).toBe(SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword); + }); + + it('returns InsufficientAuthToken for AuthTokenExpired', () => { + expect( + getErrorMessageFromTOPRFErrorCode( + TOPRFErrorCode.AuthTokenExpired, + 'default', + ), + ).toBe(SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken); + }); + + it('returns defaultMessage for unknown code', () => { + expect( + getErrorMessageFromTOPRFErrorCode( + 9999 as unknown as TOPRFErrorCode, + 'fallback', + ), + ).toBe('fallback'); + }); +}); diff --git a/packages/seedless-onboarding-controller/src/errors.ts b/packages/seedless-onboarding-controller/src/errors.ts index 2eb5a84c3f5..ddb713ce5fe 100644 --- a/packages/seedless-onboarding-controller/src/errors.ts +++ b/packages/seedless-onboarding-controller/src/errors.ts @@ -25,6 +25,8 @@ function getErrorMessageFromTOPRFErrorCode( return SeedlessOnboardingControllerErrorMessage.IncorrectPassword; case TOPRFErrorCode.CouldNotFetchPassword: return SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword; + case TOPRFErrorCode.AuthTokenExpired: + return SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken; default: return defaultMessage; } @@ -153,3 +155,5 @@ export class RecoveryError extends Error { return new RecoveryError(errorMessage, recoveryErrorData); } } + +export { getErrorMessageFromTOPRFErrorCode }; diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 5bff5bc7f91..99264d6b5dc 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -54,6 +54,11 @@ export type AuthenticatedUserDetails = { * The user email from Social login. */ socialLoginEmail: string; + + /** + * The refresh token used to refresh expired nodeAuthTokens. + */ + refreshToken: string; }; export type SRPBackedUpUserDetails = { @@ -119,6 +124,18 @@ export type SeedlessOnboardingControllerState = * And it also helps to synchronize the recovery error data across multiple devices. */ recoveryRatelimitCache?: RecoveryErrorData; + + /** + * The refresh token used to refresh expired nodeAuthTokens. + * This is persisted in state. + */ + refreshToken?: string; + + /** + * The revoke token used to revoke refresh token and get new refresh token and new revoke token. + * This is temporarily stored in state during authentication and then persisted in the vault. + */ + revokeToken?: string; }; // Actions @@ -182,6 +199,16 @@ export type ToprfKeyDeriver = { deriveKey: (seed: Uint8Array, salt: Uint8Array) => Promise; }; +export type RefreshJWTToken = (params: { + connection: AuthConnection; + refreshToken: string; +}) => Promise<{ idTokens: string[] }>; + +export type RevokeRefreshToken = (params: { + connection: AuthConnection; + revokeToken: string; +}) => Promise<{ newRevokeToken: string; newRefreshToken: string }>; + /** * Seedless Onboarding Controller Options. * @@ -204,6 +231,17 @@ export type SeedlessOnboardingControllerOptions = { */ encryptor: VaultEncryptor; + /** + * A function to get a new jwt token using refresh token. + */ + refreshJWTToken: RefreshJWTToken; + + /** + * A function to revoke the refresh token. + * And get new refresh token and revoke token. + */ + revokeRefreshToken: RevokeRefreshToken; + /** * Optional key derivation interface for the TOPRF client. * @@ -252,6 +290,10 @@ export type VaultData = { * The authentication key pair to authenticate the TOPRF. */ toprfAuthKeyPair: string; + /** + * The revoke token to revoke refresh token and get new refresh token and new revoke token. + */ + revokeToken: string; }; export type SecretDataType = Uint8Array | string | number; @@ -273,3 +315,17 @@ export type SecretMetadataOptions = { */ version: SecretMetadataVersion; }; + +export type DecodedNodeAuthToken = { + /** + * The expiration time of the token in seconds. + */ + exp: number; + temp_key_x: string; + temp_key_y: string; + aud: string; + verifier_name: string; + verifier_id: string; + scope: string; + signature: string; +};