mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 09:56:45 -06:00
PM-26575: Add AuthTab support for WebAuthN, Duo, and SSO (#6002)
This commit is contained in:
parent
0604d15d7d
commit
c6f132d5f7
@ -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)) }
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 -> {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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>,
|
||||
)
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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].
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user