diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt index 11f9924a9cf..ab85a43a938 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.sdk import com.bitwarden.auth.JitMasterPasswordRegistrationResponse import com.bitwarden.auth.KeyConnectorRegistrationResult +import com.bitwarden.auth.PasswordPreloginResponse import com.bitwarden.auth.TdeRegistrationResponse import com.bitwarden.auth.UserMasterPasswordRegistrationResponse import com.bitwarden.core.AuthRequestResponse @@ -21,6 +22,11 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength */ @Suppress("TooManyFunctions") interface AuthSdkSource { + /** + * Performs the pre-login request for the given email address. + */ + suspend fun preLogin(email: String): Result + /** * Enrolls the user to master password unlock. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt index 8d9bd776d07..43ad6f472bd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.datasource.sdk import com.bitwarden.auth.JitMasterPasswordRegistrationRequest import com.bitwarden.auth.JitMasterPasswordRegistrationResponse import com.bitwarden.auth.KeyConnectorRegistrationResult +import com.bitwarden.auth.PasswordPreloginResponse import com.bitwarden.auth.TdeRegistrationRequest import com.bitwarden.auth.TdeRegistrationResponse import com.bitwarden.auth.UserMasterPasswordRegistrationRequest @@ -38,6 +39,14 @@ class AuthSdkSourceImpl( ) : BaseSdkSource(sdkClientManager = sdkClientManager), AuthSdkSource { + override suspend fun preLogin( + email: String, + ): Result = runCatchingWithLogs { + withContext(context = dispatcherManager.io) { + useClient { auth().login().getPasswordPrelogin(email = email) } + } + } + override suspend fun postKeysForJitPasswordRegistration( userId: String, organizationId: String, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index cf5c64b55b7..de31d296cb7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.auth.repository +import com.bitwarden.auth.PasswordPreloginResponse import com.bitwarden.core.AuthRequestMethod import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.MasterPasswordUnlockData @@ -529,13 +530,18 @@ internal class AuthRepositoryImpl( override suspend fun login( email: String, password: String, - ): LoginResult = identityService - .preLogin(email = email) + ): LoginResult = if (featureFlagManager.getFeatureFlag(key = FlagKey.SdkPreLogin)) { + authSdkSource.preLogin(email = email) + } else { + identityService + .preLogin(email = email) + .map { PasswordPreloginResponse(salt = email, kdf = it.kdfParams.toSdkParams()) } + } .flatMap { authSdkSource.hashPassword( - salt = email, + salt = it.salt, password = password, - kdf = it.kdfParams.toSdkParams(), + kdf = it.kdf, purpose = HashPurpose.SERVER_AUTHORIZATION, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 6f080c69cd7..627a8086f14 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -446,7 +446,7 @@ class FakeAuthDiskSource : AuthDiskSource { /** * Assert that the given [userState] matches the currently tracked value. */ - fun assertUserState(userState: UserStateJson) { + fun assertUserState(userState: UserStateJson?) { assertEquals(userState, this.userState) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt index a1b8e8f2bc5..f45fc44e01c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.datasource.sdk import com.bitwarden.auth.JitMasterPasswordRegistrationRequest import com.bitwarden.auth.JitMasterPasswordRegistrationResponse import com.bitwarden.auth.KeyConnectorRegistrationResult +import com.bitwarden.auth.PasswordPreloginResponse import com.bitwarden.auth.TdeRegistrationRequest import com.bitwarden.auth.TdeRegistrationResponse import com.bitwarden.auth.UserMasterPasswordRegistrationRequest @@ -14,6 +15,7 @@ import com.bitwarden.core.MasterPasswordPolicyOptions import com.bitwarden.core.RegisterKeyResponse import com.bitwarden.core.RegisterTdeKeyResponse import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager +import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess import com.bitwarden.crypto.HashPurpose import com.bitwarden.crypto.Kdf @@ -22,6 +24,7 @@ import com.bitwarden.policies.PolicyType import com.bitwarden.policies.PolicyView import com.bitwarden.sdk.AuthClient import com.bitwarden.sdk.Client +import com.bitwarden.sdk.LoginClient import com.bitwarden.sdk.PlatformClient import com.bitwarden.sdk.PoliciesClient import com.bitwarden.sdk.RegistrationClient @@ -40,12 +43,14 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class AuthSdkSourceTest { + private val clientLogin = mockk() private val clientRegistration = mockk() private val clientAuth = mockk { every { registration() } returns clientRegistration + every { login() } returns clientLogin } private val clientPlatform = mockk { - coEvery { loadFlags(any()) } just runs + coEvery { loadFlags(flags = any()) } just runs } private val clientPolicies = mockk() private val client = mockk { @@ -63,6 +68,38 @@ class AuthSdkSourceTest { sdkClientManager = sdkClientManager, ) + @Test + fun `preLogin should call SDK and return a Result with correct data`() = runBlocking { + val email = "email@example.com" + val expectedResult = mockk() + val slot = slot PasswordPreloginResponse>() + coEvery { + sdkClientManager.singleUseClient(block = capture(slot)) + } coAnswers { slot.captured(client) } + coEvery { clientLogin.getPasswordPrelogin(email = email) } returns expectedResult + + val result = authSkdSource.preLogin(email = email) + + assertEquals(expectedResult.asSuccess(), result) + coVerify(exactly = 1) { clientLogin.getPasswordPrelogin(email = email) } + } + + @Test + fun `preLogin should return a failure when the SDK throws`() = runBlocking { + val email = "email@example.com" + val error = RuntimeException("Fail") + val slot = slot PasswordPreloginResponse>() + coEvery { + sdkClientManager.singleUseClient(block = capture(slot)) + } coAnswers { slot.captured(client) } + coEvery { clientLogin.getPasswordPrelogin(email = email) } throws error + + val result = authSkdSource.preLogin(email = email) + + assertEquals(error.asFailure(), result) + coVerify(exactly = 1) { clientLogin.getPasswordPrelogin(email = email) } + } + @Suppress("MaxLineLength") @Test fun `postKeysForJitPasswordRegistration should call SDK and return a Result with correct data`() = diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 6a9b8c811dc..b057c8dc17a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository import app.cash.turbine.test import com.bitwarden.auth.JitMasterPasswordRegistrationResponse +import com.bitwarden.auth.PasswordPreloginResponse import com.bitwarden.auth.TdeRegistrationResponse import com.bitwarden.auth.UserMasterPasswordRegistrationResponse import com.bitwarden.core.AuthRequestMethod @@ -200,6 +201,7 @@ class AuthRepositoryTest { } private val authSdkSource = mockk { coEvery { getNewAuthRequest(email = EMAIL) } returns AUTH_REQUEST_RESPONSE.asSuccess() + coEvery { preLogin(email = EMAIL) } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { hashPassword( salt = EMAIL, @@ -210,10 +212,10 @@ class AuthRepositoryTest { } returns PASSWORD_HASH.asSuccess() coEvery { hashPassword( - salt = EMAIL, + salt = PRE_LOGIN_SDK_SUCCESS.salt, password = PASSWORD, - kdf = ACCOUNT_1.profile.toSdkParams(), - purpose = HashPurpose.LOCAL_AUTHORIZATION, + kdf = PRE_LOGIN_SDK_SUCCESS.kdf, + purpose = HashPurpose.SERVER_AUTHORIZATION, ) } returns PASSWORD_HASH.asSuccess() coEvery { @@ -282,6 +284,7 @@ class AuthRepositoryTest { every { getFeatureFlag(FlagKey.V2EncryptionJitPassword) } returns true every { getFeatureFlag(FlagKey.V2EncryptionTde) } returns true every { getFeatureFlag(FlagKey.V2EncryptionPassword) } returns true + every { getFeatureFlag(FlagKey.SdkPreLogin) } returns true } private val authStateManager: AuthStateManager = mockk() private val organizationManager: OrganizationManager = mockk() @@ -1689,22 +1692,109 @@ class AuthRepositoryTest { @Test fun `login when pre login fails should return Error with no message`() = runTest { val error = RuntimeException() - coEvery { - identityService.preLogin(email = EMAIL) - } returns error.asFailure() + coEvery { authSdkSource.preLogin(email = EMAIL) } returns error.asFailure() val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.Error(error = error), result) - coVerify { identityService.preLogin(email = EMAIL) } + coVerify { authSdkSource.preLogin(email = EMAIL) } } + @Test + fun `login should hash the password with the salt and KDF returned by the SDK pre login`() = + runTest { + val salt = "salt@bitwarden.com" + val kdf = Kdf.Argon2id(iterations = 3u, memory = 64u, parallelism = 4u) + coEvery { + authSdkSource.preLogin(email = EMAIL) + } returns PasswordPreloginResponse(salt = salt, kdf = kdf).asSuccess() + coEvery { + authSdkSource.hashPassword( + salt = salt, + password = PASSWORD, + kdf = kdf, + purpose = HashPurpose.SERVER_AUTHORIZATION, + ) + } returns PASSWORD_HASH.asSuccess() + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.MasterPassword( + username = EMAIL, + password = PASSWORD_HASH, + ), + uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, + ) + } returns RuntimeException().asFailure() + + repository.login(email = EMAIL, password = PASSWORD) + + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) + authSdkSource.hashPassword( + salt = salt, + password = PASSWORD, + kdf = kdf, + purpose = HashPurpose.SERVER_AUTHORIZATION, + ) + } + } + + @Test + fun `login when the SdkPreLogin flag is disabled should pre login with the identity service`() = + runTest { + every { featureFlagManager.getFeatureFlag(FlagKey.SdkPreLogin) } returns false + coEvery { + identityService.preLogin(email = EMAIL) + } returns PRE_LOGIN_SUCCESS.asSuccess() + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.MasterPassword( + username = EMAIL, + password = PASSWORD_HASH, + ), + uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, + ) + } returns RuntimeException().asFailure() + + repository.login(email = EMAIL, password = PASSWORD) + + coVerify(exactly = 1) { + identityService.preLogin(email = EMAIL) + authSdkSource.hashPassword( + salt = EMAIL, + password = PASSWORD, + kdf = PRE_LOGIN_SUCCESS.kdfParams.toSdkParams(), + purpose = HashPurpose.SERVER_AUTHORIZATION, + ) + } + coVerify(exactly = 0) { authSdkSource.preLogin(email = EMAIL) } + } + + @Suppress("MaxLineLength") + @Test + fun `login when the SdkPreLogin flag is disabled and pre login fails should return Error with no message`() = + runTest { + val error = RuntimeException() + every { featureFlagManager.getFeatureFlag(FlagKey.SdkPreLogin) } returns false + coEvery { identityService.preLogin(email = EMAIL) } returns error.asFailure() + + val result = repository.login(email = EMAIL, password = PASSWORD) + + assertEquals(LoginResult.Error(error = error), result) + coVerify(exactly = 1) { identityService.preLogin(email = EMAIL) } + coVerify(exactly = 0) { authSdkSource.preLogin(email = EMAIL) } + } + @Suppress("MaxLineLength") @Test fun `login get token fails should return Error with no message when server is an official Bitwarden server`() = runTest { val error = RuntimeException() coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -1718,8 +1808,8 @@ class AuthRepositoryTest { } returns error.asFailure() val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.Error(error = error), result) - coVerify { identityService.preLogin(email = EMAIL) } - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -1738,8 +1828,8 @@ class AuthRepositoryTest { runTest { configDiskSource.serverConfig = SERVER_CONFIG_UNOFFICIAL coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -1753,7 +1843,7 @@ class AuthRepositoryTest { } returns RuntimeException().asFailure() val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.UnofficialServerError, result) - coVerify { identityService.preLogin(email = EMAIL) } + coVerify(exactly = 1) { authSdkSource.preLogin(email = EMAIL) } } @Suppress("MaxLineLength") @@ -1761,8 +1851,8 @@ class AuthRepositoryTest { fun `login get token fails should return CertificateError when SSLHandshakeException is thrown`() = runTest { coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -1776,25 +1866,25 @@ class AuthRepositoryTest { } returns SSLHandshakeException("error").asFailure() val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.CertificateError, result) - coVerify { identityService.preLogin(email = EMAIL) } + coVerify(exactly = 1) { authSdkSource.preLogin(email = EMAIL) } } @Test fun `prelogin fails should return CertificateError when SSLHandshakeException is thrown`() = runTest { coEvery { - identityService.preLogin(email = EMAIL) + authSdkSource.preLogin(email = EMAIL) } returns SSLHandshakeException("error").asFailure() val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.CertificateError, result) - coVerify { identityService.preLogin(email = EMAIL) } + coVerify(exactly = 1) { authSdkSource.preLogin(email = EMAIL) } } @Test fun `login get token returns Invalid should return Error with correct message`() = runTest { coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -1815,8 +1905,8 @@ class AuthRepositoryTest { val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.Error(errorMessage = "mock_error_message", error = null), result) - coVerify { identityService.preLogin(email = EMAIL) } - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -1834,8 +1924,8 @@ class AuthRepositoryTest { fun `login get token should return InvalidType NewDeviceVerification when message is new device verification needed`() = runTest { coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -1867,8 +1957,8 @@ class AuthRepositoryTest { runTest { val successResponse = GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -1903,12 +1993,12 @@ class AuthRepositoryTest { } returns SINGLE_USER_STATE_1 val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.Success, result) - coVerify { identityService.preLogin(email = EMAIL) } fakeAuthDiskSource.assertAccountCryptographicState( userId = USER_ID_1, accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2, ) - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -1932,10 +2022,7 @@ class AuthRepositoryTest { vaultRepository.syncIfNecessary() settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1) } - assertEquals( - SINGLE_USER_STATE_1, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -1966,8 +2053,8 @@ class AuthRepositoryTest { ) } returns false coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2027,8 +2114,8 @@ class AuthRepositoryTest { ) } returns true coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2079,8 +2166,8 @@ class AuthRepositoryTest { fun `login get token succeeds without master password policy options should not check the password against any policy`() = runTest { coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2182,8 +2269,8 @@ class AuthRepositoryTest { privateKey = "mockWrappedPrivateKey-1", ) coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2218,12 +2305,12 @@ class AuthRepositoryTest { } returns SINGLE_USER_STATE_1 val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.Success, result) - coVerify { identityService.preLogin(email = EMAIL) } fakeAuthDiskSource.assertAccountCryptographicState( userId = USER_ID_1, accountCryptographicState = accountCryptographicState, ) - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -2247,10 +2334,7 @@ class AuthRepositoryTest { vaultRepository.syncIfNecessary() settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1) } - assertEquals( - SINGLE_USER_STATE_1, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -2264,8 +2348,8 @@ class AuthRepositoryTest { val expectedErrorMessage = "crypto key failure" coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2307,7 +2391,6 @@ class AuthRepositoryTest { LoginResult.Error(errorMessage = expectedErrorMessage, error = error), result, ) - coVerify { identityService.preLogin(email = EMAIL) } fakeAuthDiskSource.assertAccountCryptographicState( userId = USER_ID_1, accountCryptographicState = null, @@ -2317,6 +2400,7 @@ class AuthRepositoryTest { passwordHash = null, ) coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -2346,10 +2430,7 @@ class AuthRepositoryTest { settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1) } - assertEquals( - null, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = null) } @Test @@ -2367,8 +2448,8 @@ class AuthRepositoryTest { ), ) coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2393,8 +2474,8 @@ class AuthRepositoryTest { password = PASSWORD, ) assertEquals(LoginResult.Success, result) - coVerify { identityService.preLogin(email = EMAIL) } - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -2407,10 +2488,7 @@ class AuthRepositoryTest { vaultRepository.syncIfNecessary() settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1) } - assertEquals( - SINGLE_USER_STATE_1, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -2438,8 +2516,8 @@ class AuthRepositoryTest { // Set up login for User 1 val successResponse = GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2488,12 +2566,12 @@ class AuthRepositoryTest { val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.Success, result) - coVerify { identityService.preLogin(email = EMAIL) } fakeAuthDiskSource.assertAccountCryptographicState( userId = USER_ID_1, accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2, ) - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -2517,10 +2595,7 @@ class AuthRepositoryTest { vaultRepository.syncIfNecessary() settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1) } - assertEquals( - MULTI_USER_STATE, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = MULTI_USER_STATE) assertFalse(repository.hasPendingAccountAddition) verify(exactly = 1) { userStateManager.hasPendingAccountAddition @@ -2531,7 +2606,7 @@ class AuthRepositoryTest { @Test fun `login get token returns two factor request should return TwoFactorRequired`() = runTest { - coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess() + coEvery { authSdkSource.preLogin(email = EMAIL) } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2559,8 +2634,8 @@ class AuthRepositoryTest { ssoToken = null, ), ) - coVerify { identityService.preLogin(email = EMAIL) } - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -2577,7 +2652,7 @@ class AuthRepositoryTest { fun `login two factor with remember saves two factor auth token`() = runTest { // Attempt a normal login with a two factor error first, so that the auth // data will be cached. - coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess() + coEvery { authSdkSource.preLogin(email = EMAIL) } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2597,8 +2672,8 @@ class AuthRepositoryTest { .asSuccess() val firstResult = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.TwoFactorRequired, firstResult) - coVerify { identityService.preLogin(email = EMAIL) } - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -2675,7 +2750,7 @@ class AuthRepositoryTest { ) // Attempt a normal login with a two factor error first, so that the auth // data will be cached. - coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess() + coEvery { authSdkSource.preLogin(email = EMAIL) } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2694,8 +2769,8 @@ class AuthRepositoryTest { ) assertEquals(LoginResult.TwoFactorRequired, firstResult) - coVerify { identityService.preLogin(email = EMAIL) } - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -2773,8 +2848,8 @@ class AuthRepositoryTest { ) val successResponse = GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2810,12 +2885,12 @@ class AuthRepositoryTest { } returns SINGLE_USER_STATE_1 val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.Success, result) - coVerify { identityService.preLogin(email = EMAIL) } fakeAuthDiskSource.assertAccountCryptographicState( userId = USER_ID_1, accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2, ) - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -2839,10 +2914,7 @@ class AuthRepositoryTest { ) vaultRepository.syncIfNecessary() } - assertEquals( - SINGLE_USER_STATE_1, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -2868,7 +2940,7 @@ class AuthRepositoryTest { @Suppress("MaxLineLength") fun `login get token returns invalid request should return EncryptionKeyMigrationRequired`() = runTest { - coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess() + coEvery { authSdkSource.preLogin(email = EMAIL) } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -2890,8 +2962,8 @@ class AuthRepositoryTest { val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.EncryptionKeyMigrationRequired, result) - coVerify { - identityService.preLogin(email = EMAIL) + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -3073,10 +3145,7 @@ class AuthRepositoryTest { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1) } - assertEquals( - SINGLE_USER_STATE_1, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false } @@ -3163,10 +3232,7 @@ class AuthRepositoryTest { organizationKeys = null, ) } - assertEquals( - SINGLE_USER_STATE_1, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -3458,10 +3524,7 @@ class AuthRepositoryTest { vaultRepository.syncIfNecessary() settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1) } - assertEquals( - SINGLE_USER_STATE_1, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -3525,7 +3588,7 @@ class AuthRepositoryTest { ) vaultRepository.syncIfNecessary() } - assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -3700,7 +3763,7 @@ class AuthRepositoryTest { ) vaultRepository.syncIfNecessary() } - assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -3793,7 +3856,7 @@ class AuthRepositoryTest { ) vaultRepository.syncIfNecessary() } - assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -4016,7 +4079,7 @@ class AuthRepositoryTest { ) vaultRepository.syncIfNecessary() } - assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -4172,7 +4235,7 @@ class AuthRepositoryTest { ) vaultRepository.syncIfNecessary() } - assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -4258,10 +4321,7 @@ class AuthRepositoryTest { organizationKeys = null, ) } - assertEquals( - SINGLE_USER_STATE_1, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -4313,7 +4373,7 @@ class AuthRepositoryTest { accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2, ) fakeAuthDiskSource.assertDeviceKey(userId = USER_ID_1, deviceKey = null) - assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) coVerify(exactly = 1) { identityService.getToken( email = EMAIL, @@ -4399,7 +4459,7 @@ class AuthRepositoryTest { accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2, ) fakeAuthDiskSource.assertDeviceKey(userId = USER_ID_1, deviceKey = deviceKey) - assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) coVerify { identityService.getToken( email = EMAIL, @@ -4500,7 +4560,7 @@ class AuthRepositoryTest { userId = USER_ID_1, accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2, ) - assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) coVerify(exactly = 1) { identityService.getToken( email = EMAIL, @@ -4588,10 +4648,7 @@ class AuthRepositoryTest { ) vaultRepository.syncIfNecessary() } - assertEquals( - MULTI_USER_STATE, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = MULTI_USER_STATE) assertFalse(repository.hasPendingAccountAddition) verify(exactly = 1) { userStateManager.hasPendingAccountAddition @@ -4793,10 +4850,7 @@ class AuthRepositoryTest { ) vaultRepository.syncIfNecessary() } - assertEquals( - SINGLE_USER_STATE_1, - fakeAuthDiskSource.userState, - ) + fakeAuthDiskSource.assertUserState(userState = SINGLE_USER_STATE_1) verify(exactly = 1) { userStateManager.hasPendingAccountAddition = false settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) @@ -4893,7 +4947,7 @@ class AuthRepositoryTest { @Test fun `register with email token Success should return Success with v1 encryption`() = runTest { every { featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionPassword) } returns false - coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess() + coEvery { authSdkSource.preLogin(email = EMAIL) } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.registerFinish( body = RegisterFinishRequestJson( @@ -5124,94 +5178,112 @@ class AuthRepositoryTest { } } + @Suppress("MaxLineLength") @Test - fun `resetPassword Success should return Success`() = runTest { - val currentPassword = "currentPassword" - val currentPasswordHash = "hashedCurrentPassword" - val newPassword = "newPassword" - val newPasswordHash = "newPasswordHash" - val newKey = "newKey" - val email = ACCOUNT_1.profile.email - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - coEvery { - authSdkSource.hashPassword( - salt = email, - password = currentPassword, - kdf = ACCOUNT_1.profile.toSdkParams(), - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) - } returns currentPasswordHash.asSuccess() - coEvery { - vaultSdkSource.updatePassword( - userId = ACCOUNT_1.profile.userId, - newPassword = newPassword, + fun `resetPassword Success should return Success and use the master password unlock salt and kdf`() = + runTest { + val currentPassword = "currentPassword" + val currentPasswordHash = "hashedCurrentPassword" + val newPassword = "newPassword" + val newPasswordHash = "newPasswordHash" + val newKey = "newKey" + // Deliberately differs from the profile email and KDF to verify the unlock data is + // used instead. + val masterPasswordUnlock = MasterPasswordUnlockDataJson( + kdf = KdfJson( + kdfType = KdfTypeJson.PBKDF2_SHA256, + iterations = 500000, + memory = null, + parallelism = null, + ), + masterKeyWrappedUserKey = ENCRYPTED_USER_KEY, + salt = SALT, ) - } returns UpdatePasswordResponse( - passwordHash = newPasswordHash, - newKey = newKey, - ) - .asSuccess() - coEvery { - accountsService.resetPassword( - body = ResetPasswordRequestJson( - currentPasswordHash = currentPasswordHash, - passwordHint = null, - kdf = ACCOUNT_1.profile.toKdfRequestModel(), - salt = email, - masterPasswordAuthenticationHash = newPasswordHash, - masterKeyWrappedUserKey = newKey, + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy( + profile = ACCOUNT_1.profile.copy( + userDecryptionOptions = UserDecryptionOptionsJson( + hasMasterPassword = true, + trustedDeviceUserDecryptionOptions = null, + keyConnectorUserDecryptionOptions = null, + masterPasswordUnlock = masterPasswordUnlock, + ), + ), + ), ), ) - } returns Unit.asSuccess() - coEvery { - authSdkSource.hashPassword( - salt = email, - password = newPassword, - kdf = ACCOUNT_1.profile.toSdkParams(), - purpose = HashPurpose.LOCAL_AUTHORIZATION, + coEvery { + authSdkSource.hashPassword( + salt = SALT, + password = currentPassword, + kdf = Kdf.Pbkdf2(iterations = 500000u), + purpose = HashPurpose.SERVER_AUTHORIZATION, + ) + } returns currentPasswordHash.asSuccess() + coEvery { + vaultSdkSource.updatePassword( + userId = ACCOUNT_1.profile.userId, + newPassword = newPassword, + ) + } returns UpdatePasswordResponse( + passwordHash = newPasswordHash, + newKey = newKey, ) - } returns newPasswordHash.asSuccess() - - val result = repository.resetPassword( - currentPassword = currentPassword, - newPassword = newPassword, - passwordHint = null, - ) + .asSuccess() + coEvery { + accountsService.resetPassword( + body = ResetPasswordRequestJson( + currentPasswordHash = currentPasswordHash, + passwordHint = null, + kdf = masterPasswordUnlock.kdf, + salt = SALT, + masterPasswordAuthenticationHash = newPasswordHash, + masterKeyWrappedUserKey = newKey, + ), + ) + } returns Unit.asSuccess() - assertEquals( - ResetPasswordResult.Success, - result, - ) - coVerify { - authSdkSource.hashPassword( - salt = email, - password = currentPassword, - kdf = ACCOUNT_1.profile.toSdkParams(), - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) - vaultSdkSource.updatePassword( - userId = ACCOUNT_1.profile.userId, + val result = repository.resetPassword( + currentPassword = currentPassword, newPassword = newPassword, + passwordHint = null, ) - accountsService.resetPassword( - body = ResetPasswordRequestJson( - currentPasswordHash = currentPasswordHash, - passwordHint = null, - kdf = ACCOUNT_1.profile.toKdfRequestModel(), - salt = email, - masterPasswordAuthenticationHash = newPasswordHash, - masterKeyWrappedUserKey = newKey, - ), - ) - } - verify(exactly = 1) { - toastManager.show(messageId = BitwardenString.updated_master_password) - userLogoutManager.logout( - userId = ACCOUNT_1.profile.userId, - reason = LogoutReason.PasswordReset, + + assertEquals( + ResetPasswordResult.Success, + result, ) + coVerify { + authSdkSource.hashPassword( + salt = SALT, + password = currentPassword, + kdf = Kdf.Pbkdf2(iterations = 500000u), + purpose = HashPurpose.SERVER_AUTHORIZATION, + ) + vaultSdkSource.updatePassword( + userId = ACCOUNT_1.profile.userId, + newPassword = newPassword, + ) + accountsService.resetPassword( + body = ResetPasswordRequestJson( + currentPasswordHash = currentPasswordHash, + passwordHint = null, + kdf = masterPasswordUnlock.kdf, + salt = SALT, + masterPasswordAuthenticationHash = newPasswordHash, + masterKeyWrappedUserKey = newKey, + ), + ) + } + verify(exactly = 1) { + toastManager.show(messageId = BitwardenString.updated_master_password) + userLogoutManager.logout( + userId = ACCOUNT_1.profile.userId, + reason = LogoutReason.PasswordReset, + ) + } } - } @Test fun `resetPassword Failure should return Error`() = runTest { @@ -5259,6 +5331,50 @@ class AuthRepositoryTest { } } + @Test + fun `resetPassword without active account should return Error`() = runTest { + fakeAuthDiskSource.userState = null + + val result = repository.resetPassword( + currentPassword = "currentPassword", + newPassword = "newPassword", + passwordHint = null, + ) + + assertEquals(ResetPasswordResult.Error(error = NoActiveUserException()), result) + } + + @Test + fun `resetPassword without master password unlock data should return Error`() = runTest { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy( + profile = ACCOUNT_1.profile.copy(userDecryptionOptions = null), + ), + ), + ) + + val result = repository.resetPassword( + currentPassword = "currentPassword", + newPassword = "newPassword", + passwordHint = null, + ) + + assertEquals( + ResetPasswordResult.Error(error = MissingPropertyException("Master Password Unlock")), + result, + ) + coVerify(exactly = 0) { + authSdkSource.hashPassword( + salt = any(), + password = any(), + kdf = any(), + purpose = any(), + ) + vaultSdkSource.updatePassword(userId = any(), newPassword = any()) + } + } + @Test fun `setPassword without active account should return Error`() = runTest { fakeAuthDiskSource.userState = null @@ -6230,7 +6346,7 @@ class AuthRepositoryTest { fun `resendVerificationCodeEmail uses cached request data to make api call`() = runTest { // Attempt a normal login with a two factor error first, so that the necessary // data will be cached. - coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess() + coEvery { authSdkSource.preLogin(email = EMAIL) } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -6250,8 +6366,8 @@ class AuthRepositoryTest { .asSuccess() val firstResult = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.TwoFactorRequired, firstResult) - coVerify { identityService.preLogin(email = EMAIL) } - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -6293,7 +6409,9 @@ class AuthRepositoryTest { runTest { // Attempt a normal login with a two factor error first, so that the necessary // data will be cached. - coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess() + coEvery { + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -6313,8 +6431,8 @@ class AuthRepositoryTest { .asSuccess() val firstResult = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.TwoFactorRequired, firstResult) - coVerify { identityService.preLogin(email = EMAIL) } - coVerify { + coVerify(exactly = 1) { + authSdkSource.preLogin(email = EMAIL) identityService.getToken( email = EMAIL, authModel = IdentityTokenAuthModel.MasterPassword( @@ -7103,8 +7221,8 @@ class AuthRepositoryTest { runTest { val successResponse = GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -7140,7 +7258,7 @@ class AuthRepositoryTest { every { settingsRepository.getUserHasLoggedInValue(USER_ID_1) } returns false val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.Success, result) - coVerify { identityService.preLogin(email = EMAIL) } + coVerify(exactly = 1) { authSdkSource.preLogin(email = EMAIL) } fakeAuthDiskSource.assertAccountCryptographicState( userId = USER_ID_1, accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2, @@ -7157,8 +7275,8 @@ class AuthRepositoryTest { runTest { val successResponse = GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS coEvery { - identityService.preLogin(email = EMAIL) - } returns PRE_LOGIN_SUCCESS.asSuccess() + authSdkSource.preLogin(email = EMAIL) + } returns PRE_LOGIN_SDK_SUCCESS.asSuccess() coEvery { identityService.getToken( email = EMAIL, @@ -7194,7 +7312,7 @@ class AuthRepositoryTest { every { settingsRepository.getUserHasLoggedInValue(USER_ID_1) } returns true val result = repository.login(email = EMAIL, password = PASSWORD) assertEquals(LoginResult.Success, result) - coVerify { identityService.preLogin(email = EMAIL) } + coVerify(exactly = 1) { authSdkSource.preLogin(email = EMAIL) } fakeAuthDiskSource.assertAccountCryptographicState( userId = USER_ID_1, accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2, @@ -7280,6 +7398,10 @@ private val TWO_FACTOR_AUTH_METHODS_DATA = mapOf( private val PRE_LOGIN_SUCCESS = PreLoginResponseJson( kdfParams = PreLoginResponseJson.KdfParams.Pbkdf2(iterations = 1u), ) +private val PRE_LOGIN_SDK_SUCCESS = PasswordPreloginResponse( + kdf = Kdf.Pbkdf2(iterations = 1u), + salt = SALT, +) private val AUTH_REQUEST_RESPONSE = AuthRequestResponse( privateKey = PRIVATE_KEY, publicKey = PUBLIC_KEY, diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt index 80ecb19ba3d..86abc7657e2 100644 --- a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt @@ -51,6 +51,7 @@ sealed class FlagKey { SendControlsExistingSends, IdentityAutofill, SdkPinUnlock, + SdkPreLogin, ) } } @@ -237,6 +238,14 @@ sealed class FlagKey { override val defaultValue: Boolean = false } + /** + * Data object holding the feature flag key for the Pre-Login flow via the SDK. + */ + data object SdkPreLogin : FlagKey() { + override val keyName: String = "pm-27060-password-prelogin-from-sdk" + override val defaultValue: Boolean = false + } + //region Dummy keys for testing /** * Data object holding the key for a [Boolean] flag to be used in tests. diff --git a/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt b/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt index 7b7ea30f881..b9d516b34a2 100644 --- a/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt +++ b/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt @@ -84,6 +84,10 @@ class FlagKeyTest { FlagKey.SdkPinUnlock.keyName, "pm-31059-sdk-pin-unlock", ) + assertEquals( + FlagKey.SdkPreLogin.keyName, + "pm-27060-password-prelogin-from-sdk", + ) } @Test @@ -109,6 +113,7 @@ class FlagKeyTest { FlagKey.SendControlsExistingSends, FlagKey.IdentityAutofill, FlagKey.SdkPinUnlock, + FlagKey.SdkPreLogin, ).all { !it.defaultValue }, diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt index 15b3a73ba39..1145d16d0f8 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt @@ -45,6 +45,7 @@ fun FlagKey.ListItemContent( FlagKey.SendControlsExistingSends, FlagKey.IdentityAutofill, FlagKey.SdkPinUnlock, + FlagKey.SdkPreLogin, -> { @Suppress("UNCHECKED_CAST") BooleanFlagItem( @@ -117,4 +118,5 @@ private fun FlagKey.getDisplayLabel(): String = when (this) { FlagKey.IdentityAutofill -> stringResource(BitwardenString.identity_autofill) FlagKey.SdkPinUnlock -> stringResource(BitwardenString.sdk_pin_unlock) + FlagKey.SdkPreLogin -> stringResource(BitwardenString.sdk_pre_login) } diff --git a/ui/src/main/res/values/strings_non_localized.xml b/ui/src/main/res/values/strings_non_localized.xml index 625e125a76c..3e2531fd717 100644 --- a/ui/src/main/res/values/strings_non_localized.xml +++ b/ui/src/main/res/values/strings_non_localized.xml @@ -65,6 +65,7 @@ Send Controls - Existing Sends Identity Autofill SDK PIN Unlock + SDK PreLogin