From c6f132d5f7dd04ea680e852965b9f1029b040ab2 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 10 Oct 2025 16:38:31 -0500 Subject: [PATCH] PM-26575: Add AuthTab support for WebAuthN, Duo, and SSO (#6002) --- .../com/x8bit/bitwarden/MainActivity.kt | 21 ++++- .../com/x8bit/bitwarden/MainViewModel.kt | 36 +++++++++ .../data/auth/repository/util/DuoUtils.kt | 46 ++++++++--- .../data/auth/repository/util/SsoUtils.kt | 42 +++++++--- .../data/auth/repository/util/WebAuthUtils.kt | 34 ++++++-- .../EnterpriseSignOnScreen.kt | 5 +- .../twofactorlogin/TwoFactorLoginScreen.kt | 7 +- .../composition/LocalManagerProvider.kt | 10 +++ .../ui/platform/model/AuthTabLaunchers.kt | 15 ++++ .../com/x8bit/bitwarden/MainViewModelTest.kt | 61 +++++++++++++++ .../data/auth/repository/util/DuoUtilsTest.kt | 77 +++++++++++++++++++ .../EnterpriseSignOnScreenTest.kt | 13 +++- .../TwoFactorLoginScreenTest.kt | 23 ++++-- .../ui/platform/base/BitwardenComposeTest.kt | 3 + .../ui/platform/manager/IntentManager.kt | 9 +++ .../ui/platform/manager/IntentManagerImpl.kt | 16 ++++ 16 files changed, 381 insertions(+), 37 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/AuthTabLaunchers.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtilsTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt index 81292d3c51..5cdff27b50 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.browser.auth.AuthTabIntent import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -38,6 +39,7 @@ import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScre import com.x8bit.bitwarden.ui.platform.feature.rootnav.ROOT_ROUTE import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers import com.x8bit.bitwarden.ui.platform.util.appLanguage import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.map @@ -68,6 +70,16 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var debugLaunchManager: DebugMenuLaunchManager + private val duoLauncher = AuthTabIntent.registerActivityResultLauncher(this) { + mainViewModel.trySendAction(MainAction.DuoResult(it)) + } + private val ssoLauncher = AuthTabIntent.registerActivityResultLauncher(this) { + mainViewModel.trySendAction(MainAction.SsoResult(it)) + } + private val webAuthnLauncher = AuthTabIntent.registerActivityResultLauncher(this) { + mainViewModel.trySendAction(MainAction.WebAuthnResult(it)) + } + override fun onCreate(savedInstanceState: Bundle?) { intent = intent.validate() var shouldShowSplashScreen = true @@ -88,7 +100,14 @@ class MainActivity : AppCompatActivity() { SetupEventsEffect(navController = navController) val state by mainViewModel.stateFlow.collectAsStateWithLifecycle() updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed) - LocalManagerProvider(featureFlagsState = state.featureFlagsState) { + LocalManagerProvider( + featureFlagsState = state.featureFlagsState, + authTabLaunchers = AuthTabLaunchers( + duo = duoLauncher, + sso = ssoLauncher, + webAuthn = webAuthnLauncher, + ), + ) { ObserveScreenDataEffect( onDataUpdate = remember(mainViewModel) { { mainViewModel.trySendAction(MainAction.ResumeScreenDataReceived(it)) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt index b3cb2e2364..456eb513ed 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden import android.content.Intent import android.os.Parcelable +import androidx.browser.auth.AuthTabIntent import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.data.manager.toast.ToastManager @@ -15,6 +16,9 @@ import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult +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.getWebAuthResult import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager @@ -181,6 +185,9 @@ class MainViewModel @Inject constructor( MainAction.OpenDebugMenu -> handleOpenDebugMenu() is MainAction.ResumeScreenDataReceived -> handleAppResumeDataUpdated(action) is MainAction.AppSpecificLanguageUpdate -> handleAppSpecificLanguageUpdate(action) + is MainAction.DuoResult -> handleDuoResult(action) + is MainAction.SsoResult -> handleSsoResult(action) + is MainAction.WebAuthnResult -> handleWebAuthnResult(action) is MainAction.Internal -> handleInternalAction(action) } } @@ -209,6 +216,20 @@ class MainViewModel @Inject constructor( settingsRepository.appLanguage = action.appLanguage } + private fun handleDuoResult(action: MainAction.DuoResult) { + authRepository.setDuoCallbackTokenResult( + tokenResult = action.authResult.getDuoCallbackTokenResult(), + ) + } + + private fun handleSsoResult(action: MainAction.SsoResult) { + authRepository.setSsoCallbackResult(result = action.authResult.getSsoCallbackResult()) + } + + private fun handleWebAuthnResult(action: MainAction.WebAuthnResult) { + authRepository.setWebAuthResult(webAuthResult = action.authResult.getWebAuthResult()) + } + private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) { when (val data = action.screenResumeData) { null -> appResumeManager.clearResumeScreen() @@ -498,6 +519,21 @@ data class MainState( * Models actions for the [MainActivity]. */ sealed class MainAction { + /** + * Receive the result from the Duo login flow. + */ + data class DuoResult(val authResult: AuthTabIntent.AuthResult) : MainAction() + + /** + * Receive the result from the SSO login flow. + */ + data class SsoResult(val authResult: AuthTabIntent.AuthResult) : MainAction() + + /** + * Receive the result from the WebAuthn login flow. + */ + data class WebAuthnResult(val authResult: AuthTabIntent.AuthResult) : MainAction() + /** * Receive first Intent by the application. */ 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 34b5bc2fba..24f20cbd8b 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 @@ -1,6 +1,9 @@ package com.x8bit.bitwarden.data.auth.repository.util import android.content.Intent +import android.net.Uri +import androidx.browser.auth.AuthTabIntent +import com.bitwarden.annotation.OmitFromCoverage private const val DUO_HOST: String = "duo-callback" @@ -16,21 +19,44 @@ private const val DUO_HOST: String = "duo-callback" */ fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? { val localData = data - return if ( - action == Intent.ACTION_VIEW && localData != null && localData.host == DUO_HOST - ) { - val code = localData.getQueryParameter("code") - val state = localData.getQueryParameter("state") - if (code != null && state != null) { - DuoCallbackTokenResult.Success(token = "$code|$state") - } else { - DuoCallbackTokenResult.MissingToken - } + return if (action == Intent.ACTION_VIEW && localData != null && localData.host == DUO_HOST) { + localData.getDuoCallbackTokenResult() } else { null } } +/** + * Retrieves a [DuoCallbackTokenResult] from an Intent. There are three possible cases. + * + * - `null`: Intent is not a Duo callback, or data is null. + * + * - [DuoCallbackTokenResult.MissingToken]: Intent is the Duo callback, but it's missing the code or + * state value. + * + * - [DuoCallbackTokenResult.Success]: Intent is the Duo callback, and it has a token. + */ +@OmitFromCoverage +fun AuthTabIntent.AuthResult.getDuoCallbackTokenResult(): DuoCallbackTokenResult = + when (this.resultCode) { + AuthTabIntent.RESULT_OK -> this.resultUri.getDuoCallbackTokenResult() + AuthTabIntent.RESULT_CANCELED -> DuoCallbackTokenResult.MissingToken + AuthTabIntent.RESULT_UNKNOWN_CODE -> DuoCallbackTokenResult.MissingToken + AuthTabIntent.RESULT_VERIFICATION_FAILED -> DuoCallbackTokenResult.MissingToken + AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> DuoCallbackTokenResult.MissingToken + else -> DuoCallbackTokenResult.MissingToken + } + +private fun Uri?.getDuoCallbackTokenResult(): DuoCallbackTokenResult { + val code = this?.getQueryParameter("code") + val state = this?.getQueryParameter("state") + return if (code != null && state != null) { + DuoCallbackTokenResult.Success(token = "$code|$state") + } else { + DuoCallbackTokenResult.MissingToken + } +} + /** * Sealed class representing the result of Duo callback token extraction. */ 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 d4d83f665d..35afe7a815 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 @@ -1,7 +1,10 @@ package com.x8bit.bitwarden.data.auth.repository.util import android.content.Intent +import android.net.Uri import android.os.Parcelable +import androidx.browser.auth.AuthTabIntent +import com.bitwarden.annotation.OmitFromCoverage import kotlinx.parcelize.Parcelize import java.net.URLEncoder import java.security.MessageDigest @@ -61,21 +64,40 @@ fun generateUriForSso( fun Intent.getSsoCallbackResult(): SsoCallbackResult? { val localData = data return if (action == Intent.ACTION_VIEW && localData?.host == SSO_HOST) { - val state = localData.getQueryParameter("state") - val code = localData.getQueryParameter("code") - if (code != null) { - SsoCallbackResult.Success( - state = state, - code = code, - ) - } else { - SsoCallbackResult.MissingCode - } + localData.getSsoCallbackResult() } else { null } } +/** + * Retrieves an [SsoCallbackResult] from an [AuthTabIntent.AuthResult]. There are two possible + * cases. + * + * - [SsoCallbackResult.MissingCode]: The code is missing. + * - [SsoCallbackResult.Success]: The relevant data is present. + */ +@OmitFromCoverage +fun AuthTabIntent.AuthResult.getSsoCallbackResult(): SsoCallbackResult = + when (this.resultCode) { + AuthTabIntent.RESULT_OK -> this.resultUri.getSsoCallbackResult() + AuthTabIntent.RESULT_CANCELED -> SsoCallbackResult.MissingCode + AuthTabIntent.RESULT_UNKNOWN_CODE -> SsoCallbackResult.MissingCode + AuthTabIntent.RESULT_VERIFICATION_FAILED -> SsoCallbackResult.MissingCode + AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> SsoCallbackResult.MissingCode + else -> SsoCallbackResult.MissingCode + } + +private fun Uri?.getSsoCallbackResult(): SsoCallbackResult { + val state = this?.getQueryParameter("state") + val code = this?.getQueryParameter("code") + return if (code != null) { + SsoCallbackResult.Success(state = state, code = code) + } else { + SsoCallbackResult.MissingCode + } +} + /** * Sealed class representing the result of an SSO callback data extraction. */ 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 43a674dcf0..ad73b53bb7 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 @@ -2,6 +2,9 @@ package com.x8bit.bitwarden.data.auth.repository.util import android.content.Intent import android.net.Uri +import androidx.browser.auth.AuthTabIntent +import androidx.core.net.toUri +import com.bitwarden.annotation.OmitFromCoverage import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -24,15 +27,36 @@ fun Intent.getWebAuthResultOrNull(): WebAuthResult? { localData != null && localData.host == WEB_AUTH_HOST ) { - localData - .getQueryParameter("data") - ?.let { WebAuthResult.Success(token = it) } - ?: WebAuthResult.Failure(message = localData.getQueryParameter("error")) + localData.getWebAuthResult() } else { null } } +/** + * Retrieves an [WebAuthResult] from an [AuthTabIntent.AuthResult]. There are two possible cases. + * + * - [WebAuthResult.Success]: The URI is the web auth key callback with correct data. + * - [WebAuthResult.Failure]: The URI is the web auth key callback with incorrect data or a failure + * has occurred. + */ +@OmitFromCoverage +fun AuthTabIntent.AuthResult.getWebAuthResult(): WebAuthResult = + when (this.resultCode) { + AuthTabIntent.RESULT_OK -> this.resultUri.getWebAuthResult() + AuthTabIntent.RESULT_CANCELED -> WebAuthResult.Failure(message = null) + AuthTabIntent.RESULT_UNKNOWN_CODE -> WebAuthResult.Failure(message = null) + AuthTabIntent.RESULT_VERIFICATION_FAILED -> WebAuthResult.Failure(message = null) + AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> WebAuthResult.Failure(message = null) + else -> WebAuthResult.Failure(message = null) + } + +private fun Uri?.getWebAuthResult(): WebAuthResult = + this + ?.getQueryParameter("data") + ?.let { WebAuthResult.Success(token = it) } + ?: WebAuthResult.Failure(message = this?.getQueryParameter("error")) + /** * Generates a [Uri] to display a web authn challenge for Bitwarden authentication. */ @@ -59,7 +83,7 @@ fun generateUriForWebAuth( "?data=$base64Data" + "&parent=$parentParam" + "&v=2" - return Uri.parse(url) + return url.toUri() } /** 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 c6690629d3..4e0b30c74d 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 @@ -41,6 +41,8 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers +import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers /** * The top level composable for the Enterprise Single Sign On screen. @@ -52,6 +54,7 @@ fun EnterpriseSignOnScreen( onNavigateBack: () -> Unit, onNavigateToSetPassword: () -> Unit, onNavigateToTwoFactorLogin: (email: String, orgIdentifier: String) -> Unit, + authTabLaunchers: AuthTabLaunchers = LocalAuthTabLaunchers.current, intentManager: IntentManager = LocalIntentManager.current, viewModel: EnterpriseSignOnViewModel = hiltViewModel(), ) { @@ -61,7 +64,7 @@ fun EnterpriseSignOnScreen( EnterpriseSignOnEvent.NavigateBack -> onNavigateBack() is EnterpriseSignOnEvent.NavigateToSsoLogin -> { - intentManager.startCustomTabsActivity(event.uri) + intentManager.startAuthTab(uri = event.uri, launcher = authTabLaunchers.sso) } is EnterpriseSignOnEvent.NavigateToSetPassword -> { 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 f0e8d11389..4131fecca4 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 @@ -61,8 +61,10 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.description import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.title +import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers import com.x8bit.bitwarden.ui.platform.composition.LocalNfcManager import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManager +import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers import kotlinx.collections.immutable.toPersistentList /** @@ -74,6 +76,7 @@ import kotlinx.collections.immutable.toPersistentList fun TwoFactorLoginScreen( onNavigateBack: () -> Unit, viewModel: TwoFactorLoginViewModel = hiltViewModel(), + authTabLaunchers: AuthTabLaunchers = LocalAuthTabLaunchers.current, intentManager: IntentManager = LocalIntentManager.current, nfcManager: NfcManager = LocalNfcManager.current, ) { @@ -105,11 +108,11 @@ fun TwoFactorLoginScreen( } is TwoFactorLoginEvent.NavigateToDuo -> { - intentManager.startCustomTabsActivity(uri = event.uri) + intentManager.startAuthTab(uri = event.uri, launcher = authTabLaunchers.duo) } is TwoFactorLoginEvent.NavigateToWebAuth -> { - intentManager.startCustomTabsActivity(uri = event.uri) + intentManager.startAuthTab(uri = event.uri, launcher = authTabLaunchers.webAuthn) } is TwoFactorLoginEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index b09f35588c..5f2e3a236d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -43,6 +43,7 @@ import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManagerImpl import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManagerImpl +import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState import java.time.Clock @@ -78,6 +79,7 @@ fun LocalManagerProvider( }, credentialExchangeRequestValidator: CredentialExchangeRequestValidator = credentialExchangeRequestValidator(activity = activity), + authTabLaunchers: AuthTabLaunchers, content: @Composable () -> Unit, ) { CompositionLocalProvider( @@ -95,6 +97,7 @@ fun LocalManagerProvider( LocalCredentialExchangeImporter provides credentialExchangeImporter, LocalCredentialExchangeCompletionManager provides credentialExchangeCompletionManager, LocalCredentialExchangeRequestValidator provides credentialExchangeRequestValidator, + LocalAuthTabLaunchers provides authTabLaunchers, content = content, ) } @@ -127,6 +130,13 @@ val LocalExitManager: ProvidableCompositionLocal = compositionLocal error("CompositionLocal ExitManager not present") } +/** + * Provides access to the Auth Tab launchers throughout the app. + */ +val LocalAuthTabLaunchers: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal AuthTabLaunchers not present") +} + /** * Provides access to the feature flags throughout the app. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/AuthTabLaunchers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/AuthTabLaunchers.kt new file mode 100644 index 0000000000..b78d393ef7 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/AuthTabLaunchers.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.ui.platform.model + +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.runtime.Immutable + +/** + * Contains all the callbacks for the Auth Tabs. + */ +@Immutable +class AuthTabLaunchers( + val duo: ActivityResultLauncher, + val sso: ActivityResultLauncher, + val webAuthn: ActivityResultLauncher, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt index 739081b06c..153b1d62d3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden import android.content.Intent +import androidx.browser.auth.AuthTabIntent import androidx.core.os.bundleOf import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.provider.BiometricPromptResult @@ -28,6 +29,12 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState +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.getDuoCallbackTokenResult +import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult +import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResult import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager @@ -120,6 +127,9 @@ class MainViewModelTest : BaseViewModelTest() { every { userStateFlow } returns mutableUserStateFlow every { switchAccount(any()) } returns SwitchAccountResult.NoChange coEvery { validateEmailToken(any(), any()) } returns EmailTokenResult.Success + every { setWebAuthResult(webAuthResult = any()) } just runs + every { setSsoCallbackResult(result = any()) } just runs + every { setDuoCallbackTokenResult(tokenResult = any()) } just runs } private val mutableVaultStateEventFlow = bufferedMutableSharedFlow() private val vaultRepository = mockk { @@ -184,6 +194,9 @@ class MainViewModelTest : BaseViewModelTest() { Intent::getGetCredentialsRequestOrNull, Intent::isAddTotpLoginItemFromAuthenticator, Intent::getProviderImportCredentialsRequest, + AuthTabIntent.AuthResult::getDuoCallbackTokenResult, + AuthTabIntent.AuthResult::getSsoCallbackResult, + AuthTabIntent.AuthResult::getWebAuthResult, ) mockkStatic( Intent::isMyVaultShortcut, @@ -216,6 +229,9 @@ class MainViewModelTest : BaseViewModelTest() { Intent::getGetCredentialsRequestOrNull, Intent::isAddTotpLoginItemFromAuthenticator, Intent::getProviderImportCredentialsRequest, + AuthTabIntent.AuthResult::getDuoCallbackTokenResult, + AuthTabIntent.AuthResult::getSsoCallbackResult, + AuthTabIntent.AuthResult::getWebAuthResult, ) unmockkStatic( Intent::isMyVaultShortcut, @@ -1167,6 +1183,51 @@ class MainViewModelTest : BaseViewModelTest() { verify { settingsRepository.appLanguage = AppLanguage.SPANISH } } + @Test + fun `on DuoResult should setDuoCallbackTokenResult with result`() = runTest { + val tokenResult = DuoCallbackTokenResult.Success(token = "token") + val authResult = mockk { + every { getDuoCallbackTokenResult() } returns tokenResult + } + val viewModel = createViewModel() + + viewModel.trySendAction(MainAction.DuoResult(authResult = authResult)) + + verify(exactly = 1) { + authRepository.setDuoCallbackTokenResult(tokenResult = tokenResult) + } + } + + @Test + fun `on SsoResult should setSsoCallbackResult with result`() = runTest { + val result = SsoCallbackResult.Success(state = null, code = "code") + val authResult = mockk { + every { getSsoCallbackResult() } returns result + } + val viewModel = createViewModel() + + viewModel.trySendAction(MainAction.SsoResult(authResult = authResult)) + + verify(exactly = 1) { + authRepository.setSsoCallbackResult(result = result) + } + } + + @Test + fun `on WebAuthnResult should setWebAuthResult with result`() = runTest { + val webAuthResult = WebAuthResult.Success(token = "token") + val authResult = mockk { + every { getWebAuthResult() } returns webAuthResult + } + val viewModel = createViewModel() + + viewModel.trySendAction(MainAction.WebAuthnResult(authResult = authResult)) + + verify(exactly = 1) { + authRepository.setWebAuthResult(webAuthResult = webAuthResult) + } + } + private fun createViewModel( initialSpecialCircumstance: SpecialCircumstance? = null, ) = MainViewModel( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtilsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtilsTest.kt new file mode 100644 index 0000000000..41bbb7b40b --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtilsTest.kt @@ -0,0 +1,77 @@ +package com.x8bit.bitwarden.data.auth.repository.util + +import android.content.Intent +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull + +class DuoUtilsTest { + + @Test + fun `getDuoCallbackTokenResult should return null when action is not VIEW`() { + val intent = mockk { + every { data } returns mockk() + every { action } returns Intent.ACTION_SEND + } + val result = intent.getDuoCallbackTokenResult() + assertNull(result) + } + + @Test + fun `getDuoCallbackTokenResult should return null when data is null`() { + val intent = mockk { + every { data } returns null + every { action } returns Intent.ACTION_VIEW + } + val result = intent.getDuoCallbackTokenResult() + assertNull(result) + } + + @Test + fun `getDuoCallbackTokenResult should return null when host is not the duo callback`() { + val intent = mockk { + every { data?.host } returns "wrongHost" + every { action } returns Intent.ACTION_VIEW + } + val result = intent.getDuoCallbackTokenResult() + assertNull(result) + } + + @Test + fun `getDuoCallbackTokenResult should return MissingToken code is null`() { + val intent = mockk { + every { data?.host } returns "duo-callback" + every { data?.getQueryParameter("code") } returns null + every { data?.getQueryParameter("state") } returns "state" + every { action } returns Intent.ACTION_VIEW + } + val result = intent.getDuoCallbackTokenResult() + assertEquals(DuoCallbackTokenResult.MissingToken, result) + } + + @Test + fun `getDuoCallbackTokenResult should return MissingToken state is null`() { + val intent = mockk { + every { data?.host } returns "duo-callback" + every { data?.getQueryParameter("code") } returns "code" + every { data?.getQueryParameter("state") } returns null + every { action } returns Intent.ACTION_VIEW + } + val result = intent.getDuoCallbackTokenResult() + assertEquals(DuoCallbackTokenResult.MissingToken, result) + } + + @Test + fun `getDuoCallbackTokenResult should return Success when all data is present`() { + val intent = mockk { + every { data?.host } returns "duo-callback" + every { data?.getQueryParameter("code") } returns "code" + every { data?.getQueryParameter("state") } returns "state" + every { action } returns Intent.ACTION_VIEW + } + val result = intent.getDuoCallbackTokenResult() + assertEquals(DuoCallbackTokenResult.Success(token = "code|state"), result) + } +} 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 7bb519321a..fa78ff7980 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 @@ -1,6 +1,8 @@ package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon +import android.content.Intent import android.net.Uri +import androidx.activity.result.ActivityResultLauncher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals @@ -17,6 +19,7 @@ import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest +import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -30,6 +33,7 @@ import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals class EnterpriseSignOnScreenTest : BitwardenComposeTest() { + private val ssoLauncher: ActivityResultLauncher = mockk() private var onNavigateBackCalled = false private var onNavigateToSetPasswordCalled = false private var onNavigateToTwoFactorLoginEmailAndOrgIdentifier: Pair? = null @@ -41,12 +45,17 @@ class EnterpriseSignOnScreenTest : BitwardenComposeTest() { } private val intentManager: IntentManager = mockk { - every { startCustomTabsActivity(any()) } just runs + every { startAuthTab(uri = any(), launcher = any()) } just runs } @Before fun setup() { setContent( + authTabLaunchers = AuthTabLaunchers( + duo = mockk(), + sso = ssoLauncher, + webAuthn = mockk(), + ), intentManager = intentManager, ) { EnterpriseSignOnScreen( @@ -107,7 +116,7 @@ class EnterpriseSignOnScreenTest : BitwardenComposeTest() { val ssoUri = Uri.parse("https://identity.bitwarden.com/sso-test") mutableEventFlow.tryEmit(EnterpriseSignOnEvent.NavigateToSsoLogin(ssoUri)) verify(exactly = 1) { - intentManager.startCustomTabsActivity(ssoUri) + intentManager.startAuthTab(uri = ssoUri, launcher = ssoLauncher) } } 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 2333c2acc7..a400ceb94d 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 @@ -1,6 +1,8 @@ package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin +import android.content.Intent import android.net.Uri +import androidx.activity.result.ActivityResultLauncher import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotDisplayed @@ -21,6 +23,7 @@ import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManager +import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -33,8 +36,11 @@ import org.junit.Before import org.junit.Test class TwoFactorLoginScreenTest : BitwardenComposeTest() { - private val intentManager = mockk(relaxed = true) { - every { launchUri(any()) } just runs + private val duoLauncher: ActivityResultLauncher = mockk() + private val webAuthnLauncher: ActivityResultLauncher = mockk() + private val intentManager = mockk { + every { launchUri(uri = any()) } just runs + every { startAuthTab(uri = any(), launcher = any()) } just runs } private val nfcManager: NfcManager = mockk { every { start() } just runs @@ -51,6 +57,11 @@ class TwoFactorLoginScreenTest : BitwardenComposeTest() { @Before fun setUp() { setContent( + authTabLaunchers = AuthTabLaunchers( + duo = duoLauncher, + sso = mockk(), + webAuthn = webAuthnLauncher, + ), intentManager = intentManager, nfcManager = nfcManager, ) { @@ -271,17 +282,17 @@ class TwoFactorLoginScreenTest : BitwardenComposeTest() { } @Test - fun `NavigateToDuo should call intentManager startCustomTabsActivity`() { + fun `NavigateToDuo should call intentManager startAuthTab`() { val mockUri = mockk() mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToDuo(mockUri)) - verify { intentManager.startCustomTabsActivity(mockUri) } + verify { intentManager.startAuthTab(uri = mockUri, launcher = duoLauncher) } } @Test - fun `NavigateToDuoNavigateToWebAuth should call intentManager startCustomTabsActivity`() { + fun `NavigateToWebAuth should call intentManager startCustomTabsActivity`() { val mockUri = mockk() mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToWebAuth(mockUri)) - verify { intentManager.startCustomTabsActivity(mockUri) } + verify { intentManager.startAuthTab(uri = mockUri, launcher = webAuthnLauncher) } } @Test diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt index b7ba8a18a2..9383792315 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.ui.platform.manager.keychain.KeyChainManager import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManager import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager +import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState import io.mockk.mockk import java.time.Clock @@ -33,6 +34,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() { protected fun setContent( theme: AppTheme = AppTheme.DEFAULT, featureFlagsState: FeatureFlagsState = FeatureFlagsState, + authTabLaunchers: AuthTabLaunchers = mockk(), appResumeStateManager: AppResumeStateManager = mockk(), appReviewManager: AppReviewManager = mockk(), biometricsManager: BiometricsManager = mockk(), @@ -51,6 +53,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() { setTestContent { LocalManagerProvider( featureFlagsState = featureFlagsState, + authTabLaunchers = authTabLaunchers, appResumeStateManager = appResumeStateManager, appReviewManager = appReviewManager, biometricsManager = biometricsManager, 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 d9e3657dc1..9eaf55cdc9 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 @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import com.bitwarden.annotation.OmitFromCoverage @@ -45,6 +46,14 @@ interface IntentManager { */ fun launchUri(uri: Uri) + /** + * Start an Auth Tab Activity using the provided [Uri]. + */ + fun startAuthTab( + uri: Uri, + launcher: ActivityResultLauncher, + ) + /** * Start an activity using the provided [Intent] and provides a callback, via [onResult], for * retrieving the [ActivityResult]. 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 63a65de748..00f20ca7d0 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 @@ -13,7 +13,10 @@ import android.webkit.MimeTypeMap import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat @@ -71,6 +74,19 @@ internal class IntentManagerImpl( onResult = onResult, ) + override fun startAuthTab( + uri: Uri, + launcher: ActivityResultLauncher, + ) { + val providerPackageName = CustomTabsClient.getPackageName(activity, null).toString() + if (CustomTabsClient.isAuthTabSupported(activity, providerPackageName)) { + AuthTabIntent.Builder().build().launch(launcher, uri, "bitwarden") + } else { + // Fall back to a Custom Tab. + startCustomTabsActivity(uri = uri) + } + } + override fun startCustomTabsActivity(uri: Uri) { CustomTabsIntent .Builder()