BIT-816: Handle login attempt of SSO flow (#797)

This commit is contained in:
Sean Weiser
2024-01-26 10:01:45 -06:00
committed by Álison Fernandes
parent 7a163d82ed
commit c765de99f1
20 changed files with 1178 additions and 79 deletions

View File

@@ -21,6 +21,11 @@ interface AuthDiskSource {
*/
var rememberedEmailAddress: String?
/**
* The currently persisted organization identifier (or `null` if not set).
*/
var rememberedOrgIdentifier: String?
/**
* The currently persisted user state information (or `null` if not set).
*/

View File

@@ -19,6 +19,7 @@ private const val BIOMETRICS_UNLOCK_KEY = "$ENCRYPTED_BASE_KEY:userKeyBiometricU
private const val USER_AUTO_UNLOCK_KEY_KEY = "$ENCRYPTED_BASE_KEY:userKeyAutoUnlock"
private const val UNIQUE_APP_ID_KEY = "$BASE_KEY:appId"
private const val REMEMBERED_EMAIL_ADDRESS_KEY = "$BASE_KEY:rememberedEmail"
private const val REMEMBERED_ORG_IDENTIFIER_KEY = "$BASE_KEY:rememberedOrgIdentifier"
private const val STATE_KEY = "$BASE_KEY:state"
private const val LAST_ACTIVE_TIME_KEY = "$BASE_KEY:lastActiveTime"
private const val INVALID_UNLOCK_ATTEMPTS_KEY = "$BASE_KEY:invalidUnlockAttempts"
@@ -67,6 +68,15 @@ class AuthDiskSourceImpl(
)
}
override var rememberedOrgIdentifier: String?
get() = getString(key = REMEMBERED_ORG_IDENTIFIER_KEY)
set(value) {
putString(
key = REMEMBERED_ORG_IDENTIFIER_KEY,
value = value,
)
}
override var userState: UserStateJson?
get() = getString(key = STATE_KEY)?.let { json.decodeFromString(it) }
set(value) {

View File

@@ -62,6 +62,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
var rememberedEmailAddress: String?
/**
* The currently persisted organization identifier (or `null` if not set).
*/
var rememberedOrgIdentifier: String?
/**
* Tracks whether there is an additional account that is pending login/registration in order to
* have multiple accounts available.
@@ -103,6 +108,17 @@ interface AuthRepository : AuthenticatorProvider {
captchaToken: String?,
): LoginResult
/**
* Attempt to login using a SSO flow. Updated access token will be reflected in [authStateFlow].
*/
suspend fun login(
email: String,
ssoCode: String,
ssoCodeVerifier: String,
ssoRedirectUri: String,
captchaToken: String?,
): LoginResult
/**
* Log out the current user.
*/

View File

@@ -186,6 +186,8 @@ class AuthRepositoryImpl(
override var rememberedEmailAddress: String? by authDiskSource::rememberedEmailAddress
override var rememberedOrgIdentifier: String? by authDiskSource::rememberedOrgIdentifier
override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value
@@ -258,6 +260,22 @@ class AuthRepositoryImpl(
)
} ?: LoginResult.Error(errorMessage = null)
override suspend fun login(
email: String,
ssoCode: String,
ssoCodeVerifier: String,
ssoRedirectUri: String,
captchaToken: String?,
): LoginResult = loginCommon(
email = email,
authModel = IdentityTokenAuthModel.SingleSignOn(
ssoCode = ssoCode,
ssoCodeVerifier = ssoCodeVerifier,
ssoRedirectUri = ssoRedirectUri,
),
captchaToken = captchaToken,
)
/**
* A helper function to extract the common logic of logging in through
* any of the available methods.

View File

@@ -1,12 +1,14 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.net.URLEncoder
import java.security.MessageDigest
import java.util.Base64
private const val SSO_HOST: String = "sso-callback"
private const val SSO_URI = "bitwarden://$SSO_HOST"
const val SSO_URI: String = "bitwarden://$SSO_HOST"
/**
* Generates a URI for the SSO custom tab.
@@ -28,7 +30,7 @@ fun generateUriForSso(
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
val encodedToken = URLEncoder.encode(token, "UTF-8")
val codeChallenge = Base64.getEncoder().encodeToString(
val codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(
MessageDigest
.getInstance("SHA-256")
.digest(codeVerifier.toByteArray()),
@@ -77,10 +79,11 @@ fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
/**
* Sealed class representing the result of an SSO callback data extraction.
*/
sealed class SsoCallbackResult {
sealed class SsoCallbackResult : Parcelable {
/**
* Represents an SSO callback object with a missing code value.
*/
@Parcelize
data object MissingCode : SsoCallbackResult()
/**
@@ -88,6 +91,7 @@ sealed class SsoCallbackResult {
* present doesn't guarantee it is correct, and should be checked against the known state before
* being used.
*/
@Parcelize
data class Success(
val state: String?,
val code: String,

View File

@@ -48,6 +48,12 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
enterpriseSignOnDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToTwoFactorLogin = { emailAddress ->
navController.navigateToTwoFactorLogin(
emailAddress = emailAddress,
password = null,
)
},
)
landingDestination(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
@@ -68,7 +74,11 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
emailAddress = emailAddress,
)
},
onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() },
onNavigateToEnterpriseSignOn = { emailAddress ->
navController.navigateToEnterpriseSignOn(
emailAddress = emailAddress,
)
},
onNavigateToLoginWithDevice = { emailAddress ->
navController.navigateToLoginWithDevice(
emailAddress = emailAddress,

View File

@@ -1,17 +1,36 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val ENTERPRISE_SIGN_ON_ROUTE = "enterprise_sign_on"
private const val ENTERPRISE_SIGN_ON_PREFIX = "enterprise_sign_on "
private const val EMAIL_ADDRESS: String = "email_address"
private const val ENTERPRISE_SIGN_ON_ROUTE = "$ENTERPRISE_SIGN_ON_PREFIX/{$EMAIL_ADDRESS}"
/**
* Class to retrieve login arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class EnterpriseSignOnArgs(val emailAddress: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
)
}
/**
* Navigate to the enterprise single sign on screen.
*/
fun NavController.navigateToEnterpriseSignOn(navOptions: NavOptions? = null) {
this.navigate(ENTERPRISE_SIGN_ON_ROUTE, navOptions)
fun NavController.navigateToEnterpriseSignOn(
emailAddress: String,
navOptions: NavOptions? = null,
) {
this.navigate("$ENTERPRISE_SIGN_ON_PREFIX/$emailAddress", navOptions)
}
/**
@@ -19,12 +38,17 @@ fun NavController.navigateToEnterpriseSignOn(navOptions: NavOptions? = null) {
*/
fun NavGraphBuilder.enterpriseSignOnDestination(
onNavigateBack: () -> Unit,
onNavigateToTwoFactorLogin: (emailAddress: String) -> Unit,
) {
composableWithSlideTransitions(
route = ENTERPRISE_SIGN_ON_ROUTE,
arguments = listOf(
navArgument(EMAIL_ADDRESS) { type = NavType.StringType },
),
) {
EnterpriseSignOnScreen(
onNavigateBack = onNavigateBack,
onNavigateToTwoFactorLogin = onNavigateToTwoFactorLogin,
)
}
}

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -22,7 +21,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
@@ -53,21 +51,26 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
@Composable
fun EnterpriseSignOnScreen(
onNavigateBack: () -> Unit,
onNavigateToTwoFactorLogin: (String) -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: EnterpriseSignOnViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
EnterpriseSignOnEvent.NavigateBack -> onNavigateBack()
is EnterpriseSignOnEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
is EnterpriseSignOnEvent.NavigateToSsoLogin -> {
intentManager.startCustomTabsActivity(event.uri)
}
is EnterpriseSignOnEvent.NavigateToCaptcha -> {
intentManager.startCustomTabsActivity(event.uri)
}
is EnterpriseSignOnEvent.NavigateToTwoFactorLogin -> {
onNavigateToTwoFactorLogin(event.emailAddress)
}
}
}

View File

@@ -6,8 +6,12 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SSO_URI
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForSso
import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
@@ -25,13 +29,15 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_SSO_STATE = "ssoState"
private const val KEY_SSO_DATA = "ssoData"
private const val KEY_SSO_CALLBACK_RESULT = "ssoCallbackResult"
private const val KEY_STATE = "state"
private const val RANDOM_STRING_LENGTH = 64
/**
* Manages application state for the enterprise single sign on screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class EnterpriseSignOnViewModel @Inject constructor(
private val authRepository: AuthRepository,
@@ -43,25 +49,41 @@ class EnterpriseSignOnViewModel @Inject constructor(
initialState = savedStateHandle[KEY_STATE]
?: EnterpriseSignOnState(
dialogState = null,
orgIdentifierInput = "",
orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "",
captchaToken = null,
),
) {
/**
* A "state" maintained throughout the SSO process to verify that the response from the server
* is valid and matches what was originally sent in the request.
* Data needed once a response is received from the SSO backend.
*/
private var ssoState: String?
get() = savedStateHandle[KEY_SSO_STATE]
private var ssoResponseData: SsoResponseData?
get() = savedStateHandle[KEY_SSO_DATA]
set(value) {
savedStateHandle[KEY_SSO_STATE] = value
savedStateHandle[KEY_SSO_DATA] = value
}
private var savedSsoCallbackResult: SsoCallbackResult?
get() = savedStateHandle[KEY_SSO_CALLBACK_RESULT]
set(value) {
savedStateHandle[KEY_SSO_CALLBACK_RESULT] = value
}
init {
authRepository
.ssoCallbackResultFlow
.onEach {
handleSsoCallbackResult(it)
sendAction(EnterpriseSignOnAction.Internal.OnSsoCallbackResult(it))
}
.launchIn(viewModelScope)
// Automatically attempt to login again if a captcha token is received.
authRepository
.captchaTokenResultFlow
.onEach {
sendAction(
EnterpriseSignOnAction.Internal.OnReceiveCaptchaToken(it),
)
}
.launchIn(viewModelScope)
}
@@ -82,6 +104,18 @@ class EnterpriseSignOnViewModel @Inject constructor(
EnterpriseSignOnAction.Internal.OnSsoPrevalidationFailure -> {
handleOnSsoPrevalidationFailure()
}
is EnterpriseSignOnAction.Internal.OnSsoCallbackResult -> {
handleOnSsoCallbackResult(action)
}
is EnterpriseSignOnAction.Internal.OnLoginResult -> {
handleOnLoginResult(action)
}
is EnterpriseSignOnAction.Internal.OnReceiveCaptchaToken -> {
handleOnReceiveCaptchaToken(action)
}
}
}
@@ -94,9 +128,6 @@ class EnterpriseSignOnViewModel @Inject constructor(
}
private fun handleLogInClicked() {
// TODO BIT-816: submit request for single sign on
sendEvent(EnterpriseSignOnEvent.ShowToast("Not yet implemented."))
if (!networkConnectionManager.isNetworkConnected) {
mutableStateFlow.update {
it.copy(
@@ -123,13 +154,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
return
}
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
R.string.logging_in.asText(),
),
)
}
showLoading()
viewModelScope.launch {
val prevalidateSsoResult = authRepository.prevalidateSso(organizationIdentifier)
@@ -148,6 +173,44 @@ class EnterpriseSignOnViewModel @Inject constructor(
}
}
private fun handleOnLoginResult(action: EnterpriseSignOnAction.Internal.OnLoginResult) {
when (val loginResult = action.loginResult) {
is LoginResult.CaptchaRequired -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(
event = EnterpriseSignOnEvent.NavigateToCaptcha(
uri = generateUriForCaptcha(captchaId = loginResult.captchaId),
),
)
}
is LoginResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = loginResult.errorMessage?.asText()
?: R.string.login_sso_error.asText(),
),
)
}
}
is LoginResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
authRepository.rememberedOrgIdentifier = state.orgIdentifierInput
}
is LoginResult.TwoFactorRequired -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(
EnterpriseSignOnEvent.NavigateToTwoFactorLogin(
emailAddress = EnterpriseSignOnArgs(savedStateHandle).emailAddress,
),
)
}
}
}
private fun handleOnGenerateUriForSsoResult(
action: EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult,
) {
@@ -156,13 +219,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
}
private fun handleOnSsoPrevalidationFailure() {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = R.string.login_sso_error.asText(),
),
)
}
showDefaultError()
}
private fun handleOrgIdentifierInputChanged(
@@ -171,8 +228,64 @@ class EnterpriseSignOnViewModel @Inject constructor(
mutableStateFlow.update { it.copy(orgIdentifierInput = action.input) }
}
private fun handleSsoCallbackResult(ssoCallbackResult: SsoCallbackResult) {
// TODO Handle result as last part of BIT-816
private fun handleOnSsoCallbackResult(
action: EnterpriseSignOnAction.Internal.OnSsoCallbackResult,
) {
savedSsoCallbackResult = action.ssoCallbackResult
attemptLogin()
}
private fun handleOnReceiveCaptchaToken(
action: EnterpriseSignOnAction.Internal.OnReceiveCaptchaToken,
) {
when (val tokenResult = action.tokenResult) {
CaptchaCallbackTokenResult.MissingToken -> {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
title = R.string.log_in_denied.asText(),
message = R.string.captcha_failed.asText(),
),
)
}
}
is CaptchaCallbackTokenResult.Success -> {
mutableStateFlow.update {
it.copy(captchaToken = tokenResult.token)
}
attemptLogin()
}
}
}
private fun attemptLogin() {
val ssoCallbackResult = requireNotNull(savedSsoCallbackResult)
val ssoData = requireNotNull(ssoResponseData)
when (ssoCallbackResult) {
is SsoCallbackResult.MissingCode -> {
showDefaultError()
}
is SsoCallbackResult.Success -> {
if (ssoCallbackResult.state == ssoData.state) {
showLoading()
viewModelScope.launch {
val result = authRepository
.login(
email = EnterpriseSignOnArgs(savedStateHandle).emailAddress,
ssoCode = ssoCallbackResult.code,
ssoCodeVerifier = ssoData.codeVerifier,
ssoRedirectUri = SSO_URI,
captchaToken = mutableStateFlow.value.captchaToken,
)
sendAction(EnterpriseSignOnAction.Internal.OnLoginResult(result))
}
} else {
showDefaultError()
}
}
}
}
private suspend fun prepareAndLaunchCustomTab(
@@ -184,7 +297,12 @@ class EnterpriseSignOnViewModel @Inject constructor(
// Save this for later so that we can validate the SSO callback response
val generatedSsoState = generatorRepository
.generateRandomString(RANDOM_STRING_LENGTH)
.also { ssoState = it }
.also {
ssoResponseData = SsoResponseData(
codeVerifier = codeVerifier,
state = it,
)
}
val uri = generateUriForSso(
identityBaseUrl = environmentRepository.environment.environmentUrlData.baseIdentityUrl,
@@ -198,6 +316,26 @@ class EnterpriseSignOnViewModel @Inject constructor(
// a result due to user intervention
sendAction(EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult(Uri.parse(uri)))
}
private fun showDefaultError() {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = R.string.login_sso_error.asText(),
),
)
}
}
private fun showLoading() {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
R.string.logging_in.asText(),
),
)
}
}
}
/**
@@ -207,6 +345,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
data class EnterpriseSignOnState(
val dialogState: DialogState?,
val orgIdentifierInput: String,
val captchaToken: String?,
) : Parcelable {
/**
* Represents the current state of any dialogs on the screen.
@@ -247,11 +386,14 @@ sealed class EnterpriseSignOnEvent {
data class NavigateToSsoLogin(val uri: Uri) : EnterpriseSignOnEvent()
/**
* Shows a toast with the given [message].
* Navigates to the captcha verification screen.
*/
data class ShowToast(
val message: String,
) : EnterpriseSignOnEvent()
data class NavigateToCaptcha(val uri: Uri) : EnterpriseSignOnEvent()
/**
* Navigates to the two-factor login screen.
*/
data class NavigateToTwoFactorLogin(val emailAddress: String) : EnterpriseSignOnEvent()
}
/**
@@ -289,9 +431,38 @@ sealed class EnterpriseSignOnAction {
*/
data class OnGenerateUriForSsoResult(val uri: Uri) : Internal()
/**
* A login result has been received.
*/
data class OnLoginResult(val loginResult: LoginResult) : Internal()
/**
* An SSO callback result has been received.
*/
data class OnSsoCallbackResult(val ssoCallbackResult: SsoCallbackResult) : Internal()
/**
* SSO prevalidation failed.
*/
data object OnSsoPrevalidationFailure : Internal()
/**
* A captcha callback result has been received
*/
data class OnReceiveCaptchaToken(val tokenResult: CaptchaCallbackTokenResult) : Internal()
}
}
/**
* Data needed by the SSO flow to verify and continue the process after receiving a response.
*
* @property state A "state" maintained throughout the SSO process to verify that the response from
* the server is valid and matches what was originally sent in the request.
* @property codeVerifier A random string used to generate the code challenge for the initial SSO
* request.
*/
@Parcelize
data class SsoResponseData(
val state: String,
val codeVerifier: String,
) : Parcelable

