Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)) }
Expand Down
36 changes: 36 additions & 0 deletions app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
*/
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we either write a few tests for this, or omit it from coverage?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added tests for the DuoUtils... I didn't realize it had none.

Tests for the AuthTabIntent.AuthResult have been omitted since mockking it is problematic.

Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*/
Expand All @@ -59,7 +83,7 @@ fun generateUriForWebAuth(
"?data=$base64Data" +
"&parent=$parentParam" +
"&v=2"
return Uri.parse(url)
return url.toUri()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(),
) {
Expand All @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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,
) {
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading