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.

View file

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

View file

@ -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<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()

View file

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

View file

@ -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" +

View file

@ -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<EnterpriseSignOnEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<EnterpriseSignOnViewModel>(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,
)
}
}

View file

@ -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<SsoCallbackResult>()
private val mutableCaptchaTokenResultFlow =
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
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"
}
}

View file

@ -291,7 +291,7 @@ class LoginScreenTest : BaseComposeTest() {
@Test
fun `NavigateToEnterpriseSignOn should call onNavigateToEnterpriseSignOn`() {
mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn)
mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn("email"))
assertTrue(onNavigateToEnterpriseSignOnCalled)
}

View file

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