From 64c8a7e5d682b3b29a80287639b7dac85682b92a Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 8 Jan 2026 15:09:05 -0600 Subject: [PATCH] PM-26577: Support multiple schemes for Duo, WebAuthn, and SSO callbacks --- app/src/main/AndroidManifest.xml | 1 + .../auth/repository/AuthRepositoryImpl.kt | 2 + .../data/auth/repository/util/DuoUtils.kt | 7 +- .../data/auth/repository/util/SsoUtils.kt | 14 +- .../data/auth/repository/util/WebAuthUtils.kt | 20 +-- .../util/EnvironmentUrlDataJsonExtensions.kt | 51 +++++++ .../EnterpriseSignOnScreen.kt | 2 +- .../EnterpriseSignOnViewModel.kt | 37 +++-- .../twofactorlogin/TwoFactorLoginScreen.kt | 4 +- .../twofactorlogin/TwoFactorLoginViewModel.kt | 142 ++++++++++-------- .../auth/repository/AuthRepositoryTest.kt | 97 +++++++++++- .../data/auth/repository/util/SsoUtilsTest.kt | 4 +- .../auth/repository/util/WebAuthUtilsTest.kt | 13 +- .../EnvironmentUrlDataJsonExtensionsTest.kt | 119 +++++++++++++++ .../EnterpriseSignOnScreenTest.kt | 9 +- .../EnterpriseSignOnViewModelTest.kt | 30 ++-- .../TwoFactorLoginScreenTest.kt | 15 +- .../TwoFactorLoginViewModelTest.kt | 25 ++- .../disk/model/EnvironmentUrlDataJson.kt | 13 +- .../repository/model/EnvironmentRegion.kt | 1 + .../util/EnvironmentUrlDataJsonExtensions.kt | 34 ++++- .../EnvironmentUrlsDataJsonExtensionsTest.kt | 40 +++++ .../network/api/UnauthenticatedIdentityApi.kt | 1 + .../network/service/IdentityService.kt | 2 + .../network/service/IdentityServiceImpl.kt | 2 + .../network/service/IdentityServiceTest.kt | 6 + .../ui/platform/manager/IntentManager.kt | 5 +- .../ui/platform/manager/IntentManagerImpl.kt | 17 ++- .../manager/intent/model/AuthTabData.kt | 38 +++++ 29 files changed, 597 insertions(+), 154 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/EnvironmentUrlDataJsonExtensions.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/platform/util/EnvironmentUrlDataJsonExtensionsTest.kt create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/manager/intent/model/AuthTabData.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 269eb5cf550..ac62a2639f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -169,6 +169,7 @@ + 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 c6bcf78fbea..4081c6a68c4 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 @@ -14,6 +14,7 @@ import com.bitwarden.core.data.util.flatMap import com.bitwarden.crypto.HashPurpose import com.bitwarden.crypto.Kdf import com.bitwarden.data.datasource.disk.ConfigDiskSource +import com.bitwarden.data.repository.util.appLinksScheme import com.bitwarden.data.repository.util.toEnvironmentUrls import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault import com.bitwarden.network.model.CreateAccountKeysResponseJson @@ -1573,6 +1574,7 @@ class AuthRepositoryImpl( ): LoginResult = identityService .getToken( uniqueAppId = authDiskSource.uniqueAppId, + deeplinkScheme = environmentRepository.environment.environmentUrlData.appLinksScheme, email = email, authModel = authModel, twoFactorData = twoFactorData ?: getRememberedTwoFactorData(email), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtils.kt index 56a1555767c..4aa97283691 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtils.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtils.kt @@ -5,8 +5,7 @@ import android.net.Uri import androidx.browser.auth.AuthTabIntent import com.bitwarden.annotation.OmitFromCoverage -private const val BITWARDEN_EU_HOST: String = "bitwarden.eu" -private const val BITWARDEN_US_HOST: String = "bitwarden.com" +private val BITWARDEN_HOSTS: List = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw") private const val APP_LINK_SCHEME: String = "https" private const val DEEPLINK_SCHEME: String = "bitwarden" private const val CALLBACK: String = "duo-callback" @@ -34,9 +33,7 @@ fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? { } APP_LINK_SCHEME -> { - if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) && - localData.path == "/$CALLBACK" - ) { + if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") { localData.getDuoCallbackTokenResult() } else { null diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SsoUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SsoUtils.kt index 94113ce7899..13f64907bc3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SsoUtils.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SsoUtils.kt @@ -11,31 +11,31 @@ import java.net.URLEncoder import java.security.MessageDigest import java.util.Base64 -private const val BITWARDEN_EU_HOST: String = "bitwarden.eu" -private const val BITWARDEN_US_HOST: String = "bitwarden.com" +private val BITWARDEN_HOSTS: List = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw") private const val APP_LINK_SCHEME: String = "https" private const val DEEPLINK_SCHEME: String = "bitwarden" private const val CALLBACK: String = "sso-callback" -const val SSO_URI: String = "bitwarden://$CALLBACK" - /** * Generates a URI for the SSO custom tab. * * @param identityBaseUrl The base URl for the identity service. + * @param redirectUrl The redirect URI used in the SSO request. * @param organizationIdentifier The SSO organization identifier. * @param token The prevalidated SSO token. * @param state Random state used to verify the validity of the response. * @param codeVerifier A random string used to generate the code challenge. */ +@Suppress("LongParameterList") fun generateUriForSso( identityBaseUrl: String, + redirectUrl: String, organizationIdentifier: String, token: String, state: String, codeVerifier: String, ): Uri { - val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8") + val redirectUri = URLEncoder.encode(redirectUrl, "UTF-8") val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8") val encodedToken = URLEncoder.encode(token, "UTF-8") @@ -81,9 +81,7 @@ fun Intent.getSsoCallbackResult(): SsoCallbackResult? { } APP_LINK_SCHEME -> { - if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) && - localData.path == "/$CALLBACK" - ) { + if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") { localData.getSsoCallbackResult() } else { null diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtils.kt index 8bf85dadf08..eda4938b7bb 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtils.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtils.kt @@ -8,17 +8,13 @@ import com.bitwarden.annotation.OmitFromCoverage 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 BITWARDEN_EU_HOST: String = "bitwarden.eu" -private const val BITWARDEN_US_HOST: String = "bitwarden.com" +private val BITWARDEN_HOSTS: List = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw") private const val APP_LINK_SCHEME: String = "https" private const val DEEPLINK_SCHEME: String = "bitwarden" private const val CALLBACK: String = "webauthn-callback" -private const val CALLBACK_URI = "bitwarden://$CALLBACK" - /** * Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases. * @@ -39,9 +35,7 @@ fun Intent.getWebAuthResultOrNull(): WebAuthResult? { } APP_LINK_SCHEME -> { - if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) && - localData.path == "/$CALLBACK" - ) { + if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") { localData.getWebAuthResult() } else { null @@ -79,29 +73,31 @@ private fun Uri?.getWebAuthResult(): WebAuthResult = /** * Generates a [Uri] to display a web authn challenge for Bitwarden authentication. */ +@Suppress("LongParameterList") fun generateUriForWebAuth( baseUrl: String, + callbackScheme: 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) + put(key = "mobile", value = true) } 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" + "&client=mobile" + + "&v=2" + + "&deeplinkScheme=$callbackScheme" return url.toUri() } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/EnvironmentUrlDataJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/EnvironmentUrlDataJsonExtensions.kt new file mode 100644 index 00000000000..a27c9daf825 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/EnvironmentUrlDataJsonExtensions.kt @@ -0,0 +1,51 @@ +package com.x8bit.bitwarden.data.platform.util + +import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson +import com.bitwarden.data.repository.model.EnvironmentRegion +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData + +/** + * Creates the appropriate Duo [AuthTabData] for the given [EnvironmentUrlDataJson]. + */ +val EnvironmentUrlDataJson.duoAuthTabData: AuthTabData get() = authTabData(kind = "duo") + +/** + * Creates the appropriate WebAuthn [AuthTabData] for the given [EnvironmentUrlDataJson]. + */ +val EnvironmentUrlDataJson.webAuthnAuthTabData: AuthTabData get() = authTabData(kind = "webauthn") + +/** + * Creates the appropriate SSO [AuthTabData] for the given [EnvironmentUrlDataJson]. + */ +val EnvironmentUrlDataJson.ssoAuthTabData: AuthTabData get() = authTabData(kind = "sso") + +private fun EnvironmentUrlDataJson.authTabData( + kind: String, +): AuthTabData = when (this.environmentRegion) { + EnvironmentRegion.UNITED_STATES -> { + AuthTabData.HttpsScheme( + host = "bitwarden.com", + path = "$kind-callback", + ) + } + + EnvironmentRegion.EUROPEAN_UNION -> { + AuthTabData.HttpsScheme( + host = "bitwarden.eu", + path = "$kind-callback", + ) + } + + EnvironmentRegion.INTERNAL -> { + AuthTabData.HttpsScheme( + host = "bitwarden.pw", + path = "$kind-callback", + ) + } + + EnvironmentRegion.SELF_HOSTED -> { + AuthTabData.CustomScheme( + callbackUrl = "bitwarden://$kind-callback", + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt index d7acc1d32ab..e7dbf2a2b88 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt @@ -66,7 +66,7 @@ fun EnterpriseSignOnScreen( is EnterpriseSignOnEvent.NavigateToSsoLogin -> { intentManager.startAuthTab( uri = event.uri, - redirectScheme = event.scheme, + authTabData = event.authTabData, launcher = authTabLaunchers.sso, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt index 6f16ed4ee4f..56a93fe689d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.bitwarden.data.repository.util.baseIdentityUrl import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault import com.bitwarden.ui.platform.base.BaseViewModel +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText @@ -14,11 +15,11 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult -import com.x8bit.bitwarden.data.auth.repository.util.SSO_URI import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForSso import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.platform.util.ssoAuthTabData import com.x8bit.bitwarden.data.platform.util.toUriOrNull import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.utils.generateRandomString @@ -208,7 +209,7 @@ class EnterpriseSignOnViewModel @Inject constructor( sendEvent( EnterpriseSignOnEvent.NavigateToSsoLogin( uri = action.uri, - scheme = action.scheme, + authTabData = action.authTabData, ), ) } @@ -342,14 +343,13 @@ class EnterpriseSignOnViewModel @Inject constructor( if (ssoCallbackResult.state == ssoData.state) { showLoading() viewModelScope.launch { - val result = authRepository - .login( - email = savedStateHandle.toEnterpriseSignOnArgs().emailAddress, - ssoCode = ssoCallbackResult.code, - ssoCodeVerifier = ssoData.codeVerifier, - ssoRedirectUri = SSO_URI, - organizationIdentifier = state.orgIdentifierInput, - ) + val result = authRepository.login( + email = savedStateHandle.toEnterpriseSignOnArgs().emailAddress, + ssoCode = ssoCallbackResult.code, + ssoCodeVerifier = ssoData.codeVerifier, + ssoRedirectUri = ssoData.redirectUri, + organizationIdentifier = state.orgIdentifierInput, + ) sendAction(EnterpriseSignOnAction.Internal.OnLoginResult(result)) } } else { @@ -385,18 +385,22 @@ class EnterpriseSignOnViewModel @Inject constructor( ) { val codeVerifier = generatorRepository.generateRandomString(RANDOM_STRING_LENGTH) + val environmentData = environmentRepository.environment.environmentUrlData + val authTabData = environmentData.ssoAuthTabData // Save this for later so that we can validate the SSO callback response val generatedSsoState = generatorRepository .generateRandomString(RANDOM_STRING_LENGTH) .also { ssoResponseData = SsoResponseData( + redirectUri = authTabData.callbackUrl, codeVerifier = codeVerifier, state = it, ) } val uri = generateUriForSso( - identityBaseUrl = environmentRepository.environment.environmentUrlData.baseIdentityUrl, + identityBaseUrl = environmentData.baseIdentityUrl, + redirectUrl = authTabData.callbackUrl, organizationIdentifier = organizationIdentifier, token = prevalidateSsoResult.token, state = generatedSsoState, @@ -408,7 +412,7 @@ class EnterpriseSignOnViewModel @Inject constructor( sendAction( EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult( uri = uri, - scheme = "bitwarden", + authTabData = authTabData, ), ) } @@ -518,7 +522,7 @@ sealed class EnterpriseSignOnEvent { */ data class NavigateToSsoLogin( val uri: Uri, - val scheme: String, + val authTabData: AuthTabData, ) : EnterpriseSignOnEvent() /** @@ -580,7 +584,10 @@ sealed class EnterpriseSignOnAction { /** * A [uri] has been generated to request an SSO result. */ - data class OnGenerateUriForSsoResult(val uri: Uri, val scheme: String) : Internal() + data class OnGenerateUriForSsoResult( + val uri: Uri, + val authTabData: AuthTabData, + ) : Internal() /** * A login result has been received. @@ -612,6 +619,7 @@ sealed class EnterpriseSignOnAction { /** * Data needed by the SSO flow to verify and continue the process after receiving a response. * + * @property redirectUri The redirect URI used in the SSO request. * @property state A "state" maintained throughout the SSO process to verify that the response from * the server is valid and matches what was originally sent in the request. * @property codeVerifier A random string used to generate the code challenge for the initial SSO @@ -619,6 +627,7 @@ sealed class EnterpriseSignOnAction { */ @Parcelize data class SsoResponseData( + val redirectUri: String, val state: String, val codeVerifier: String, ) : Parcelable diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt index 071436edecb..00119e6e3c7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt @@ -104,7 +104,7 @@ fun TwoFactorLoginScreen( is TwoFactorLoginEvent.NavigateToDuo -> { intentManager.startAuthTab( uri = event.uri, - redirectScheme = event.scheme, + authTabData = event.authTabData, launcher = authTabLaunchers.duo, ) } @@ -112,7 +112,7 @@ fun TwoFactorLoginScreen( is TwoFactorLoginEvent.NavigateToWebAuth -> { intentManager.startAuthTab( uri = event.uri, - redirectScheme = event.scheme, + authTabData = event.authTabData, launcher = authTabLaunchers.webAuthn, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt index 93a02ceba15..206186572d8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt @@ -15,6 +15,7 @@ import com.bitwarden.network.util.twoFactorDisplayEmail import com.bitwarden.network.util.twoFactorDuoAuthUrl import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText @@ -26,6 +27,8 @@ import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult 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.util.duoAuthTabData +import com.x8bit.bitwarden.data.platform.util.webAuthnAuthTabData 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.isContinueButtonEnabled @@ -173,71 +176,15 @@ class TwoFactorLoginViewModel @Inject constructor( } /** - * Navigates to the Duo webpage if appropriate, else processes the login. + * Navigates to the two-factor auth webpage if appropriate, else processes the login. */ - @Suppress("LongMethod") private fun handleContinueButtonClick() { 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. - authUrl - ?.let { - sendEvent( - event = TwoFactorLoginEvent.NavigateToDuo( - uri = it.toUri(), - scheme = "bitwarden", - ), - ) - } - ?: mutableStateFlow.update { - @Suppress("MaxLineLength") - it.copy( - dialogState = TwoFactorLoginState.DialogState.Error( - title = BitwardenString.an_error_has_occurred.asText(), - message = BitwardenString - .error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance - .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 = BitwardenString.fido2_title, - ), - buttonText = resourceManager.getString( - resId = BitwardenString.fido2_authenticate_web_authn, - ), - returnButtonText = resourceManager.getString( - resId = BitwardenString.fido2_return_to_app, - ), - ) - TwoFactorLoginEvent.NavigateToWebAuth(uri = uri, scheme = "bitwarden") - } - ?: TwoFactorLoginEvent.ShowSnackbar( - message = BitwardenString - .there_was_an_error_starting_web_authn_two_factor_authentication - .asText(), - ), - ) - } + -> handleDuoContinueButtonClick() + TwoFactorAuthMethod.WEB_AUTH -> handleWebAuthnContinueButtonClick() TwoFactorAuthMethod.AUTHENTICATOR_APP, TwoFactorAuthMethod.EMAIL, TwoFactorAuthMethod.YUBI_KEY, @@ -248,6 +195,73 @@ class TwoFactorLoginViewModel @Inject constructor( } } + /** + * Navigates to the Duo webpage if appropriate, or displays the error dialog. + */ + private fun handleDuoContinueButtonClick() { + // The url should not be empty unless the environment is somehow not supported. + authRepository + .twoFactorResponse + .twoFactorDuoAuthUrl + ?.toUri() + ?.let { + val environmentData = environmentRepository.environment.environmentUrlData + sendEvent( + event = TwoFactorLoginEvent.NavigateToDuo( + uri = it, + authTabData = environmentData.duoAuthTabData, + ), + ) + } + ?: mutableStateFlow.update { + @Suppress("MaxLineLength") + it.copy( + dialogState = TwoFactorLoginState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString + .error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance + .asText(), + ), + ) + } + } + + /** + * Navigates to the Web Authn webpage if appropriate, or displays the error snackbar. + */ + private fun handleWebAuthnContinueButtonClick() { + sendEvent( + event = authRepository + .twoFactorResponse + ?.authMethodsData + ?.get(TwoFactorAuthMethod.WEB_AUTH) + ?.let { + val environmentData = environmentRepository.environment.environmentUrlData + val authTabData = environmentData.webAuthnAuthTabData + val uri = generateUriForWebAuth( + baseUrl = environmentData.baseWebVaultUrlOrDefault, + callbackScheme = authTabData.callbackScheme, + data = it, + headerText = resourceManager.getString( + resId = BitwardenString.fido2_title, + ), + buttonText = resourceManager.getString( + resId = BitwardenString.fido2_authenticate_web_authn, + ), + returnButtonText = resourceManager.getString( + resId = BitwardenString.fido2_return_to_app, + ), + ) + TwoFactorLoginEvent.NavigateToWebAuth(uri = uri, authTabData = authTabData) + } + ?: TwoFactorLoginEvent.ShowSnackbar( + message = BitwardenString + .there_was_an_error_starting_web_authn_two_factor_authentication + .asText(), + ), + ) + } + /** * Dismiss the view. */ @@ -677,12 +691,18 @@ sealed class TwoFactorLoginEvent { /** * Navigates to the Duo 2-factor authentication screen. */ - data class NavigateToDuo(val uri: Uri, val scheme: String) : TwoFactorLoginEvent() + data class NavigateToDuo( + val uri: Uri, + val authTabData: AuthTabData, + ) : TwoFactorLoginEvent() /** * Navigates to the WebAuth authentication screen. */ - data class NavigateToWebAuth(val uri: Uri, val scheme: String) : TwoFactorLoginEvent() + data class NavigateToWebAuth( + val uri: Uri, + val authTabData: AuthTabData, + ) : TwoFactorLoginEvent() /** * Navigates to the recovery code help page. 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 4bfa97937fb..939fb858534 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 @@ -190,11 +190,9 @@ class AuthRepositoryTest { } private val fakeAuthDiskSource = FakeAuthDiskSource() private val fakeSettingsDiskSource = FakeSettingsDiskSource() - private val fakeEnvironmentRepository = - FakeEnvironmentRepository() - .apply { - environment = Environment.Us - } + private val fakeEnvironmentRepository = FakeEnvironmentRepository().apply { + environment = Environment.Us + } private val settingsRepository: SettingsRepository = mockk { every { setDefaultsIfNecessary(any()) } just runs every { hasUserLoggedInOrCreatedAccount = true } just runs @@ -411,6 +409,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -493,6 +492,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.unlockVault( accountCryptographicState = createWrappedAccountCryptographicState( @@ -1761,6 +1761,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns error.asFailure() val result = repository.login(email = EMAIL, password = PASSWORD) @@ -1775,6 +1776,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -1795,6 +1797,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns RuntimeException().asFailure() val result = repository.login(email = EMAIL, password = PASSWORD) @@ -1818,6 +1821,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns SSLHandshakeException("error").asFailure() val result = repository.login(email = EMAIL, password = PASSWORD) @@ -1851,6 +1855,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .Invalid( @@ -1872,6 +1877,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -1891,6 +1897,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .Invalid( @@ -1924,6 +1931,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -1987,6 +1995,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.unlockVault( accountCryptographicState = createWrappedAccountCryptographicState( @@ -2043,6 +2052,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -2100,6 +2110,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.unlockVault( accountCryptographicState = createWrappedAccountCryptographicState( @@ -2149,6 +2160,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() val error = Throwable("Fail") @@ -2219,6 +2231,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.unlockVault( @@ -2284,6 +2297,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -2312,6 +2326,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1) @@ -2370,6 +2385,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -2443,6 +2459,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.unlockVault( accountCryptographicState = createWrappedAccountCryptographicState( @@ -2494,6 +2511,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .TwoFactorRequired( @@ -2522,6 +2540,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -2539,6 +2558,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .TwoFactorRequired( @@ -2558,6 +2578,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } @@ -2574,6 +2595,7 @@ class AuthRepositoryTest { ), uniqueAppId = UNIQUE_APP_ID, twoFactorData = TWO_FACTOR_DATA, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -2646,6 +2668,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns twoFactorResponse.asSuccess() @@ -2664,6 +2687,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } @@ -2680,6 +2704,7 @@ class AuthRepositoryTest { ), uniqueAppId = UNIQUE_APP_ID, twoFactorData = TWO_FACTOR_DATA, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() val error = Throwable("Fail") @@ -2755,6 +2780,7 @@ class AuthRepositoryTest { ), uniqueAppId = UNIQUE_APP_ID, twoFactorData = rememberedTwoFactorData, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -2815,6 +2841,7 @@ class AuthRepositoryTest { ), uniqueAppId = UNIQUE_APP_ID, twoFactorData = rememberedTwoFactorData, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.unlockVault( accountCryptographicState = createWrappedAccountCryptographicState( @@ -2883,6 +2910,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .Invalid( @@ -2905,6 +2933,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -2921,6 +2950,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns error.asFailure() val result = repository.login( @@ -2942,6 +2972,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -2958,6 +2989,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .Invalid( @@ -2989,6 +3021,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -3007,6 +3040,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -3076,6 +3110,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() vaultRepository.unlockVault( @@ -3131,6 +3166,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -3200,6 +3236,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() vaultRepository.unlockVault( @@ -3252,6 +3289,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .TwoFactorRequired( @@ -3287,6 +3325,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -3304,6 +3343,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .TwoFactorRequired( @@ -3330,6 +3370,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } @@ -3347,6 +3388,7 @@ class AuthRepositoryTest { ), uniqueAppId = UNIQUE_APP_ID, twoFactorData = TWO_FACTOR_DATA, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -3414,6 +3456,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns error.asFailure() val result = repository.login( @@ -3434,6 +3477,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -3449,6 +3493,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .Invalid( @@ -3476,6 +3521,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -3494,6 +3540,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -3533,6 +3580,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1) @@ -3568,6 +3616,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -3598,6 +3647,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() } @@ -3630,6 +3680,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -3666,6 +3717,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) keyConnectorManager.getMasterKeyFromKeyConnector( url = keyConnectorUrl, @@ -3699,6 +3751,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -3753,6 +3806,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) keyConnectorManager.getMasterKeyFromKeyConnector( url = keyConnectorUrl, @@ -3809,6 +3863,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -3863,6 +3918,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) keyConnectorManager.getMasterKeyFromKeyConnector( url = keyConnectorUrl, @@ -3918,6 +3974,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -3963,6 +4020,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) keyConnectorManager.migrateNewUserToKeyConnector( url = keyConnectorUrl, @@ -4007,6 +4065,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -4070,6 +4129,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) keyConnectorManager.migrateNewUserToKeyConnector( url = keyConnectorUrl, @@ -4129,6 +4189,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() every { @@ -4180,6 +4241,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -4246,6 +4308,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() } @@ -4270,6 +4333,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -4338,6 +4402,7 @@ class AuthRepositoryTest { accessCode = DEVICE_ACCESS_CODE, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() vaultRepository.unlockVault( @@ -4396,6 +4461,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -4430,6 +4496,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() } @@ -4492,6 +4559,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -4526,6 +4594,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.unlockVault( accountCryptographicState = createWrappedAccountCryptographicState( @@ -4617,6 +4686,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -4650,6 +4720,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.unlockVault( accountCryptographicState = createWrappedAccountCryptographicState( @@ -4702,6 +4773,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -4743,6 +4815,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() } @@ -4770,6 +4843,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .TwoFactorRequired( @@ -4804,6 +4878,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } } @@ -4821,6 +4896,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .TwoFactorRequired( @@ -4847,6 +4923,7 @@ class AuthRepositoryTest { ssoRedirectUri = SSO_REDIRECT_URI, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } @@ -4864,6 +4941,7 @@ class AuthRepositoryTest { ), uniqueAppId = UNIQUE_APP_ID, twoFactorData = TWO_FACTOR_DATA, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -4909,6 +4987,7 @@ class AuthRepositoryTest { ), uniqueAppId = UNIQUE_APP_ID, twoFactorData = rememberedTwoFactorData, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { vaultRepository.syncIfNecessary() } just runs @@ -4949,6 +5028,7 @@ class AuthRepositoryTest { ), uniqueAppId = UNIQUE_APP_ID, twoFactorData = rememberedTwoFactorData, + deeplinkScheme = DEEPLINK_SCHEME, ) vaultRepository.syncIfNecessary() } @@ -6323,6 +6403,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .TwoFactorRequired( @@ -6342,6 +6423,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } @@ -6384,6 +6466,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns GetTokenResponseJson .TwoFactorRequired( @@ -6403,6 +6486,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } @@ -7245,6 +7329,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -7323,6 +7408,7 @@ class AuthRepositoryTest { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) } returns successResponse.asSuccess() coEvery { @@ -7445,6 +7531,7 @@ class AuthRepositoryTest { Instant.parse("2023-10-27T12:00:00Z"), ZoneOffset.UTC, ) + private const val DEEPLINK_SCHEME = "https" private const val UNIQUE_APP_ID = "testUniqueAppId" private const val NAME = "Example Name" private const val EMAIL = "test@bitwarden.com" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SsoUtilsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SsoUtilsTest.kt index 71fcbb6e7b0..d522cc4bc6d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SsoUtilsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SsoUtilsTest.kt @@ -4,15 +4,16 @@ import android.content.Intent import android.net.Uri import io.mockk.every import io.mockk.mockk -import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test class SsoUtilsTest { @Test fun `generateUriForSso should generate the correct URI`() { val identityBaseUrl = "https://identity.bitwarden.com" + val redirectUrl = "https://bitwarden.com/sso-callback" val organizationIdentifier = "Test Organization" val token = "Test Token" val state = "test_state" @@ -31,6 +32,7 @@ class SsoUtilsTest { val uri = generateUriForSso( identityBaseUrl = identityBaseUrl, + redirectUrl = redirectUrl, organizationIdentifier = organizationIdentifier, token = token, state = state, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtilsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtilsTest.kt index 7b0661a8576..fa48276d59e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtilsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtilsTest.kt @@ -2,20 +2,20 @@ package com.x8bit.bitwarden.data.auth.repository.util import android.content.Intent import android.net.Uri -import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import io.mockk.every import io.mockk.mockk import kotlinx.serialization.json.JsonObject -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test -class WebAuthUtilsTest : BitwardenComposeTest() { +class WebAuthUtilsTest { @Test fun `generateUriForWebAuth should return valid Uri`() { val baseUrl = "https://vault.bitwarden.com" val actualUri = generateUriForWebAuth( baseUrl = baseUrl, + callbackScheme = "https", data = JsonObject(emptyMap()), headerText = "header", buttonText = "button", @@ -26,8 +26,9 @@ class WebAuthUtilsTest : BitwardenComposeTest() { "?data=eyJjYWxsYmFja1VyaSI6ImJpdHdhcmRlbjovL3dlYmF1dGhuLWNhbGxiYWNrIiwiZ" + "GF0YSI6Int9IiwiaGVhZGVyVGV4dCI6ImhlYWRlciIsImJ0blRleHQiOiJidXR0b24iLCJi" + "dG5SZXR1cm5UZXh0IjoicmV0dXJuQnV0dG9uIn0=" + - "&parent=bitwarden%3A%2F%2Fwebauthn-callback" + - "&v=2" + "&client=mobile" + + "&v=2" + + "&deeplinkScheme=https" val expectedUri = Uri.parse(expectedUrl) assertEquals(expectedUri, actualUri) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/util/EnvironmentUrlDataJsonExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/util/EnvironmentUrlDataJsonExtensionsTest.kt new file mode 100644 index 00000000000..4a146d2b636 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/util/EnvironmentUrlDataJsonExtensionsTest.kt @@ -0,0 +1,119 @@ +package com.x8bit.bitwarden.data.platform.util + +import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class EnvironmentUrlDataJsonExtensionsTest { + + @Test + fun `duoAuthTabData should return the correct AuthTabData for all environments`() { + assertEquals( + AuthTabData.HttpsScheme( + host = "bitwarden.com", + path = "duo-callback", + ), + EnvironmentUrlDataJson.DEFAULT_US.duoAuthTabData, + ) + assertEquals( + AuthTabData.HttpsScheme( + host = "bitwarden.eu", + path = "duo-callback", + ), + EnvironmentUrlDataJson.DEFAULT_EU.duoAuthTabData, + ) + assertEquals( + AuthTabData.HttpsScheme( + host = "bitwarden.pw", + path = "duo-callback", + ), + DEFAULT_INTERNAL_ENVIRONMENT_URL_DATA.duoAuthTabData, + ) + assertEquals( + AuthTabData.CustomScheme( + callbackUrl = "bitwarden://duo-callback", + callbackScheme = "bitwarden", + ), + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.duoAuthTabData, + ) + } + + @Test + fun `webAuthnAuthTabData should return the correct AuthTabData for all environments`() { + assertEquals( + AuthTabData.HttpsScheme( + host = "bitwarden.com", + path = "webauthn-callback", + ), + EnvironmentUrlDataJson.DEFAULT_US.webAuthnAuthTabData, + ) + assertEquals( + AuthTabData.HttpsScheme( + host = "bitwarden.eu", + path = "webauthn-callback", + ), + EnvironmentUrlDataJson.DEFAULT_EU.webAuthnAuthTabData, + ) + assertEquals( + AuthTabData.HttpsScheme( + host = "bitwarden.pw", + path = "webauthn-callback", + ), + DEFAULT_INTERNAL_ENVIRONMENT_URL_DATA.webAuthnAuthTabData, + ) + assertEquals( + AuthTabData.CustomScheme( + callbackUrl = "bitwarden://webauthn-callback", + callbackScheme = "bitwarden", + ), + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.webAuthnAuthTabData, + ) + } + + @Test + fun `ssoAuthTabData should return the correct AuthTabData for all environments`() { + assertEquals( + AuthTabData.HttpsScheme( + host = "bitwarden.com", + path = "sso-callback", + ), + EnvironmentUrlDataJson.DEFAULT_US.ssoAuthTabData, + ) + assertEquals( + AuthTabData.HttpsScheme( + host = "bitwarden.eu", + path = "sso-callback", + ), + EnvironmentUrlDataJson.DEFAULT_EU.ssoAuthTabData, + ) + assertEquals( + AuthTabData.HttpsScheme( + host = "bitwarden.pw", + path = "sso-callback", + ), + DEFAULT_INTERNAL_ENVIRONMENT_URL_DATA.ssoAuthTabData, + ) + assertEquals( + AuthTabData.CustomScheme( + callbackUrl = "bitwarden://sso-callback", + callbackScheme = "bitwarden", + ), + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.ssoAuthTabData, + ) + } +} + +private val DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA = EnvironmentUrlDataJson( + base = "base", + api = "api", + identity = "identity", + icon = "icon", + notifications = "notifications", + webVault = "webVault", + events = "events", +) + +private val DEFAULT_INTERNAL_ENVIRONMENT_URL_DATA = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.copy( + base = "qa.vault.bitwarden.pw", +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt index b5ac60270d9..4a81afb229f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.platform.manager.IntentManager +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest @@ -45,7 +46,7 @@ class EnterpriseSignOnScreenTest : BitwardenComposeTest() { } private val intentManager: IntentManager = mockk { - every { startAuthTab(uri = any(), redirectScheme = any(), launcher = any()) } just runs + every { startAuthTab(uri = any(), authTabData = any(), launcher = any()) } just runs } @Before @@ -114,12 +115,12 @@ class EnterpriseSignOnScreenTest : BitwardenComposeTest() { @Test fun `NavigateToSsoLogin should call startCustomTabsActivity`() { val ssoUri = Uri.parse("https://identity.bitwarden.com/sso-test") - val scheme = "bitwarden" - mutableEventFlow.tryEmit(EnterpriseSignOnEvent.NavigateToSsoLogin(ssoUri, scheme)) + val authTabData = mockk() + mutableEventFlow.tryEmit(EnterpriseSignOnEvent.NavigateToSsoLogin(ssoUri, authTabData)) verify(exactly = 1) { intentManager.startAuthTab( uri = ssoUri, - redirectScheme = scheme, + authTabData = authTabData, launcher = ssoLauncher, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt index b79cfc90182..03100c4ff68 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt @@ -8,6 +8,7 @@ import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson import com.bitwarden.data.repository.model.Environment import com.bitwarden.network.model.VerifiedOrganizationDomainSsoDetailsResponse import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -163,7 +164,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val ssoUri: Uri = mockk() every { - generateUriForSso(any(), any(), any(), any(), any()) + generateUriForSso(any(), any(), any(), any(), any(), any()) } returns ssoUri val viewModel = createViewModel(state) @@ -186,7 +187,13 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ) assertEquals( - EnterpriseSignOnEvent.NavigateToSsoLogin(uri = ssoUri, scheme = "bitwarden"), + EnterpriseSignOnEvent.NavigateToSsoLogin( + uri = ssoUri, + authTabData = AuthTabData.HttpsScheme( + host = "bitwarden.com", + path = "sso-callback", + ), + ), eventFlow.awaitItem(), ) } @@ -385,7 +392,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { email = "test@gmail.com", ssoCode = "lmn", ssoCodeVerifier = "def", - ssoRedirectUri = "bitwarden://sso-callback", + ssoRedirectUri = "https://bitwarden.com/sso-callback", organizationIdentifier = orgIdentifier, ) } @@ -451,7 +458,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { email = "test@gmail.com", ssoCode = "lmn", ssoCodeVerifier = "def", - ssoRedirectUri = "bitwarden://sso-callback", + ssoRedirectUri = "https://bitwarden.com/sso-callback", organizationIdentifier = orgIdentifier, ) } @@ -474,7 +481,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ) val viewModel = createViewModel( - ssoData = DEFAULT_SSO_DATA, + ssoData = DEFAULT_SSO_DATA.copy(redirectUri = "bitwarden://sso-callback"), ) val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") @@ -548,7 +555,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ) val viewModel = createViewModel( - ssoData = DEFAULT_SSO_DATA, + ssoData = DEFAULT_SSO_DATA.copy(redirectUri = "bitwarden://sso-callback"), ) val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") @@ -622,7 +629,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ) val viewModel = createViewModel( - ssoData = DEFAULT_SSO_DATA, + ssoData = DEFAULT_SSO_DATA.copy(redirectUri = "bitwarden://sso-callback"), ) val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") @@ -739,7 +746,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { email = "test@gmail.com", ssoCode = "lmn", ssoCodeVerifier = "def", - ssoRedirectUri = "bitwarden://sso-callback", + ssoRedirectUri = "https://bitwarden.com/sso-callback", organizationIdentifier = orgIdentifier, ) } @@ -792,7 +799,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { email = "test@gmail.com", ssoCode = "lmn", ssoCodeVerifier = "def", - ssoRedirectUri = "bitwarden://sso-callback", + ssoRedirectUri = "https://bitwarden.com/sso-callback", organizationIdentifier = "Bitwarden", ) } @@ -848,7 +855,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { email = "test@gmail.com", ssoCode = "lmn", ssoCodeVerifier = "def", - ssoRedirectUri = "bitwarden://sso-callback", + ssoRedirectUri = "https://bitwarden.com/sso-callback", organizationIdentifier = "Bitwarden", ) } @@ -912,7 +919,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { email = "test@gmail.com", ssoCode = "lmn", ssoCodeVerifier = "def", - ssoRedirectUri = "bitwarden://sso-callback", + ssoRedirectUri = "https://bitwarden.com/sso-callback", organizationIdentifier = orgIdentifier, ) } @@ -1269,6 +1276,7 @@ private val DEFAULT_STATE = EnterpriseSignOnState( orgIdentifierInput = "", ) private val DEFAULT_SSO_DATA = SsoResponseData( + redirectUri = "https://bitwarden.com/sso-callback", state = "abc", codeVerifier = "def", ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt index a88f0eed279..c91c3761f8e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt @@ -19,6 +19,7 @@ import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.network.model.TwoFactorAuthMethod import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData import com.bitwarden.ui.platform.manager.IntentManager +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManager @@ -39,7 +40,7 @@ class TwoFactorLoginScreenTest : BitwardenComposeTest() { private val webAuthnLauncher: ActivityResultLauncher = mockk() private val intentManager = mockk { every { launchUri(uri = any()) } just runs - every { startAuthTab(uri = any(), redirectScheme = any(), launcher = any()) } just runs + every { startAuthTab(uri = any(), authTabData = any(), launcher = any()) } just runs } private val nfcManager: NfcManager = mockk { every { start() } just runs @@ -283,12 +284,12 @@ class TwoFactorLoginScreenTest : BitwardenComposeTest() { @Test fun `NavigateToDuo should call intentManager startAuthTab`() { val mockUri = mockk() - val scheme = "bitwarden" - mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToDuo(mockUri, scheme)) + val authTabData = mockk() + mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToDuo(mockUri, authTabData)) verify(exactly = 1) { intentManager.startAuthTab( uri = mockUri, - redirectScheme = scheme, + authTabData = authTabData, launcher = duoLauncher, ) } @@ -297,12 +298,12 @@ class TwoFactorLoginScreenTest : BitwardenComposeTest() { @Test fun `NavigateToWebAuth should call intentManager startCustomTabsActivity`() { val mockUri = mockk() - val scheme = "bitwarden" - mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToWebAuth(mockUri, scheme)) + val authTabData = mockk() + mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToWebAuth(mockUri, authTabData)) verify(exactly = 1) { intentManager.startAuthTab( uri = mockUri, - redirectScheme = scheme, + authTabData = authTabData, launcher = webAuthnLauncher, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt index f27318fc74a..162decd964e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt @@ -6,11 +6,13 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.repository.model.Environment +import com.bitwarden.data.repository.util.appLinksScheme import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault import com.bitwarden.network.model.GetTokenResponseJson import com.bitwarden.network.model.TwoFactorAuthMethod import com.bitwarden.network.model.TwoFactorDataModel import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -28,7 +30,6 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic -import io.mockk.verify import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject @@ -418,22 +419,25 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { ) every { authRepository.twoFactorResponse } returns response val mockkUri = mockk() + every { "bitwarden.com".toUri() } returns mockkUri val viewModel = createViewModel( state = DEFAULT_STATE.copy( authMethod = TwoFactorAuthMethod.DUO, ), ) - every { Uri.parse("bitwarden.com") } returns mockkUri viewModel.eventFlow.test { viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick) assertEquals( - TwoFactorLoginEvent.NavigateToDuo(uri = mockkUri, scheme = "bitwarden"), + TwoFactorLoginEvent.NavigateToDuo( + uri = mockkUri, + authTabData = AuthTabData.HttpsScheme( + host = "bitwarden.com", + path = "duo-callback", + ), + ), awaitItem(), ) } - verify { - Uri.parse("bitwarden.com") - } } @Suppress("MaxLineLength") @@ -500,6 +504,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { every { generateUriForWebAuth( baseUrl = Environment.Us.environmentUrlData.baseWebVaultUrlOrDefault, + callbackScheme = Environment.Us.environmentUrlData.appLinksScheme, data = data, headerText = headerText, buttonText = buttonText, @@ -512,7 +517,13 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick) assertEquals( - TwoFactorLoginEvent.NavigateToWebAuth(uri = mockkUri, scheme = "bitwarden"), + TwoFactorLoginEvent.NavigateToWebAuth( + uri = mockkUri, + authTabData = AuthTabData.HttpsScheme( + host = "bitwarden.com", + path = "webauthn-callback", + ), + ), awaitItem(), ) } diff --git a/data/src/main/kotlin/com/bitwarden/data/datasource/disk/model/EnvironmentUrlDataJson.kt b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/model/EnvironmentUrlDataJson.kt index 140236a2a64..9ee4f12a235 100644 --- a/data/src/main/kotlin/com/bitwarden/data/datasource/disk/model/EnvironmentUrlDataJson.kt +++ b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/model/EnvironmentUrlDataJson.kt @@ -49,11 +49,22 @@ data class EnvironmentUrlDataJson( get() = when (base) { DEFAULT_US.base -> EnvironmentRegion.UNITED_STATES DEFAULT_EU.base -> EnvironmentRegion.EUROPEAN_UNION - else -> EnvironmentRegion.SELF_HOSTED + else -> { + if (base.contains(BITWARDEN_INTERNAL_DOMAIN)) { + EnvironmentRegion.INTERNAL + } else { + EnvironmentRegion.SELF_HOSTED + } + } } @Suppress("UndocumentedPublicClass") companion object { + /** + * The domain used for internal Bitwarden environments. + */ + private const val BITWARDEN_INTERNAL_DOMAIN: String = "bitwarden.pw" + /** * Default [EnvironmentUrlDataJson] for the US region. */ diff --git a/data/src/main/kotlin/com/bitwarden/data/repository/model/EnvironmentRegion.kt b/data/src/main/kotlin/com/bitwarden/data/repository/model/EnvironmentRegion.kt index 250eb979603..a24f6d64c6e 100644 --- a/data/src/main/kotlin/com/bitwarden/data/repository/model/EnvironmentRegion.kt +++ b/data/src/main/kotlin/com/bitwarden/data/repository/model/EnvironmentRegion.kt @@ -6,5 +6,6 @@ package com.bitwarden.data.repository.model enum class EnvironmentRegion { UNITED_STATES, EUROPEAN_UNION, + INTERNAL, SELF_HOSTED, } diff --git a/data/src/main/kotlin/com/bitwarden/data/repository/util/EnvironmentUrlDataJsonExtensions.kt b/data/src/main/kotlin/com/bitwarden/data/repository/util/EnvironmentUrlDataJsonExtensions.kt index ecc0a1a1ae2..ecb47ad9253 100644 --- a/data/src/main/kotlin/com/bitwarden/data/repository/util/EnvironmentUrlDataJsonExtensions.kt +++ b/data/src/main/kotlin/com/bitwarden/data/repository/util/EnvironmentUrlDataJsonExtensions.kt @@ -24,13 +24,28 @@ val EnvironmentUrlDataJson.baseApiUrl: String get() = when (this.environmentRegion) { EnvironmentRegion.UNITED_STATES -> DEFAULT_US_API_URL EnvironmentRegion.EUROPEAN_UNION -> DEFAULT_EU_API_URL - EnvironmentRegion.SELF_HOSTED -> { + EnvironmentRegion.INTERNAL, + EnvironmentRegion.SELF_HOSTED, + -> { this.api.sanitizeUrl ?: this.base.sanitizeUrl?.let { "$it/api" } ?: DEFAULT_US_API_URL } } +/** + * Returns the scheme used for app-links within the app. + */ +val EnvironmentUrlDataJson.appLinksScheme: String + get() = when (this.environmentRegion) { + EnvironmentRegion.UNITED_STATES, + EnvironmentRegion.EUROPEAN_UNION, + EnvironmentRegion.INTERNAL, + -> "https" + + EnvironmentRegion.SELF_HOSTED -> "bitwarden" + } + /** * Returns the base events URL or the default value if one is not present. */ @@ -38,7 +53,9 @@ val EnvironmentUrlDataJson.baseEventsUrl: String get() = when (this.environmentRegion) { EnvironmentRegion.UNITED_STATES -> DEFAULT_US_EVENTS_URL EnvironmentRegion.EUROPEAN_UNION -> DEFAULT_EU_EVENTS_URL - EnvironmentRegion.SELF_HOSTED -> { + EnvironmentRegion.INTERNAL, + EnvironmentRegion.SELF_HOSTED, + -> { this.events.sanitizeUrl ?: this.base.sanitizeUrl?.let { "$it/events" } ?: DEFAULT_US_EVENTS_URL @@ -52,7 +69,9 @@ val EnvironmentUrlDataJson.baseIdentityUrl: String get() = when (this.environmentRegion) { EnvironmentRegion.UNITED_STATES -> DEFAULT_US_IDENTITY_URL EnvironmentRegion.EUROPEAN_UNION -> DEFAULT_EU_IDENTITY_URL - EnvironmentRegion.SELF_HOSTED -> { + EnvironmentRegion.INTERNAL, + EnvironmentRegion.SELF_HOSTED, + -> { this.identity.sanitizeUrl ?: this.base.sanitizeUrl?.let { "$it/identity" } ?: DEFAULT_US_IDENTITY_URL @@ -68,7 +87,9 @@ val EnvironmentUrlDataJson.baseWebVaultUrlOrNull: String? get() = when (this.environmentRegion) { EnvironmentRegion.UNITED_STATES -> DEFAULT_US_WEB_VAULT_URL EnvironmentRegion.EUROPEAN_UNION -> DEFAULT_EU_WEB_VAULT_URL - EnvironmentRegion.SELF_HOSTED -> this.webVault.sanitizeUrl ?: this.base.sanitizeUrl + EnvironmentRegion.INTERNAL, + EnvironmentRegion.SELF_HOSTED, + -> this.webVault.sanitizeUrl ?: this.base.sanitizeUrl } /** @@ -86,6 +107,7 @@ val EnvironmentUrlDataJson.baseWebSendUrl: String get() = when (this.environmentRegion) { EnvironmentRegion.UNITED_STATES -> DEFAULT_US_WEB_SEND_URL EnvironmentRegion.EUROPEAN_UNION, + EnvironmentRegion.INTERNAL, EnvironmentRegion.SELF_HOSTED, -> this.baseWebVaultUrlOrNull?.let { "$it/#/send/" } ?: DEFAULT_US_WEB_SEND_URL } @@ -106,7 +128,9 @@ val EnvironmentUrlDataJson.baseIconUrl: String get() = when (this.environmentRegion) { EnvironmentRegion.UNITED_STATES -> DEFAULT_US_ICON_URL EnvironmentRegion.EUROPEAN_UNION -> DEFAULT_EU_ICON_URL - EnvironmentRegion.SELF_HOSTED -> { + EnvironmentRegion.INTERNAL, + EnvironmentRegion.SELF_HOSTED, + -> { this.icon.sanitizeUrl ?: this.base.sanitizeUrl?.let { "$it/icons" } ?: DEFAULT_US_ICON_URL diff --git a/data/src/test/kotlin/com/bitwarden/data/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt b/data/src/test/kotlin/com/bitwarden/data/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt index 6f6a4a982a6..9a171732af9 100644 --- a/data/src/test/kotlin/com/bitwarden/data/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt +++ b/data/src/test/kotlin/com/bitwarden/data/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt @@ -336,6 +336,46 @@ class EnvironmentUrlsDataJsonExtensionsTest { DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.toBaseWebVaultImportUrl, ) } + + @Test + fun `appLinksScheme should return the correct scheme for US environment`() { + val expectedScheme = "https" + + assertEquals( + expectedScheme, + EnvironmentUrlDataJson.DEFAULT_US.appLinksScheme, + ) + } + + @Test + fun `appLinksScheme should return the correct scheme for EU environment`() { + val expectedScheme = "https" + + assertEquals( + expectedScheme, + EnvironmentUrlDataJson.DEFAULT_EU.appLinksScheme, + ) + } + + @Test + fun `appLinksScheme should return the correct scheme for internal environment`() { + val expectedScheme = "https" + + assertEquals( + expectedScheme, + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.copy(base = "qa.vault.bitwarden.pw").appLinksScheme, + ) + } + + @Test + fun `appLinksScheme should return the correct scheme for custom environment`() { + val expectedScheme = "bitwarden" + + assertEquals( + expectedScheme, + DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.appLinksScheme, + ) + } } private val DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA = EnvironmentUrlDataJson( diff --git a/network/src/main/kotlin/com/bitwarden/network/api/UnauthenticatedIdentityApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/UnauthenticatedIdentityApi.kt index 501d453f4d8..5f191876f9c 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/UnauthenticatedIdentityApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/UnauthenticatedIdentityApi.kt @@ -45,6 +45,7 @@ internal interface UnauthenticatedIdentityApi { @Field(value = "twoFactorRemember") twoFactorRemember: String?, @Field(value = "authRequest") authRequestId: String?, @Field(value = "newDeviceOtp") newDeviceOtp: String?, + @Field(value = "deeplinkScheme") deeplinkScheme: String, ): NetworkResult @GET("/sso/prevalidate") diff --git a/network/src/main/kotlin/com/bitwarden/network/service/IdentityService.kt b/network/src/main/kotlin/com/bitwarden/network/service/IdentityService.kt index ea47d2f2ea8..254c44f30d5 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/IdentityService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/IdentityService.kt @@ -33,6 +33,7 @@ interface IdentityService { * Make request to get an access token. * * @param uniqueAppId applications unique identifier. + * @param deeplinkScheme deeplink scheme to use for duo two-factor logins. * @param email user's email address. * @param authModel information necessary to authenticate with any * of the available login methods. @@ -41,6 +42,7 @@ interface IdentityService { @Suppress("LongParameterList") suspend fun getToken( uniqueAppId: String, + deeplinkScheme: String, email: String, authModel: IdentityTokenAuthModel, twoFactorData: TwoFactorDataModel? = null, diff --git a/network/src/main/kotlin/com/bitwarden/network/service/IdentityServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/IdentityServiceImpl.kt index 2223da2f171..65887be2bc3 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/IdentityServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/IdentityServiceImpl.kt @@ -54,6 +54,7 @@ internal class IdentityServiceImpl( override suspend fun getToken( uniqueAppId: String, + deeplinkScheme: String, email: String, authModel: IdentityTokenAuthModel, twoFactorData: TwoFactorDataModel?, @@ -76,6 +77,7 @@ internal class IdentityServiceImpl( twoFactorRemember = twoFactorData?.remember?.let { if (it) "1" else "0 " }, authRequestId = authModel.authRequestId, newDeviceOtp = newDeviceOtp, + deeplinkScheme = deeplinkScheme, ) .toResult() .recoverCatching { throwable -> diff --git a/network/src/test/kotlin/com/bitwarden/network/service/IdentityServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/IdentityServiceTest.kt index 3ff50f2dad6..e07bbf3afab 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/IdentityServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/IdentityServiceTest.kt @@ -184,6 +184,7 @@ class IdentityServiceTest : BaseServiceTest() { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) assertEquals(LOGIN_SUCCESS.asSuccess(), result) } @@ -198,6 +199,7 @@ class IdentityServiceTest : BaseServiceTest() { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) assertTrue(result.isFailure) } @@ -212,6 +214,7 @@ class IdentityServiceTest : BaseServiceTest() { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) assertEquals(TWO_FACTOR_BODY.asSuccess(), result) } @@ -226,6 +229,7 @@ class IdentityServiceTest : BaseServiceTest() { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) assertEquals(INVALID_LOGIN.asSuccess(), result) } @@ -241,6 +245,7 @@ class IdentityServiceTest : BaseServiceTest() { password = PASSWORD_HASH, ), uniqueAppId = UNIQUE_APP_ID, + deeplinkScheme = DEEPLINK_SCHEME, ) assertEquals(INVALID_LOGIN.asSuccess(), result) } @@ -438,6 +443,7 @@ class IdentityServiceTest : BaseServiceTest() { } companion object { + private const val DEEPLINK_SCHEME = "deeplinkScheme" private const val UNIQUE_APP_ID = "testUniqueAppId" private const val REFRESH_TOKEN = "refreshToken" private const val EMAIL_TOKEN = "emailToken" diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManager.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManager.kt index d773f7c33b4..d2bea223005 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManager.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManager.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.core.data.manager.BuildInfoManager +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.platform.model.FileData import java.time.Clock @@ -47,11 +48,11 @@ interface IntentManager { fun launchUri(uri: Uri) /** - * Start an Auth Tab Activity using the provided [Uri]. + * Start an Auth Tab Activity using the provided [Uri] and [AuthTabData]. */ fun startAuthTab( uri: Uri, - redirectScheme: String, + authTabData: AuthTabData, launcher: ActivityResultLauncher, ) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt index 4d14b75bfce..f4aadf22749 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt @@ -27,6 +27,7 @@ import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.core.data.manager.BuildInfoManager import com.bitwarden.core.data.util.toFormattedPattern import com.bitwarden.core.util.isBuildVersionAtLeast +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.platform.manager.util.deviceData import com.bitwarden.ui.platform.manager.util.fileProviderAuthority import com.bitwarden.ui.platform.model.FileData @@ -77,13 +78,25 @@ internal class IntentManagerImpl( override fun startAuthTab( uri: Uri, - redirectScheme: String, + authTabData: AuthTabData, launcher: ActivityResultLauncher, ) { val providerPackageName = CustomTabsClient.getPackageName(activity, null).toString() if (CustomTabsClient.isAuthTabSupported(activity, providerPackageName)) { Timber.d("Launching uri with AuthTab for $providerPackageName") - AuthTabIntent.Builder().build().launch(launcher, uri, redirectScheme) + when (authTabData) { + is AuthTabData.CustomScheme -> { + AuthTabIntent.Builder() + .build() + .launch(launcher, uri, authTabData.callbackScheme) + } + + is AuthTabData.HttpsScheme -> { + AuthTabIntent.Builder() + .build() + .launch(launcher, uri, authTabData.host, "\\${authTabData.path}") + } + } } else { // Fall back to a Custom Tab. Timber.d("Launching uri with CustomTabs fallback for $providerPackageName") diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/intent/model/AuthTabData.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/intent/model/AuthTabData.kt new file mode 100644 index 00000000000..69db637409c --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/intent/model/AuthTabData.kt @@ -0,0 +1,38 @@ +package com.bitwarden.ui.platform.manager.intent.model + +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsIntent + +/** + * Represents all data required to launch an [AuthTabIntent] or a fallback [CustomTabsIntent]. + */ +sealed class AuthTabData { + /** + * The scheme being used for the callback. + */ + abstract val callbackScheme: String + + /** + * The url to be used for the callback. + */ + abstract val callbackUrl: String + + /** + * A representation of a custom "Bitwarden" scheme callback. + */ + data class CustomScheme( + override val callbackUrl: String, + override val callbackScheme: String = "bitwarden", + ) : AuthTabData() + + /** + * A representation of a "https" app link scheme callback. + */ + data class HttpsScheme( + val host: String, + val path: String, + ) : AuthTabData() { + override val callbackScheme: String = "https" + override val callbackUrl: String = "$callbackScheme://$host/$path" + } +}