PM-26575: Add AuthTab support for WebAuthN, Duo, and SSO (#6002)

This commit is contained in:
David Perez 2025-10-10 16:38:31 -05:00 committed by GitHub
parent 0604d15d7d
commit c6f132d5f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 381 additions and 37 deletions

View File

@ -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)) }

View File

@ -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.
*/

View File

@ -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.
*/

View File

@ -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.
*/

View File

@ -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()
}
/**

View File

@ -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 -> {

View File

@ -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)

View File

@ -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<ExitManager> = compositionLocal
error("CompositionLocal ExitManager not present")
}
/**
* Provides access to the Auth Tab launchers throughout the app.
*/
val LocalAuthTabLaunchers: ProvidableCompositionLocal<AuthTabLaunchers> = compositionLocalOf {
error("CompositionLocal AuthTabLaunchers not present")
}
/**
* Provides access to the feature flags throughout the app.
*/

View File

@ -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<Intent>,
val sso: ActivityResultLauncher<Intent>,
val webAuthn: ActivityResultLauncher<Intent>,
)

View File

@ -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<VaultStateEvent>()
private val vaultRepository = mockk<VaultRepository> {
@ -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<AuthTabIntent.AuthResult> {
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<AuthTabIntent.AuthResult> {
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<AuthTabIntent.AuthResult> {
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(

View File

@ -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<Intent> {
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<Intent> {
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<Intent> {
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<Intent> {
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<Intent> {
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<Intent> {
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)
}
}

View File

@ -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<Intent> = mockk()
private var onNavigateBackCalled = false
private var onNavigateToSetPasswordCalled = false
private var onNavigateToTwoFactorLoginEmailAndOrgIdentifier: Pair<String, String>? = 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)
}
}

View File

@ -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<IntentManager>(relaxed = true) {
every { launchUri(any()) } just runs
private val duoLauncher: ActivityResultLauncher<Intent> = mockk()
private val webAuthnLauncher: ActivityResultLauncher<Intent> = mockk()
private val intentManager = mockk<IntentManager> {
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<Uri>()
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<Uri>()
mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToWebAuth(mockUri))
verify { intentManager.startCustomTabsActivity(mockUri) }
verify { intentManager.startAuthTab(uri = mockUri, launcher = webAuthnLauncher) }
}
@Test

View File

@ -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,

View File

@ -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<Intent>,
)
/**
* Start an activity using the provided [Intent] and provides a callback, via [onResult], for
* retrieving the [ActivityResult].

View File

@ -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<Intent>,
) {
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()