mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-816: Handle login attempt of SSO flow (#797)
This commit is contained in:
parent
7a163d82ed
commit
c765de99f1
20 changed files with 1178 additions and 79 deletions
|
@ -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).
|
||||
*/
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>?>>()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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" +
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -291,7 +291,7 @@ class LoginScreenTest : BaseComposeTest() {
|
|||
|
||||
@Test
|
||||
fun `NavigateToEnterpriseSignOn should call onNavigateToEnterpriseSignOn`() {
|
||||
mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn)
|
||||
mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn("email"))
|
||||
assertTrue(onNavigateToEnterpriseSignOnCalled)
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue