From 1a0cead2f17bc2885d4cc8af95eca28162cf6005 Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 25 Apr 2024 12:35:58 -0500 Subject: [PATCH] BIT-2276: Add support for logging in with WebAuthN two-factor (#1304) --- app/src/main/AndroidManifest.xml | 10 + .../x8bit/bitwarden/AuthCallbackViewModel.kt | 6 + .../util/TwoFactorRequiredExtensions.kt | 53 ----- .../data/auth/repository/AuthRepository.kt | 12 + .../auth/repository/AuthRepositoryImpl.kt | 8 + .../data/auth/repository/util/WebAuthUtils.kt | 78 +++++++ .../twofactorlogin/TwoFactorLoginScreen.kt | 4 + .../twofactorlogin/TwoFactorLoginViewModel.kt | 129 +++++++++-- .../util/TwoFactorAuthMethodExtensions.kt | 32 ++- .../main/res/values/strings_non_localized.xml | 1 + .../bitwarden/AuthCallbackViewModelTest.kt | 26 +++ .../util/TwoFactorRequiredExtensionTest.kt | 77 ------- .../auth/repository/AuthRepositoryTest.kt | 10 + .../auth/repository/util/WebAuthUtilsTest.kt | 76 +++++++ .../TwoFactorLoginScreenTest.kt | 17 ++ .../TwoFactorLoginViewModelTest.kt | 214 ++++++++++++++---- .../util/TwoFactorAuthMethodExtensionTest.kt | 26 ++- 17 files changed, 582 insertions(+), 197 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtils.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtilsTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e5e6d3a1e5..f8853bb846 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -92,6 +92,16 @@ android:host="sso-callback" android:scheme="bitwarden" /> + + + + + + + + { + authRepository.setWebAuthResult(webAuthResult = webAuthResult) + } + else -> Unit } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensions.kt index 959c0865c5..3ed674c34e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensions.kt @@ -2,11 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.util import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod -import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlDecodeOrNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** @@ -61,53 +58,3 @@ val GetTokenResponseJson.TwoFactorRequired?.twoFactorDisplayEmail: String */ private val Map.duo: JsonObject? get() = get(TwoFactorAuthMethod.DUO) ?: get(TwoFactorAuthMethod.DUO_ORGANIZATION) - -/** - * If it exists, return the identifier for the relying party used with Web AuthN two-factor - * authentication. - */ -val GetTokenResponseJson.TwoFactorRequired?.webAuthRpId: String? - get() = this - ?.authMethodsData - ?.get(TwoFactorAuthMethod.WEB_AUTH) - ?.get("rpId") - ?.jsonPrimitive - ?.contentOrNull - -/** - * If it exists, return the type of user verification needed to complete the Web AuthN two-factor - * authentication. - */ -val GetTokenResponseJson.TwoFactorRequired?.webAuthUserVerification: String? - get() = this - ?.authMethodsData - ?.get(TwoFactorAuthMethod.WEB_AUTH) - ?.get("userVerification") - ?.jsonPrimitive - ?.contentOrNull - -/** - * If it exists, return the challenge that the authenticator need to solve to complete the - * Web AuthN two-factor authentication. - */ -val GetTokenResponseJson.TwoFactorRequired?.webAuthChallenge: String? - get() = this - ?.authMethodsData - ?.get(TwoFactorAuthMethod.WEB_AUTH) - ?.get("challenge") - ?.jsonPrimitive - ?.contentOrNull - -/** - * If it exists, return the credentials allowed to be used to solve the challenge to complete the - * Web AuthN two-factor authentication. - */ -val GetTokenResponseJson.TwoFactorRequired?.webAuthAllowCredentials: List? - get() = this - ?.authMethodsData - ?.get(TwoFactorAuthMethod.WEB_AUTH) - ?.get("allowCredentials") - ?.jsonArray - ?.mapNotNull { - it.jsonObject["id"]?.jsonPrimitive?.contentOrNull?.base64UrlDecodeOrNull() - } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 8d49e5000c..dfb09041ca 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -27,6 +27,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult +import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider import kotlinx.coroutines.flow.Flow @@ -71,6 +72,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { */ val yubiKeyResultFlow: Flow + /** + * Flow of the current [WebAuthResult]. Subscribers should listen to the flow in order to + * receive updates whenever [setWebAuthResult] is called. + */ + val webAuthResultFlow: Flow + /** * The organization identifier currently associated with this user's SSO flow. */ @@ -285,6 +292,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { */ fun setYubiKeyResult(yubiKeyResult: YubiKeyResult) + /** + * Set the value of [webAuthResultFlow]. + */ + fun setWebAuthResult(webAuthResult: WebAuthResult) + /** * Checks for a claimed domain organization for the [email] that can be used for an SSO request. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 984c07e31b..bb1dd85677 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult +import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow import com.x8bit.bitwarden.data.auth.repository.util.policyInformation import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams @@ -275,6 +276,9 @@ class AuthRepositoryImpl( private val yubiKeyResultChannel = Channel(capacity = Int.MAX_VALUE) override val yubiKeyResultFlow: Flow = yubiKeyResultChannel.receiveAsFlow() + private val webAuthResultChannel = Channel(capacity = Int.MAX_VALUE) + override val webAuthResultFlow: Flow = webAuthResultChannel.receiveAsFlow() + private val mutableSsoCallbackResultFlow = bufferedMutableSharedFlow() override val ssoCallbackResultFlow: Flow = mutableSsoCallbackResultFlow.asSharedFlow() @@ -934,6 +938,10 @@ class AuthRepositoryImpl( yubiKeyResultChannel.trySend(yubiKeyResult) } + override fun setWebAuthResult(webAuthResult: WebAuthResult) { + webAuthResultChannel.trySend(webAuthResult) + } + override suspend fun getOrganizationDomainSsoDetails( email: String, ): OrganizationDomainSsoDetailsResult = organizationService diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtils.kt new file mode 100644 index 0000000000..85c7f0f907 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtils.kt @@ -0,0 +1,78 @@ +package com.x8bit.bitwarden.data.auth.repository.util + +import android.content.Intent +import android.net.Uri +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.net.URLEncoder +import java.util.Base64 + +private const val WEB_AUTH_HOST: String = "webauthn-callback" +private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST" + +/** + * Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases. + * + * - `null`: Intent is not an web auth key callback. + * - [WebAuthResult.Success]: Intent is the web auth key callback with correct data. + * - [WebAuthResult.Failure]: Intent is the web auth key callback with incorrect data. + */ +fun Intent.getWebAuthResultOrNull(): WebAuthResult? { + val localData = data + return if (action == Intent.ACTION_VIEW && + localData != null && + localData.host == WEB_AUTH_HOST + ) { + localData + .getQueryParameter("data") + ?.let { WebAuthResult.Success(token = it) } + ?: WebAuthResult.Failure + } else { + null + } +} + +/** + * Generates a [Uri] to display a web authn challenge for Bitwarden authentication. + */ +fun generateUriForWebAuth( + baseUrl: String, + data: JsonObject, + headerText: String, + buttonText: String, + returnButtonText: String, +): Uri { + val json = buildJsonObject { + put(key = "callbackUri", value = CALLBACK_URI) + put(key = "data", value = data.toString()) + put(key = "headerText", value = headerText) + put(key = "btnText", value = buttonText) + put(key = "btnReturnText", value = returnButtonText) + } + val base64Data = Base64 + .getEncoder() + .encodeToString(json.toString().toByteArray(Charsets.UTF_8)) + val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8") + val url = baseUrl + + "/webauthn-mobile-connector.html" + + "?data=$base64Data" + + "&parent=$parentParam" + + "&v=2" + return Uri.parse(url) +} + +/** + * Sealed class representing the result of web auth callback token extraction. + */ +sealed class WebAuthResult { + /** + * Represents a token present in the web auth callback. + */ + data class Success(val token: String) : WebAuthResult() + + /** + * Represents a failure in the web auth callback. + */ + data object Failure : WebAuthResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt index efa21ddfe8..d7b57e8557 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt @@ -108,6 +108,10 @@ fun TwoFactorLoginScreen( intentManager.startCustomTabsActivity(uri = event.uri) } + is TwoFactorLoginEvent.NavigateToWebAuth -> { + intentManager.startCustomTabsActivity(uri = event.uri) + } + is TwoFactorLoginEvent.ShowToast -> { Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt index 1781584cb8..5c569bab31 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt @@ -18,17 +18,21 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult +import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.button import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.imageRes -import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.isDuo +import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.isContinueButtonEnabled import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.shouldUseNfc +import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.showPasswordInput import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -48,6 +52,7 @@ private const val KEY_STATE = "state" class TwoFactorLoginViewModel @Inject constructor( private val authRepository: AuthRepository, private val environmentRepository: EnvironmentRepository, + private val resourceManager: ResourceManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] @@ -57,7 +62,10 @@ class TwoFactorLoginViewModel @Inject constructor( codeInput = "", displayEmail = authRepository.twoFactorResponse.twoFactorDisplayEmail, dialogState = null, - isContinueButtonEnabled = authRepository.twoFactorResponse.preferredAuthMethod.isDuo, + isContinueButtonEnabled = authRepository + .twoFactorResponse + .preferredAuthMethod + .isContinueButtonEnabled, isRememberMeEnabled = false, captchaToken = null, email = TwoFactorLoginArgs(savedStateHandle).emailAddress, @@ -100,6 +108,13 @@ class TwoFactorLoginViewModel @Inject constructor( .map { TwoFactorLoginAction.Internal.ReceiveYubiKeyResult(yubiKeyResult = it) } .onEach(::sendAction) .launchIn(viewModelScope) + + // Process the Web Authn result when it is received. + authRepository + .webAuthResultFlow + .map { TwoFactorLoginAction.Internal.ReceiveWebAuthResult(webAuthResult = it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: TwoFactorLoginAction) { @@ -130,6 +145,10 @@ class TwoFactorLoginViewModel @Inject constructor( handleReceiveYubiKeyResult(action) } + is TwoFactorLoginAction.Internal.ReceiveWebAuthResult -> { + handleReceiveWebAuthResult(action) + } + is TwoFactorLoginAction.Internal.ReceiveResendEmailResult -> { handleReceiveResendEmailResult(action) } @@ -175,23 +194,59 @@ class TwoFactorLoginViewModel @Inject constructor( /** * Navigates to the Duo webpage if appropriate, else processes the login. */ + @Suppress("MaxLineLength") private fun handleContinueButtonClick() { - if (state.authMethod.isDuo) { - val authUrl = authRepository.twoFactorResponse.twoFactorDuoAuthUrl - // The url should not be empty unless the environment is somehow not supported. - sendEvent( - event = authUrl - ?.let { - TwoFactorLoginEvent.NavigateToDuo( - uri = Uri.parse(it), - ) - } - ?: TwoFactorLoginEvent.ShowToast( - message = R.string.generic_error_message.asText(), - ), - ) - } else { - initiateLogin() + when (state.authMethod) { + TwoFactorAuthMethod.DUO, + TwoFactorAuthMethod.DUO_ORGANIZATION, + -> { + val authUrl = authRepository.twoFactorResponse.twoFactorDuoAuthUrl + // The url should not be empty unless the environment is somehow not supported. + sendEvent( + event = authUrl + ?.let { TwoFactorLoginEvent.NavigateToDuo(uri = Uri.parse(it)) } + ?: TwoFactorLoginEvent.ShowToast(R.string.generic_error_message.asText()), + ) + } + + TwoFactorAuthMethod.WEB_AUTH -> { + sendEvent( + event = authRepository + .twoFactorResponse + ?.authMethodsData + ?.get(TwoFactorAuthMethod.WEB_AUTH) + ?.let { + val uri = generateUriForWebAuth( + baseUrl = environmentRepository + .environment + .environmentUrlData + .baseWebVaultUrlOrDefault, + data = it, + headerText = resourceManager.getString( + resId = R.string.fido2_title, + ), + buttonText = resourceManager.getString( + resId = R.string.fido2_authenticate_web_authn, + ), + returnButtonText = resourceManager.getString( + resId = R.string.fido2_return_to_app, + ), + ) + TwoFactorLoginEvent.NavigateToWebAuth(uri = uri) + } + ?: TwoFactorLoginEvent.ShowToast( + message = R.string.there_was_an_error_starting_web_authn_two_factor_authentication.asText(), + ), + ) + } + + TwoFactorAuthMethod.AUTHENTICATOR_APP, + TwoFactorAuthMethod.EMAIL, + TwoFactorAuthMethod.YUBI_KEY, + TwoFactorAuthMethod.U2F, + TwoFactorAuthMethod.REMEMBER, + TwoFactorAuthMethod.RECOVERY_CODE, + -> initiateLogin() } } @@ -289,6 +344,30 @@ class TwoFactorLoginViewModel @Inject constructor( } } + /** + * Handle the web auth result. + */ + private fun handleReceiveWebAuthResult( + action: TwoFactorLoginAction.Internal.ReceiveWebAuthResult, + ) { + when (val result = action.webAuthResult) { + WebAuthResult.Failure -> { + mutableStateFlow.update { + it.copy( + dialogState = TwoFactorLoginState.DialogState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is WebAuthResult.Success -> { + mutableStateFlow.update { it.copy(codeInput = result.token) } + initiateLogin() + } + } + } + /** * Handle the resend email result. */ @@ -481,7 +560,7 @@ data class TwoFactorLoginState( /** * Indicates whether the code input should be displayed. */ - val shouldShowCodeInput: Boolean get() = !authMethod.isDuo + val shouldShowCodeInput: Boolean get() = authMethod.showPasswordInput /** * The image to display for the given the [authMethod]. @@ -532,6 +611,11 @@ sealed class TwoFactorLoginEvent { */ data class NavigateToDuo(val uri: Uri) : TwoFactorLoginEvent() + /** + * Navigates to the WebAuth authentication screen. + */ + data class NavigateToWebAuth(val uri: Uri) : TwoFactorLoginEvent() + /** * Navigates to the recovery code help page. * @@ -631,5 +715,12 @@ sealed class TwoFactorLoginAction { val resendEmailResult: ResendEmailResult, val isUserInitiated: Boolean, ) : Internal() + + /** + * Indicates a web auth result has been received. + */ + data class ReceiveWebAuthResult( + val webAuthResult: WebAuthResult, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt index 740e2732ac..4bbe2d5308 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt @@ -65,15 +65,41 @@ val TwoFactorAuthMethod.button: Text } /** - * Gets a boolean indicating if the given auth method uses Duo. + * Gets a boolean indicating if the given auth method has the continue button enabled by default. */ -val TwoFactorAuthMethod.isDuo: Boolean +val TwoFactorAuthMethod.isContinueButtonEnabled: Boolean get() = when (this) { TwoFactorAuthMethod.DUO, TwoFactorAuthMethod.DUO_ORGANIZATION, + TwoFactorAuthMethod.WEB_AUTH, -> true - else -> false + TwoFactorAuthMethod.AUTHENTICATOR_APP, + TwoFactorAuthMethod.EMAIL, + TwoFactorAuthMethod.YUBI_KEY, + TwoFactorAuthMethod.U2F, + TwoFactorAuthMethod.REMEMBER, + TwoFactorAuthMethod.RECOVERY_CODE, + -> false + } + +/** + * Gets a boolean indicating if the given auth method should display the password input field. + */ +val TwoFactorAuthMethod.showPasswordInput: Boolean + get() = when (this) { + TwoFactorAuthMethod.DUO, + TwoFactorAuthMethod.DUO_ORGANIZATION, + TwoFactorAuthMethod.WEB_AUTH, + -> false + + TwoFactorAuthMethod.AUTHENTICATOR_APP, + TwoFactorAuthMethod.EMAIL, + TwoFactorAuthMethod.YUBI_KEY, + TwoFactorAuthMethod.U2F, + TwoFactorAuthMethod.REMEMBER, + TwoFactorAuthMethod.RECOVERY_CODE, + -> true } /** diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index 8faa49ff77..412d57200e 100644 --- a/app/src/main/res/values/strings_non_localized.xml +++ b/app/src/main/res/values/strings_non_localized.xml @@ -17,4 +17,5 @@ Autofill suggestion Continue to complete WebAuthn verification. Launch WebAuthn + There was an error starting WebAuthn two factor authentication diff --git a/app/src/test/java/com/x8bit/bitwarden/AuthCallbackViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/AuthCallbackViewModelTest.kt index df775e479f..0ecb570ad9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/AuthCallbackViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/AuthCallbackViewModelTest.kt @@ -5,9 +5,11 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult +import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult +import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.auth.util.getYubiKeyResultOrNull import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest @@ -34,6 +36,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() { fun setUp() { mockkStatic( Intent::getYubiKeyResultOrNull, + Intent::getWebAuthResultOrNull, Intent::getCaptchaCallbackTokenResult, Intent::getDuoCallbackTokenResult, Intent::getSsoCallbackResult, @@ -44,6 +47,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() { fun tearDown() { unmockkStatic( Intent::getYubiKeyResultOrNull, + Intent::getWebAuthResultOrNull, Intent::getCaptchaCallbackTokenResult, Intent::getDuoCallbackTokenResult, Intent::getSsoCallbackResult, @@ -58,6 +62,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() { every { mockIntent.getCaptchaCallbackTokenResult() } returns captchaCallbackTokenResult every { mockIntent.getDuoCallbackTokenResult() } returns null every { mockIntent.getYubiKeyResultOrNull() } returns null + every { mockIntent.getWebAuthResultOrNull() } returns null every { mockIntent.getSsoCallbackResult() } returns null viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent)) @@ -74,6 +79,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() { every { mockIntent.getCaptchaCallbackTokenResult() } returns null every { mockIntent.getDuoCallbackTokenResult() } returns duoCallbackTokenResult every { mockIntent.getYubiKeyResultOrNull() } returns null + every { mockIntent.getWebAuthResultOrNull() } returns null every { mockIntent.getSsoCallbackResult() } returns null viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent)) @@ -92,6 +98,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() { ) every { mockIntent.getSsoCallbackResult() } returns sseCallbackResult every { mockIntent.getYubiKeyResultOrNull() } returns null + every { mockIntent.getWebAuthResultOrNull() } returns null every { mockIntent.getCaptchaCallbackTokenResult() } returns null every { mockIntent.getDuoCallbackTokenResult() } returns null @@ -107,6 +114,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() { val mockIntent = mockk() val yubiKeyResult = mockk() every { mockIntent.getYubiKeyResultOrNull() } returns yubiKeyResult + every { mockIntent.getWebAuthResultOrNull() } returns null every { mockIntent.getCaptchaCallbackTokenResult() } returns null every { mockIntent.getDuoCallbackTokenResult() } returns null every { mockIntent.getSsoCallbackResult() } returns null @@ -117,6 +125,24 @@ class AuthCallbackViewModelTest : BaseViewModelTest() { } } + @Test + fun `on ReceiveNewIntent with Web Auth Result should call setWebAuthResult`() { + val viewModel = createViewModel() + val webAuthResult = mockk() + val mockIntent = mockk { + every { getWebAuthResultOrNull() } returns webAuthResult + every { getYubiKeyResultOrNull() } returns null + every { getCaptchaCallbackTokenResult() } returns null + every { getDuoCallbackTokenResult() } returns null + every { getSsoCallbackResult() } returns null + } + + viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent)) + verify(exactly = 1) { + authRepository.setWebAuthResult(webAuthResult) + } + } + private fun createViewModel() = AuthCallbackViewModel( authRepository = authRepository, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensionTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensionTest.kt index 26faa0b5a7..86c885416d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensionTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensionTest.kt @@ -2,12 +2,10 @@ package com.x8bit.bitwarden.data.auth.datasource.network.util import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -156,79 +154,4 @@ class TwoFactorRequiredExtensionTest { ) assertEquals(authUrl, subject.twoFactorDuoAuthUrl) } - - @Test - fun `webAuthRpId returns the expected value`() { - val rpId = "vault.bitwarden.com" - val subject = GetTokenResponseJson.TwoFactorRequired( - authMethodsData = mapOf( - TwoFactorAuthMethod.WEB_AUTH to JsonObject( - mapOf("rpId" to JsonPrimitive(rpId)), - ), - ), - captchaToken = null, - ssoToken = null, - twoFactorProviders = null, - ) - assertEquals(rpId, subject.webAuthRpId) - } - - @Test - fun `webAuthUserVerification returns the expected value`() { - val userVerification = "discouraged" - val subject = GetTokenResponseJson.TwoFactorRequired( - authMethodsData = mapOf( - TwoFactorAuthMethod.WEB_AUTH to JsonObject( - mapOf("userVerification" to JsonPrimitive(userVerification)), - ), - ), - captchaToken = null, - ssoToken = null, - twoFactorProviders = null, - ) - assertEquals(userVerification, subject.webAuthUserVerification) - } - - @Test - fun `webAuthChallenge returns the expected value`() { - val challenge = "987t34478t9rxq7t8n" - val subject = GetTokenResponseJson.TwoFactorRequired( - authMethodsData = mapOf( - TwoFactorAuthMethod.WEB_AUTH to JsonObject( - mapOf("challenge" to JsonPrimitive(challenge)), - ), - ), - captchaToken = null, - ssoToken = null, - twoFactorProviders = null, - ) - assertEquals(challenge, subject.webAuthChallenge) - } - - @Test - fun `webAuthAllowCredentials returns the expected value`() { - val credential = "98426435782" - val subject = GetTokenResponseJson.TwoFactorRequired( - authMethodsData = mapOf( - TwoFactorAuthMethod.WEB_AUTH to JsonObject( - mapOf( - "allowCredentials" to JsonArray( - listOf( - JsonObject( - mapOf( - "type" to JsonPrimitive("public-key"), - "id" to JsonPrimitive(credential), - ), - ), - ), - ), - ), - ), - ), - captchaToken = null, - ssoToken = null, - twoFactorProviders = null, - ) - assertNotNull(subject.webAuthAllowCredentials) - } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index e9fc08840e..205a9fb07f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -75,6 +75,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult +import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.auth.repository.util.toUserState @@ -4048,6 +4049,15 @@ class AuthRepositoryTest { } } + @Test + fun `setWebAuthResult should change the value of webAuthResultFlow`() = runTest { + val webAuthResult = WebAuthResult.Success("mockk") + repository.webAuthResultFlow.test { + repository.setWebAuthResult(webAuthResult) + assertEquals(webAuthResult, awaitItem()) + } + } + @Test fun `getOrganizationDomainSsoDetails Failure should return Failure `() = runTest { val email = "test@gmail.com" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtilsTest.kt new file mode 100644 index 0000000000..762a14f72e --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtilsTest.kt @@ -0,0 +1,76 @@ +package com.x8bit.bitwarden.data.auth.repository.util + +import android.content.Intent +import android.net.Uri +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class WebAuthUtilsTest : BaseComposeTest() { + + @Test + fun `generateUriForWebAuth should return valid Uri`() { + val baseUrl = "https://vault.bitwarden.com" + val actualUri = generateUriForWebAuth( + baseUrl = baseUrl, + data = JsonObject(emptyMap()), + headerText = "header", + buttonText = "button", + returnButtonText = "returnButton", + ) + val expectedUrl = baseUrl + + "/webauthn-mobile-connector.html" + + "?data=eyJjYWxsYmFja1VyaSI6ImJpdHdhcmRlbjovL3dlYmF1dGhuLWNhbGxiYWNrIiwiZ" + + "GF0YSI6Int9IiwiaGVhZGVyVGV4dCI6ImhlYWRlciIsImJ0blRleHQiOiJidXR0b24iLCJi" + + "dG5SZXR1cm5UZXh0IjoicmV0dXJuQnV0dG9uIn0=" + + "&parent=bitwarden%3A%2F%2Fwebauthn-callback" + + "&v=2" + val expectedUri = Uri.parse(expectedUrl) + assertEquals(expectedUri, actualUri) + } + + @Test + fun `getWebAuthResultOrNull should return null when data is null`() { + val intent = mockk { + every { data } returns null + every { action } returns Intent.ACTION_VIEW + } + val result = intent.getWebAuthResultOrNull() + assertEquals(null, result) + } + + @Test + fun `getWebAuthResultOrNull should return null when action is not Intent ACTION_VIEW`() { + val intent = mockk { + every { data } returns null + every { action } returns Intent.ACTION_ANSWER + } + val result = intent.getWebAuthResultOrNull() + assertEquals(null, result) + } + + @Test + fun `getWebAuthResultOrNull should return Failure with missing data parameter`() { + val intent = mockk { + every { data?.getQueryParameter("data") } returns null + every { action } returns Intent.ACTION_VIEW + every { data?.host } returns "webauthn-callback" + } + val result = intent.getWebAuthResultOrNull() + assertEquals(WebAuthResult.Failure, result) + } + + @Test + fun `getWebAuthResultOrNull should return Success when data query parameter is present`() { + val intent = mockk { + every { data?.getQueryParameter("data") } returns "myToken" + every { action } returns Intent.ACTION_VIEW + every { data?.host } returns "webauthn-callback" + } + val result = intent.getWebAuthResultOrNull() + assertEquals(WebAuthResult.Success("myToken"), result) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt index aff2547fd9..9beaa24c9c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt @@ -200,6 +200,16 @@ class TwoFactorLoginScreenTest : BaseComposeTest() { it.copy(authMethod = TwoFactorAuthMethod.DUO) } composeTestRule.onNodeWithText("Verification code").assertIsNotDisplayed() + + mutableStateFlow.update { + it.copy(authMethod = TwoFactorAuthMethod.DUO_ORGANIZATION) + } + composeTestRule.onNodeWithText("Verification code").assertIsNotDisplayed() + + mutableStateFlow.update { + it.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH) + } + composeTestRule.onNodeWithText("Verification code").assertIsNotDisplayed() } @Test @@ -253,6 +263,13 @@ class TwoFactorLoginScreenTest : BaseComposeTest() { verify { intentManager.startCustomTabsActivity(mockUri) } } + @Test + fun `NavigateToDuoNavigateToWebAuth should call intentManager startCustomTabsActivity`() { + val mockUri = mockk() + mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToWebAuth(mockUri)) + verify { intentManager.startCustomTabsActivity(mockUri) } + } + @Test fun `NavigateToRecoveryCode should launch the recovery code uri`() { val mockUri = mockk() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt index 2beffe8856..455f0b60e4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt @@ -13,13 +13,17 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult +import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -36,33 +40,40 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +@Suppress("LargeClass") class TwoFactorLoginViewModelTest : BaseViewModelTest() { private val mutableCaptchaTokenResultFlow = bufferedMutableSharedFlow() - private val mutableDuoTokenResultFlow = - bufferedMutableSharedFlow() + private val mutableDuoTokenResultFlow = bufferedMutableSharedFlow() private val mutableYubiKeyResultFlow = bufferedMutableSharedFlow() + private val mutableWebAuthResultFlow = bufferedMutableSharedFlow() private val authRepository: AuthRepository = mockk(relaxed = true) { every { twoFactorResponse } returns TWO_FACTOR_RESPONSE every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow every { duoTokenResultFlow } returns mutableDuoTokenResultFlow every { yubiKeyResultFlow } returns mutableYubiKeyResultFlow + every { webAuthResultFlow } returns mutableWebAuthResultFlow } - private val environmentRepository: EnvironmentRepository = mockk(relaxed = true) { - every { - environment.environmentUrlData.baseWebVaultUrlOrDefault - } returns "https://vault.bitwarden.com" + private val environmentRepository: EnvironmentRepository = mockk { + every { environment } returns Environment.Us } + private val resourceManager: ResourceManager = mockk() @BeforeEach fun setUp() { - mockkStatic(::generateUriForCaptcha) + mockkStatic( + ::generateUriForCaptcha, + ::generateUriForWebAuth, + ) mockkStatic(Uri::class) } @AfterEach fun tearDown() { - unmockkStatic(::generateUriForCaptcha) + unmockkStatic( + ::generateUriForCaptcha, + ::generateUriForWebAuth, + ) unmockkStatic(Uri::class) } @@ -89,6 +100,59 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { ) } + @Test + fun `webAuthResultFlow update with success should populate the codeInput and initial login`() { + val token = "token" + val initialState = DEFAULT_STATE.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH) + coEvery { + authRepository.login( + email = "example@email.com", + password = "password123", + twoFactorData = TwoFactorDataModel( + code = token, + method = TwoFactorAuthMethod.WEB_AUTH.value.toString(), + remember = false, + ), + captchaToken = null, + ) + } returns LoginResult.Success + val viewModel = createViewModel(state = initialState) + + mutableWebAuthResultFlow.tryEmit(WebAuthResult.Success(token)) + + assertEquals( + initialState.copy(codeInput = token), + viewModel.stateFlow.value, + ) + coVerify(exactly = 1) { + authRepository.login( + email = "example@email.com", + password = "password123", + twoFactorData = TwoFactorDataModel( + code = token, + method = TwoFactorAuthMethod.WEB_AUTH.value.toString(), + remember = false, + ), + captchaToken = null, + ) + } + } + + @Test + fun `webAuthResultFlow update with failure should display error dialog`() { + val initialState = DEFAULT_STATE.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH) + val viewModel = createViewModel(state = initialState) + mutableWebAuthResultFlow.tryEmit(WebAuthResult.Failure) + assertEquals( + initialState.copy( + dialogState = TwoFactorLoginState.DialogState.Error( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `captchaTokenFlow success update should trigger a login`() = runTest { coEvery { @@ -317,6 +381,75 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `ContinueButtonClick login should emit NavigateToWebAuth when auth method is WEB_AUTH and data is non-null`() = + runTest { + val data = JsonObject(mapOf("AuthUrl" to JsonPrimitive("bitwarden.com"))) + val response = GetTokenResponseJson.TwoFactorRequired( + authMethodsData = mapOf(TwoFactorAuthMethod.WEB_AUTH to data), + captchaToken = null, + ssoToken = null, + twoFactorProviders = null, + ) + val mockkUri = mockk() + val headerText = "header" + val buttonText = "button" + val returnButtonText = "return" + every { resourceManager.getString(R.string.fido2_title) } returns headerText + every { + resourceManager.getString(R.string.fido2_authenticate_web_authn) + } returns buttonText + every { + resourceManager.getString(R.string.fido2_return_to_app) + } returns returnButtonText + every { authRepository.twoFactorResponse } returns response + every { + generateUriForWebAuth( + baseUrl = Environment.Us.environmentUrlData.baseWebVaultUrlOrDefault, + data = data, + headerText = headerText, + buttonText = buttonText, + returnButtonText = returnButtonText, + ) + } returns mockkUri + val viewModel = createViewModel( + state = DEFAULT_STATE.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH), + ) + viewModel.eventFlow.test { + viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick) + assertEquals( + TwoFactorLoginEvent.NavigateToWebAuth(mockkUri), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ContinueButtonClick login should emit ShowToast when auth method is WEB_AUTH and data is null`() = + runTest { + val response = GetTokenResponseJson.TwoFactorRequired( + authMethodsData = emptyMap(), + captchaToken = null, + ssoToken = null, + twoFactorProviders = null, + ) + every { authRepository.twoFactorResponse } returns response + val viewModel = createViewModel( + state = DEFAULT_STATE.copy(authMethod = TwoFactorAuthMethod.WEB_AUTH), + ) + viewModel.eventFlow.test { + viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick) + assertEquals( + TwoFactorLoginEvent.ShowToast( + message = R.string.there_was_an_error_starting_web_authn_two_factor_authentication.asText(), + ), + awaitItem(), + ) + } + } + @Test fun `ContinueButtonClick login returns CaptchaRequired should emit NavigateToCaptcha`() = runTest { @@ -627,43 +760,42 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { TwoFactorLoginViewModel( authRepository = authRepository, environmentRepository = environmentRepository, + resourceManager = resourceManager, savedStateHandle = SavedStateHandle().also { it["state"] = state it["email_address"] = "example@email.com" it["password"] = "password123" }, ) - - companion object { - private val TWO_FACTOR_AUTH_METHODS_DATA = mapOf( - TwoFactorAuthMethod.EMAIL to JsonObject( - mapOf("Email" to JsonPrimitive("ex***@email.com")), - ), - TwoFactorAuthMethod.AUTHENTICATOR_APP to JsonObject(mapOf("Email" to JsonNull)), - ) - private val TWO_FACTOR_RESPONSE = - GetTokenResponseJson.TwoFactorRequired( - authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA, - captchaToken = null, - ssoToken = null, - twoFactorProviders = null, - ) - - private val DEFAULT_STATE = TwoFactorLoginState( - authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP, - availableAuthMethods = listOf( - TwoFactorAuthMethod.EMAIL, - TwoFactorAuthMethod.AUTHENTICATOR_APP, - TwoFactorAuthMethod.RECOVERY_CODE, - ), - codeInput = "", - displayEmail = "ex***@email.com", - dialogState = null, - isContinueButtonEnabled = false, - isRememberMeEnabled = false, - captchaToken = null, - email = "example@email.com", - password = "password123", - ) - } } + +private val TWO_FACTOR_AUTH_METHODS_DATA = mapOf( + TwoFactorAuthMethod.EMAIL to JsonObject( + mapOf("Email" to JsonPrimitive("ex***@email.com")), + ), + TwoFactorAuthMethod.AUTHENTICATOR_APP to JsonObject(mapOf("Email" to JsonNull)), +) + +private val TWO_FACTOR_RESPONSE = GetTokenResponseJson.TwoFactorRequired( + authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA, + captchaToken = null, + ssoToken = null, + twoFactorProviders = null, +) + +private val DEFAULT_STATE = TwoFactorLoginState( + authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP, + availableAuthMethods = listOf( + TwoFactorAuthMethod.EMAIL, + TwoFactorAuthMethod.AUTHENTICATOR_APP, + TwoFactorAuthMethod.RECOVERY_CODE, + ), + codeInput = "", + displayEmail = "ex***@email.com", + dialogState = null, + isContinueButtonEnabled = false, + isRememberMeEnabled = false, + captchaToken = null, + email = "example@email.com", + password = "password123", +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt index 60e758d260..48f17d9b6a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt @@ -79,7 +79,7 @@ class TwoFactorAuthMethodExtensionTest { } @Test - fun `isDuo returns the expected value`() { + fun `isContinueButtonEnabled returns the expected value`() { mapOf( TwoFactorAuthMethod.AUTHENTICATOR_APP to false, TwoFactorAuthMethod.EMAIL to false, @@ -88,11 +88,29 @@ class TwoFactorAuthMethodExtensionTest { TwoFactorAuthMethod.U2F to false, TwoFactorAuthMethod.REMEMBER to false, TwoFactorAuthMethod.DUO_ORGANIZATION to true, - TwoFactorAuthMethod.WEB_AUTH to false, + TwoFactorAuthMethod.WEB_AUTH to true, TwoFactorAuthMethod.RECOVERY_CODE to false, ) - .forEach { (type, isDuo) -> - assertEquals(isDuo, type.isDuo) + .forEach { (type, isContinueButtonEnabled) -> + assertEquals(isContinueButtonEnabled, type.isContinueButtonEnabled) + } + } + + @Test + fun `showPasswordInput returns the expected value`() { + mapOf( + TwoFactorAuthMethod.AUTHENTICATOR_APP to true, + TwoFactorAuthMethod.EMAIL to true, + TwoFactorAuthMethod.DUO to false, + TwoFactorAuthMethod.YUBI_KEY to true, + TwoFactorAuthMethod.U2F to true, + TwoFactorAuthMethod.REMEMBER to true, + TwoFactorAuthMethod.DUO_ORGANIZATION to false, + TwoFactorAuthMethod.WEB_AUTH to false, + TwoFactorAuthMethod.RECOVERY_CODE to true, + ) + .forEach { (type, showPasswordInput) -> + assertEquals(showPasswordInput, type.showPasswordInput) } }