From 3fa11bc0e063655434bfe32bfd160f998bab5eed Mon Sep 17 00:00:00 2001 From: Sean Weiser <125889608+sean-livefront@users.noreply.github.com> Date: Fri, 26 Jan 2024 10:01:45 -0600 Subject: [PATCH] BIT-816: Handle login attempt of SSO flow (#797) --- .../auth/datasource/disk/AuthDiskSource.kt | 5 + .../datasource/disk/AuthDiskSourceImpl.kt | 10 + .../data/auth/repository/AuthRepository.kt | 16 + .../auth/repository/AuthRepositoryImpl.kt | 18 + .../data/auth/repository/util/SsoUtils.kt | 10 +- .../ui/auth/feature/auth/AuthNavigation.kt | 12 +- .../EnterpriseSignOnNavigation.kt | 30 +- .../EnterpriseSignOnScreen.kt | 15 +- .../EnterpriseSignOnViewModel.kt | 235 +++++++-- .../ui/auth/feature/login/LoginNavigation.kt | 2 +- .../ui/auth/feature/login/LoginScreen.kt | 7 +- .../ui/auth/feature/login/LoginViewModel.kt | 5 +- .../datasource/disk/AuthDiskSourceTest.kt | 20 + .../disk/util/FakeAuthDiskSource.kt | 1 + .../auth/repository/AuthRepositoryTest.kt | 465 ++++++++++++++++++ .../data/auth/repository/util/SsoUtilsTest.kt | 2 +- .../EnterpriseSignOnScreenTest.kt | 20 + .../EnterpriseSignOnViewModelTest.kt | 380 +++++++++++++- .../ui/auth/feature/login/LoginScreenTest.kt | 2 +- .../auth/feature/login/LoginViewModelTest.kt | 2 +- 20 files changed, 1178 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index 82e1e1ebe1..d96486103b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -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). */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index 6e3ac77c92..4685e63d5e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -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) { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index f7f20a0df0..8003d02067 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index c3274424e1..055d06b942 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SsoUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SsoUtils.kt index 4c4f55ab58..d4d83f665d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SsoUtils.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SsoUtils.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 2f73e4ce46..13a5fd6eee 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt index 04bbe1cd97..f6cf381fc5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt @@ -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, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt index 6dc8aff7db..bed82d0e97 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt @@ -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) + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt index 40eb336537..2e16af4bab 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt index 44d131856b..770df6ba16 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt @@ -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, ) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt index d79e4f57bc..e65016e445 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -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) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 62f028dd15..5a1809d979 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -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. diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index ac3133f582..4d82775313 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -97,6 +97,26 @@ class AuthDiskSourceTest { assertNull(authDiskSource.rememberedEmailAddress) } + @Test + fun `rememberedOrgIdentifier should pull from and update SharedPreferences`() { + val rememberedOrgIdentifierKey = "bwPreferencesStorage:rememberedOrgIdentifier" + + // Shared preferences and the disk source start with the same value. + assertNull(authDiskSource.rememberedOrgIdentifier) + assertNull(fakeSharedPreferences.getString(rememberedOrgIdentifierKey, null)) + + // Updating the disk source updates shared preferences + authDiskSource.rememberedOrgIdentifier = "Bitwarden" + assertEquals( + "Bitwarden", + fakeSharedPreferences.getString(rememberedOrgIdentifierKey, null), + ) + + // Update SharedPreferences updates the disk source + fakeSharedPreferences.edit { putString(rememberedOrgIdentifierKey, null) } + assertNull(authDiskSource.rememberedOrgIdentifier) + } + @Test fun `userState should pull from and update SharedPreferences`() { val userStateKey = "bwPreferencesStorage:state" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index b5464cbc62..dbe596fd95 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -14,6 +14,7 @@ class FakeAuthDiskSource : AuthDiskSource { override val uniqueAppId: String = "testUniqueAppId" override var rememberedEmailAddress: String? = null + override var rememberedOrgIdentifier: String? = null private val mutableOrganizationsFlowMap = mutableMapOf?>>() diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index e5218925cd..2cedb6af26 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -325,6 +325,21 @@ class AuthRepositoryTest { assertNull(repository.rememberedEmailAddress) } + @Test + fun `rememberedOrgIdentifier should pull from and update AuthDiskSource`() { + // AuthDiskSource and the repository start with the same value. + assertNull(repository.rememberedOrgIdentifier) + assertNull(fakeAuthDiskSource.rememberedOrgIdentifier) + + // Updating the repository updates AuthDiskSource + repository.rememberedOrgIdentifier = "Bitwarden" + assertEquals("Bitwarden", fakeAuthDiskSource.rememberedOrgIdentifier) + + // Updating AuthDiskSource updates the repository + fakeAuthDiskSource.rememberedOrgIdentifier = null + assertNull(repository.rememberedOrgIdentifier) + } + @Test fun `clear Pending Account Deletion should unblock userState updates`() = runTest { val masterPassword = "hello world" @@ -986,6 +1001,453 @@ class AuthRepositoryTest { assertEquals(LoginResult.Error(errorMessage = null), result) } + @Test + fun `SSO login get token fails should return Error with no message`() = runTest { + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + .returns(Result.failure(RuntimeException())) + val result = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + ) + assertEquals(LoginResult.Error(errorMessage = null), result) + assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) + coVerify { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + } + + @Test + fun `SSO login get token returns Invalid should return Error with correct message`() = runTest { + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } returns Result.success( + GetTokenResponseJson.Invalid( + errorModel = GetTokenResponseJson.Invalid.ErrorModel( + errorMessage = "mock_error_message", + ), + ), + ) + + val result = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + ) + assertEquals(LoginResult.Error(errorMessage = "mock_error_message"), result) + assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) + coVerify { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `SSO login get token succeeds should return Success, update AuthState, update stored keys, and sync`() = + runTest { + val successResponse = GET_TOKEN_RESPONSE_SUCCESS + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + .returns(Result.success(successResponse)) + coEvery { vaultRepository.syncIfNecessary() } just runs + every { + GET_TOKEN_RESPONSE_SUCCESS.toUserState( + previousUserState = null, + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ) + } returns SINGLE_USER_STATE_1 + val result = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + ) + assertEquals(LoginResult.Success, result) + assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value) + fakeAuthDiskSource.assertPrivateKey( + userId = USER_ID_1, + privateKey = "privateKey", + ) + fakeAuthDiskSource.assertUserKey( + userId = USER_ID_1, + userKey = "key", + ) + coVerify { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + vaultRepository.syncIfNecessary() + } + assertEquals( + SINGLE_USER_STATE_1, + fakeAuthDiskSource.userState, + ) + verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) } + } + + @Suppress("MaxLineLength") + @Test + fun `SSO login get token succeeds when there is an existing user should switch to the new logged in user`() = + runTest { + // Ensure the initial state for User 2 with a account addition + fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 + repository.hasPendingAccountAddition = true + + // Set up login for User 1 + val successResponse = GET_TOKEN_RESPONSE_SUCCESS + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + .returns(Result.success(successResponse)) + coEvery { vaultRepository.syncIfNecessary() } just runs + every { + GET_TOKEN_RESPONSE_SUCCESS.toUserState( + previousUserState = SINGLE_USER_STATE_2, + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ) + } returns MULTI_USER_STATE + + val result = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + ) + + assertEquals(LoginResult.Success, result) + assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value) + fakeAuthDiskSource.assertPrivateKey( + userId = USER_ID_1, + privateKey = "privateKey", + ) + fakeAuthDiskSource.assertUserKey( + userId = USER_ID_1, + userKey = "key", + ) + coVerify { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + vaultRepository.syncIfNecessary() + } + assertEquals( + MULTI_USER_STATE, + fakeAuthDiskSource.userState, + ) + assertFalse(repository.hasPendingAccountAddition) + verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) } + } + + @Test + fun `SSO login get token returns captcha request should return CaptchaRequired`() = runTest { + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + .returns(Result.success(GetTokenResponseJson.CaptchaRequired(CAPTCHA_KEY))) + val result = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + ) + assertEquals(LoginResult.CaptchaRequired(CAPTCHA_KEY), result) + assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) + coVerify { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `SSO login get token returns two factor request should return TwoFactorRequired`() = runTest { + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + .returns( + Result.success( + GetTokenResponseJson.TwoFactorRequired( + TWO_FACTOR_AUTH_METHODS_DATA, null, null, + ), + ), + ) + val result = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + ) + assertEquals(LoginResult.TwoFactorRequired, result) + assertEquals( + repository.twoFactorResponse, + GetTokenResponseJson.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null), + ) + assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) + coVerify { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + } + + @Test + fun `SSO login two factor with remember saves two factor auth token`() = runTest { + // Attempt a normal login with a two factor error first, so that the auth + // data will be cached. + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } returns Result.success( + GetTokenResponseJson.TwoFactorRequired( + TWO_FACTOR_AUTH_METHODS_DATA, null, null, + ), + ) + val firstResult = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + ) + assertEquals(LoginResult.TwoFactorRequired, firstResult) + coVerify { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + + // Login with two factor data. + val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy( + twoFactorToken = "twoFactorTokenToStore", + ) + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + twoFactorData = TWO_FACTOR_DATA, + ) + } returns Result.success(successResponse) + coEvery { vaultRepository.syncIfNecessary() } just runs + every { + successResponse.toUserState( + previousUserState = null, + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ) + } returns SINGLE_USER_STATE_1 + val finalResult = repository.login( + email = EMAIL, + password = null, + twoFactorData = TWO_FACTOR_DATA, + captchaToken = null, + ) + assertEquals(LoginResult.Success, finalResult) + assertNull(repository.twoFactorResponse) + fakeAuthDiskSource.assertTwoFactorToken( + email = EMAIL, + twoFactorToken = "twoFactorTokenToStore", + ) + } + + @Test + fun `SSO login uses remembered two factor tokens`() = runTest { + fakeAuthDiskSource.storeTwoFactorToken(EMAIL, "storedTwoFactorToken") + val rememberedTwoFactorData = TwoFactorDataModel( + code = "storedTwoFactorToken", + method = TwoFactorAuthMethod.REMEMBER.value.toString(), + remember = false, + ) + val successResponse = GET_TOKEN_RESPONSE_SUCCESS + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + twoFactorData = rememberedTwoFactorData, + ) + } returns Result.success(successResponse) + coEvery { vaultRepository.syncIfNecessary() } just runs + every { + GET_TOKEN_RESPONSE_SUCCESS.toUserState( + previousUserState = null, + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ) + } returns SINGLE_USER_STATE_1 + val result = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + ) + assertEquals(LoginResult.Success, result) + assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value) + fakeAuthDiskSource.assertPrivateKey( + userId = USER_ID_1, + privateKey = "privateKey", + ) + fakeAuthDiskSource.assertUserKey( + userId = USER_ID_1, + userKey = "key", + ) + coVerify { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + twoFactorData = rememberedTwoFactorData, + ) + vaultRepository.syncIfNecessary() + } + assertEquals( + SINGLE_USER_STATE_1, + fakeAuthDiskSource.userState, + ) + verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) } + } + @Test fun `register check data breaches error should still return register success`() = runTest { coEvery { @@ -1950,6 +2412,9 @@ class AuthRepositoryTest { method = TWO_FACTOR_METHOD.value.toString(), remember = TWO_FACTOR_REMEMBER, ) + private const val SSO_CODE = "ssoCode" + private const val SSO_CODE_VERIFIER = "ssoCodeVerifier" + private const val SSO_REDIRECT_URI = "bitwarden://sso-test" private const val DEFAULT_KDF_ITERATIONS = 600000 private const val ENCRYPTED_USER_KEY = "encryptedUserKey" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SsoUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SsoUtilsTest.kt index 6d95c1ce47..ae1e4d537a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SsoUtilsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SsoUtilsTest.kt @@ -31,7 +31,7 @@ class SsoUtilsTest { "&response_type=code" + "&scope=api%20offline_access" + "&state=test_state" + - "&code_challenge=Qq1fGD0HhxwbmeMrqaebgn1qhvKeguQPXqLdpmixaM4=" + + "&code_challenge=Qq1fGD0HhxwbmeMrqaebgn1qhvKeguQPXqLdpmixaM4" + "&code_challenge_method=S256" + "&response_mode=query" + "&domain_hint=Test+Organization" + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt index d68fe3ece6..94991b13b9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt @@ -26,9 +26,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.junit.Before import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals class EnterpriseSignOnScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false + private var twoFactorLoginEmail: String? = null private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -45,6 +47,7 @@ class EnterpriseSignOnScreenTest : BaseComposeTest() { composeTestRule.setContent { EnterpriseSignOnScreen( onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToTwoFactorLogin = { twoFactorLoginEmail = it }, viewModel = viewModel, intentManager = intentManager, ) @@ -102,6 +105,22 @@ class EnterpriseSignOnScreenTest : BaseComposeTest() { } } + @Test + fun `NavigateToCaptcha should call startCustomTabsActivity`() { + val captchaUri = Uri.parse("https://captcha.com") + mutableEventFlow.tryEmit(EnterpriseSignOnEvent.NavigateToCaptcha(captchaUri)) + verify(exactly = 1) { + intentManager.startCustomTabsActivity(captchaUri) + } + } + + @Test + fun `NavigateToTwoFactorLogin should call onNavigateToTwoFactorLogin`() { + val email = "test@example.com" + mutableEventFlow.tryEmit(EnterpriseSignOnEvent.NavigateToTwoFactorLogin(email)) + assertEquals(email, twoFactorLoginEmail) + } + @Test fun `error dialog should be shown or hidden according to the state`() { composeTestRule.onNode(isDialog()).assertDoesNotExist() @@ -170,6 +189,7 @@ class EnterpriseSignOnScreenTest : BaseComposeTest() { private val DEFAULT_STATE = EnterpriseSignOnState( dialogState = null, orgIdentifierInput = "", + captchaToken = null, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt index 1c291ca307..9cf9a94f6a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt @@ -3,10 +3,14 @@ package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import app.cash.turbine.turbineScope 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.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.util.FakeNetworkConnectionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository @@ -17,9 +21,12 @@ import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRep import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.runs import io.mockk.unmockkStatic import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach @@ -30,16 +37,18 @@ import org.junit.jupiter.api.Test class EnterpriseSignOnViewModelTest : BaseViewModelTest() { private val mutableSsoCallbackResultFlow = bufferedMutableSharedFlow() + private val mutableCaptchaTokenResultFlow = + bufferedMutableSharedFlow() private val authRepository: AuthRepository = mockk { every { ssoCallbackResultFlow } returns mutableSsoCallbackResultFlow + every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow + every { rememberedOrgIdentifier } returns null } private val environmentRepository: EnvironmentRepository = FakeEnvironmentRepository() private val generatorRepository: GeneratorRepository = FakeGeneratorRepository() - private val savedStateHandle = SavedStateHandle() - @BeforeEach fun setUp() { mockkStatic(::generateUriForSso) @@ -85,7 +94,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `LogInClick with valid organization and failed prevalidation should emit ShowToast, show a loading dialog, and then show an error`() = + fun `LogInClick with valid organization and failed prevalidation should show a loading dialog, and then show an error`() = runTest { val organizationId = "Test" val state = DEFAULT_STATE.copy(orgIdentifierInput = organizationId) @@ -111,23 +120,17 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { assertEquals( state.copy( dialogState = EnterpriseSignOnState.DialogState.Error( - message = R.string.login_sso_error.asText(), + message = R.string.login_sso_error.asText(), ), ), awaitItem(), ) } - viewModel.eventFlow.test { - assertEquals( - EnterpriseSignOnEvent.ShowToast("Not yet implemented."), - awaitItem(), - ) - } } @Suppress("MaxLineLength") @Test - fun `LogInClick with valid organization and successful prevalidation should emit ShowToast, show a loading dialog, hide a loading dialog, and then emit NavigateToSsoLogin`() = + fun `LogInClick with valid organization and successful prevalidation should show a loading dialog, hide a loading dialog, and then emit NavigateToSsoLogin`() = runTest { val organizationId = "Test" val state = DEFAULT_STATE.copy(orgIdentifierInput = organizationId) @@ -164,10 +167,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ) } viewModel.eventFlow.test { - assertEquals( - EnterpriseSignOnEvent.ShowToast("Not yet implemented."), - awaitItem(), - ) assertEquals( EnterpriseSignOnEvent.NavigateToSsoLogin(ssoUri), awaitItem(), @@ -177,7 +176,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `LogInClick with invalid organization should emit ShowToast and show error dialog`() = + fun `LogInClick with invalid organization should show error dialog`() = runTest { val viewModel = createViewModel() viewModel.eventFlow.test { @@ -192,16 +191,12 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ), viewModel.stateFlow.value, ) - assertEquals( - EnterpriseSignOnEvent.ShowToast("Not yet implemented."), - awaitItem(), - ) } } @Suppress("MaxLineLength") @Test - fun `LogInClick with no Internet should emit ShowToast and show error dialog`() = runTest { + fun `LogInClick with no Internet should show error dialog`() = runTest { val viewModel = createViewModel(isNetworkConnected = false) viewModel.eventFlow.test { viewModel.actionChannel.trySend(EnterpriseSignOnAction.LogInClick) @@ -214,10 +209,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ), viewModel.stateFlow.value, ) - assertEquals( - EnterpriseSignOnEvent.ShowToast("Not yet implemented."), - awaitItem(), - ) } } @@ -276,10 +267,341 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ) } + @Test + fun `ssoCallbackResultFlow MissingCode should show an error dialog`() { + val viewModel = createViewModel( + ssoData = DEFAULT_SSO_DATA, + ) + mutableSsoCallbackResultFlow.tryEmit(SsoCallbackResult.MissingCode) + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + message = R.string.login_sso_error.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `ssoCallbackResultFlow Success with different state should show an error dialog`() { + val viewModel = createViewModel( + ssoData = DEFAULT_SSO_DATA, + ) + val ssoCallbackResult = SsoCallbackResult.Success(state = "xyz", code = "lmn") + mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult) + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + message = R.string.login_sso_error.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `ssoCallbackResultFlow Success with same state with login Error should show loading dialog then show an error`() = + runTest { + coEvery { + authRepository.login(any(), any(), any(), any(), any()) + } returns LoginResult.Error(null) + + val viewModel = createViewModel( + ssoData = DEFAULT_SSO_DATA, + emailAddress = DEFAULT_EMAIL, + ) + val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + + mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.Loading( + R.string.logging_in.asText(), + ), + ), + awaitItem(), + ) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + message = R.string.login_sso_error.asText(), + ), + ), + awaitItem(), + ) + } + + coVerify(exactly = 1) { + authRepository.login( + email = "test@gmail.com", + ssoCode = "lmn", + ssoCodeVerifier = "def", + ssoRedirectUri = "bitwarden://sso-callback", + captchaToken = null, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ssoCallbackResultFlow Success with same state with login Success should show loading dialog, hide it, and save org identifier`() = + runTest { + coEvery { + authRepository.login(any(), any(), any(), any(), any()) + } returns LoginResult.Success + + coEvery { + authRepository.rememberedOrgIdentifier = "Bitwarden" + } just runs + + val initialState = DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden") + val viewModel = createViewModel( + initialState = initialState, + ssoData = DEFAULT_SSO_DATA, + emailAddress = DEFAULT_EMAIL, + ) + val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") + + viewModel.stateFlow.test { + assertEquals( + initialState, + awaitItem(), + ) + + mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult) + + assertEquals( + initialState.copy( + dialogState = EnterpriseSignOnState.DialogState.Loading( + R.string.logging_in.asText(), + ), + ), + awaitItem(), + ) + + assertEquals( + initialState, + awaitItem(), + ) + } + + coVerify(exactly = 1) { + authRepository.login( + email = "test@gmail.com", + ssoCode = "lmn", + ssoCodeVerifier = "def", + ssoRedirectUri = "bitwarden://sso-callback", + captchaToken = null, + ) + } + coVerify(exactly = 1) { + authRepository.rememberedOrgIdentifier = "Bitwarden" + } + } + + @Suppress("MaxLineLength") + @Test + fun `ssoCallbackResultFlow Success with same state with login CaptchaRequired should show loading dialog, hide it, and send NavigateToCaptcha event`() = + runTest { + coEvery { + authRepository.login(any(), any(), any(), any(), any()) + } returns LoginResult.CaptchaRequired("captcha") + + val uri: Uri = mockk() + every { + generateUriForCaptcha(captchaId = "captcha") + } returns uri + + val initialState = DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden") + val viewModel = createViewModel( + initialState = initialState, + ssoData = DEFAULT_SSO_DATA, + emailAddress = DEFAULT_EMAIL, + ) + val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") + + turbineScope { + val stateFlow = viewModel.stateFlow.testIn(backgroundScope) + val eventFlow = viewModel.eventFlow.testIn(backgroundScope) + + assertEquals(initialState, stateFlow.awaitItem()) + + mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult) + + assertEquals( + initialState.copy( + dialogState = EnterpriseSignOnState.DialogState.Loading( + R.string.logging_in.asText(), + ), + ), + stateFlow.awaitItem(), + ) + + assertEquals( + initialState, + stateFlow.awaitItem(), + ) + + assertEquals( + EnterpriseSignOnEvent.NavigateToCaptcha(uri), + eventFlow.awaitItem(), + ) + } + + coVerify(exactly = 1) { + authRepository.login( + email = "test@gmail.com", + ssoCode = "lmn", + ssoCodeVerifier = "def", + ssoRedirectUri = "bitwarden://sso-callback", + captchaToken = null, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ssoCallbackResultFlow Success with same state with login TwoFactorRequired should show loading dialog, hide it, and send NavigateToTwoFactorLogin event`() = + runTest { + coEvery { + authRepository.login(any(), any(), any(), any(), any()) + } returns LoginResult.TwoFactorRequired + + val initialState = DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden") + val viewModel = createViewModel( + initialState = initialState, + ssoData = DEFAULT_SSO_DATA, + emailAddress = DEFAULT_EMAIL, + ) + val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") + + turbineScope { + val stateFlow = viewModel.stateFlow.testIn(backgroundScope) + val eventFlow = viewModel.eventFlow.testIn(backgroundScope) + + assertEquals(initialState, stateFlow.awaitItem()) + + mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult) + + assertEquals( + initialState.copy( + dialogState = EnterpriseSignOnState.DialogState.Loading( + R.string.logging_in.asText(), + ), + ), + stateFlow.awaitItem(), + ) + + assertEquals( + initialState, + stateFlow.awaitItem(), + ) + + assertEquals( + EnterpriseSignOnEvent.NavigateToTwoFactorLogin("test@gmail.com"), + eventFlow.awaitItem(), + ) + } + + coVerify(exactly = 1) { + authRepository.login( + email = "test@gmail.com", + ssoCode = "lmn", + ssoCodeVerifier = "def", + ssoRedirectUri = "bitwarden://sso-callback", + captchaToken = null, + ) + } + } + + @Test + fun `captchaTokenResultFlow MissingToken should show error dialog`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + + mutableCaptchaTokenResultFlow.tryEmit(CaptchaCallbackTokenResult.MissingToken) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + title = R.string.log_in_denied.asText(), + message = R.string.captcha_failed.asText(), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `captchaTokenResultFlow Success should update the state and attempt to login`() = runTest { + coEvery { + authRepository.login(any(), any(), any(), any(), any()) + } returns LoginResult.Success + + coEvery { + authRepository.rememberedOrgIdentifier = "Bitwarden" + } just runs + + val initialState = DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden") + val viewModel = createViewModel( + initialState = initialState, + emailAddress = "test@gmail.com", + ssoData = DEFAULT_SSO_DATA, + ssoCallbackResult = SsoCallbackResult.Success( + state = "abc", + code = "lmn", + ), + ) + viewModel.stateFlow.test { + assertEquals( + initialState, + awaitItem(), + ) + + mutableCaptchaTokenResultFlow.tryEmit(CaptchaCallbackTokenResult.Success("token")) + + assertEquals( + initialState.copy( + captchaToken = "token", + dialogState = EnterpriseSignOnState.DialogState.Loading( + R.string.logging_in.asText(), + ), + ), + awaitItem(), + ) + + assertEquals( + initialState.copy(captchaToken = "token"), + awaitItem(), + ) + } + } + + @Suppress("LongParameterList") private fun createViewModel( initialState: EnterpriseSignOnState? = null, + emailAddress: String? = null, + ssoData: SsoResponseData? = null, + ssoCallbackResult: SsoCallbackResult? = null, savedStateHandle: SavedStateHandle = SavedStateHandle( - initialState = mapOf("state" to initialState), + initialState = mapOf( + "state" to initialState, + "email_address" to emailAddress, + "ssoData" to ssoData, + "ssoCallbackResult" to ssoCallbackResult, + ), ), isNetworkConnected: Boolean = true, ): EnterpriseSignOnViewModel = EnterpriseSignOnViewModel( @@ -294,6 +616,12 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { private val DEFAULT_STATE = EnterpriseSignOnState( dialogState = null, orgIdentifierInput = "", + captchaToken = null, ) + private val DEFAULT_SSO_DATA = SsoResponseData( + state = "abc", + codeVerifier = "def", + ) + private const val DEFAULT_EMAIL = "test@gmail.com" } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt index 3e8eb13323..7316b530de 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -291,7 +291,7 @@ class LoginScreenTest : BaseComposeTest() { @Test fun `NavigateToEnterpriseSignOn should call onNavigateToEnterpriseSignOn`() { - mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn) + mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn("email")) assertTrue(onNavigateToEnterpriseSignOnCalled) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index 914b5d4ee3..dc66b06484 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -368,7 +368,7 @@ class LoginViewModelTest : BaseViewModelTest() { viewModel.actionChannel.trySend(LoginAction.SingleSignOnClick) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) assertEquals( - LoginEvent.NavigateToEnterpriseSignOn, + LoginEvent.NavigateToEnterpriseSignOn("test@gmail.com"), awaitItem(), ) }