View File

@@ -44,7 +44,7 @@ fun NavController.navigateToLogin(
fun NavGraphBuilder.loginDestination(
onNavigateBack: () -> Unit,
onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToEnterpriseSignOn: (emailAddress: String) -> Unit,
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
onNavigateToTwoFactorLogin: (emailAddress: String, password: String?) -> Unit,
) {

View File

@@ -65,7 +65,7 @@ import kotlinx.collections.immutable.toImmutableList
fun LoginScreen(
onNavigateBack: () -> Unit,
onNavigateToMasterPasswordHint: (String) -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToEnterpriseSignOn: (String) -> Unit,
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
onNavigateToTwoFactorLogin: (String, String?) -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
@@ -84,7 +84,10 @@ fun LoginScreen(
intentManager.startCustomTabsActivity(uri = event.uri)
}
LoginEvent.NavigateToEnterpriseSignOn -> onNavigateToEnterpriseSignOn()
is LoginEvent.NavigateToEnterpriseSignOn -> {
onNavigateToEnterpriseSignOn(event.emailAddress)
}
is LoginEvent.NavigateToLoginWithDevice -> {
onNavigateToLoginWithDevice(event.emailAddress)
}

View File

@@ -257,7 +257,8 @@ class LoginViewModel @Inject constructor(
}
private fun handleSingleSignOnClicked() {
sendEvent(LoginEvent.NavigateToEnterpriseSignOn)
val email = mutableStateFlow.value.emailAddress
sendEvent(LoginEvent.NavigateToEnterpriseSignOn(email))
}
private fun handlePasswordInputChanged(action: LoginAction.PasswordInputChanged) {
@@ -310,7 +311,7 @@ sealed class LoginEvent {
/**
* Navigates to the enterprise single sign on screen.
*/
data object NavigateToEnterpriseSignOn : LoginEvent()
data class NavigateToEnterpriseSignOn(val emailAddress: String) : LoginEvent()
/**
* Navigates to the login with device screen.