From 0e9241d54c81ab0eab7dc4c4ac91fa72c138b6d2 Mon Sep 17 00:00:00 2001 From: Sean Weiser <125889608+sean-livefront@users.noreply.github.com> Date: Sat, 27 Jan 2024 16:09:30 -0600 Subject: [PATCH] BIT-1517: Add check for claimed organization domain to SSO ViewModel (#816) --- .../datasource/network/api/OrganizationApi.kt | 19 ++ .../network/di/AuthNetworkModule.kt | 10 + ...OrganizationDomainSsoDetailsRequestJson.kt | 14 ++ ...rganizationDomainSsoDetailsResponseJson.kt | 25 ++ .../network/service/OrganizationService.kt | 15 ++ .../service/OrganizationServiceImpl.kt | 21 ++ .../data/auth/repository/AuthRepository.kt | 8 + .../auth/repository/AuthRepositoryImpl.kt | 19 ++ .../repository/di/AuthRepositoryModule.kt | 3 + .../OrganizationDomainSsoDetailsResult.kt | 22 ++ .../EnterpriseSignOnViewModel.kt | 178 ++++++++++---- .../service/OrganizationServiceTest.kt | 59 +++++ .../auth/repository/AuthRepositoryTest.kt | 40 ++++ .../EnterpriseSignOnViewModelTest.kt | 218 ++++++++++++++---- 14 files changed, 560 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/OrganizationApi.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsRequestJson.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsResponseJson.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationService.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/OrganizationDomainSsoDetailsResult.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/OrganizationApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/OrganizationApi.kt new file mode 100644 index 0000000000..287c91f066 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/OrganizationApi.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.api + +import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Defines raw calls under the /organizations API. + */ +interface OrganizationApi { + /** + * Checks for the claimed domain organization of an email for SSO purposes. + */ + @POST("/organizations/domain/sso/details") + suspend fun getClaimedDomainOrganizationDetails( + @Body body: OrganizationDomainSsoDetailsRequestJson, + ): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt index bf632d6cd4..563ebcb1de 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt @@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityServiceImpl import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestServiceImpl +import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService +import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationServiceImpl import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits import dagger.Module import dagger.Provides @@ -84,4 +86,12 @@ object AuthNetworkModule { ): NewAuthRequestService = NewAuthRequestServiceImpl( authRequestsApi = retrofits.unauthenticatedApiRetrofit.create(), ) + + @Provides + @Singleton + fun providesOrganizationService( + retrofits: Retrofits, + ): OrganizationService = OrganizationServiceImpl( + organizationApi = retrofits.unauthenticatedApiRetrofit.create(), + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsRequestJson.kt new file mode 100644 index 0000000000..f51e93c0c1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsRequestJson.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body object when retrieving organization domain SSO info. + * + * @param email The email address to check against. + */ +@Serializable +data class OrganizationDomainSsoDetailsRequestJson( + @SerialName("email") val email: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsResponseJson.kt new file mode 100644 index 0000000000..94edbb6a7a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsResponseJson.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.ZonedDateTime + +/** + * Response object returned when requesting organization domain SSO details. + * + * @property isSsoAvailable Whether or not SSO is available for this domain. + * @property domainName The organization's domain name. + * @property organizationIdentifier The organization's identifier. + * @property isSsoRequired Whether or not SSO is required. + * @property verifiedDate The date these details were verified. + */ +@Serializable +data class OrganizationDomainSsoDetailsResponseJson( + @SerialName("ssoAvailable") val isSsoAvailable: Boolean, + @SerialName("domainName") val domainName: String, + @SerialName("organizationIdentifier") val organizationIdentifier: String, + @SerialName("ssoRequired") val isSsoRequired: Boolean, + @Contextual + @SerialName("verifiedDate") val verifiedDate: ZonedDateTime?, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationService.kt new file mode 100644 index 0000000000..46b387ecc5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationService.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson + +/** + * Provides an API for querying organization endpoints. + */ +interface OrganizationService { + /** + * Request claimed organization domain information for an [email] needed for SSO requests. + */ + suspend fun getOrganizationDomainSsoDetails( + email: String, + ): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceImpl.kt new file mode 100644 index 0000000000..52dd9bf385 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceImpl.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.OrganizationApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson + +/** + * Default implementation of [OrganizationService]. + */ +class OrganizationServiceImpl constructor( + private val organizationApi: OrganizationApi, +) : OrganizationService { + override suspend fun getOrganizationDomainSsoDetails( + email: String, + ): Result = organizationApi + .getClaimedDomainOrganizationDetails( + body = OrganizationDomainSsoDetailsRequestJson( + email = email, + ), + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 935f478856..4d2637f9a0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult +import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult @@ -161,6 +162,13 @@ interface AuthRepository : AuthenticatorProvider { */ fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) + /** + * Checks for a claimed domain organization for the [email] that can be used for an SSO request. + */ + suspend fun getOrganizationDomainSsoDetails( + email: String, + ): OrganizationDomainSsoDetailsResult + /** * Prevalidates the organization identifier used in an SSO request. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index fd1cf9342a..b679ed333e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService +import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager @@ -33,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult +import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult @@ -83,6 +85,7 @@ class AuthRepositoryImpl( private val haveIBeenPwnedService: HaveIBeenPwnedService, private val identityService: IdentityService, private val newAuthRequestService: NewAuthRequestService, + private val organizationService: OrganizationService, private val authSdkSource: AuthSdkSource, private val authDiskSource: AuthDiskSource, private val environmentRepository: EnvironmentRepository, @@ -572,6 +575,22 @@ class AuthRepositoryImpl( mutableCaptchaTokenFlow.tryEmit(tokenResult) } + override suspend fun getOrganizationDomainSsoDetails( + email: String, + ): OrganizationDomainSsoDetailsResult = organizationService + .getOrganizationDomainSsoDetails( + email = email, + ) + .fold( + onSuccess = { + OrganizationDomainSsoDetailsResult.Success( + isSsoAvailable = it.isSsoAvailable, + organizationIdentifier = it.organizationIdentifier, + ) + }, + onFailure = { OrganizationDomainSsoDetailsResult.Failure }, + ) + override suspend fun prevalidateSso( organizationIdentifier: String, ): PrevalidateSsoResult = identityService diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index 51d4d68767..37b019479a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService +import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -38,6 +39,7 @@ object AuthRepositoryModule { identityService: IdentityService, haveIBeenPwnedService: HaveIBeenPwnedService, newAuthRequestService: NewAuthRequestService, + organizationService: OrganizationService, authSdkSource: AuthSdkSource, authDiskSource: AuthDiskSource, dispatchers: DispatcherManager, @@ -51,6 +53,7 @@ object AuthRepositoryModule { devicesService = devicesService, identityService = identityService, newAuthRequestService = newAuthRequestService, + organizationService = organizationService, authSdkSource = authSdkSource, authDiskSource = authDiskSource, haveIBeenPwnedService = haveIBeenPwnedService, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/OrganizationDomainSsoDetailsResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/OrganizationDomainSsoDetailsResult.kt new file mode 100644 index 0000000000..27bdcc399b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/OrganizationDomainSsoDetailsResult.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * Response types when checking for an email's claimed domain organization. + */ +sealed class OrganizationDomainSsoDetailsResult { + /** + * The request was successful. + * + * @property isSsoAvailable Indicates if SSO is available for the email address. + * @property organizationIdentifier The claimed organization identifier for the email address. + */ + data class Success( + val isSsoAvailable: Boolean, + val organizationIdentifier: String, + ) : OrganizationDomainSsoDetailsResult() + + /** + * The request failed. + */ + data object Failure : OrganizationDomainSsoDetailsResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt index 2e16af4bab..a260de93b5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt @@ -7,6 +7,7 @@ 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.OrganizationDomainSsoDetailsResult 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 @@ -49,7 +50,7 @@ class EnterpriseSignOnViewModel @Inject constructor( initialState = savedStateHandle[KEY_STATE] ?: EnterpriseSignOnState( dialogState = null, - orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "", + orgIdentifierInput = "", captchaToken = null, ), ) { @@ -86,6 +87,8 @@ class EnterpriseSignOnViewModel @Inject constructor( ) } .launchIn(viewModelScope) + + checkOrganizationDomainSsoDetails() } override fun handleAction(action: EnterpriseSignOnAction) { @@ -105,6 +108,10 @@ class EnterpriseSignOnViewModel @Inject constructor( handleOnSsoPrevalidationFailure() } + is EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive -> { + handleOnOrganizationDomainSsoDetailsReceive(action) + } + is EnterpriseSignOnAction.Internal.OnSsoCallbackResult -> { handleOnSsoCallbackResult(action) } @@ -128,49 +135,7 @@ class EnterpriseSignOnViewModel @Inject constructor( } private fun handleLogInClicked() { - if (!networkConnectionManager.isNetworkConnected) { - mutableStateFlow.update { - it.copy( - dialogState = EnterpriseSignOnState.DialogState.Error( - title = R.string.internet_connection_required_title.asText(), - message = R.string.internet_connection_required_message.asText(), - ), - ) - } - return - } - - val organizationIdentifier = state.orgIdentifierInput - if (organizationIdentifier.isBlank()) { - mutableStateFlow.update { - it.copy( - dialogState = EnterpriseSignOnState.DialogState.Error( - message = R.string.validation_field_required.asText( - R.string.org_identifier.asText(), - ), - ), - ) - } - return - } - - showLoading() - - viewModelScope.launch { - val prevalidateSsoResult = authRepository.prevalidateSso(organizationIdentifier) - when (prevalidateSsoResult) { - is PrevalidateSsoResult.Failure -> { - sendAction(EnterpriseSignOnAction.Internal.OnSsoPrevalidationFailure) - } - - is PrevalidateSsoResult.Success -> { - prepareAndLaunchCustomTab( - organizationIdentifier = organizationIdentifier, - prevalidateSsoResult = prevalidateSsoResult, - ) - } - } - } + prevalidateSso() } private fun handleOnLoginResult(action: EnterpriseSignOnAction.Internal.OnLoginResult) { @@ -222,6 +187,61 @@ class EnterpriseSignOnViewModel @Inject constructor( showDefaultError() } + private fun handleOnOrganizationDomainSsoDetailsFailure() { + mutableStateFlow.update { + it.copy( + dialogState = null, + orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "", + ) + } + } + + private fun handleOnOrganizationDomainSsoDetailsReceive( + action: EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive, + ) { + when (val orgDetails = action.organizationDomainSsoDetailsResult) { + is OrganizationDomainSsoDetailsResult.Failure -> { + handleOnOrganizationDomainSsoDetailsFailure() + } + + is OrganizationDomainSsoDetailsResult.Success -> { + handleOnOrganizationDomainSsoDetailsSuccess(orgDetails) + } + } + } + + private fun handleOnOrganizationDomainSsoDetailsSuccess( + orgDetails: OrganizationDomainSsoDetailsResult.Success, + ) { + if (!orgDetails.isSsoAvailable) { + mutableStateFlow.update { + it.copy( + dialogState = null, + orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "", + ) + } + return + } + + if (orgDetails.organizationIdentifier.isBlank()) { + mutableStateFlow.update { + it.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + message = R.string.organization_sso_identifier_required.asText(), + ), + orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "", + ) + } + return + } + + mutableStateFlow.update { it.copy(orgIdentifierInput = orgDetails.organizationIdentifier) } + + // If the email address is associated with a claimed organization we can proceed to the + // prevalidation step. + prevalidateSso() + } + private fun handleOrgIdentifierInputChanged( action: EnterpriseSignOnAction.OrgIdentifierInputChange, ) { @@ -259,6 +279,52 @@ class EnterpriseSignOnViewModel @Inject constructor( } } + private fun prevalidateSso() { + if (!networkConnectionManager.isNetworkConnected) { + mutableStateFlow.update { + it.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + title = R.string.internet_connection_required_title.asText(), + message = R.string.internet_connection_required_message.asText(), + ), + ) + } + return + } + + val organizationIdentifier = state.orgIdentifierInput + if (organizationIdentifier.isBlank()) { + mutableStateFlow.update { + it.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + message = R.string.validation_field_required.asText( + R.string.org_identifier.asText(), + ), + ), + ) + } + return + } + + showLoading() + + viewModelScope.launch { + val prevalidateSsoResult = authRepository.prevalidateSso(organizationIdentifier) + when (prevalidateSsoResult) { + is PrevalidateSsoResult.Failure -> { + sendAction(EnterpriseSignOnAction.Internal.OnSsoPrevalidationFailure) + } + + is PrevalidateSsoResult.Success -> { + prepareAndLaunchCustomTab( + organizationIdentifier = organizationIdentifier, + prevalidateSsoResult = prevalidateSsoResult, + ) + } + } + } + } + private fun attemptLogin() { val ssoCallbackResult = requireNotNull(savedSsoCallbackResult) val ssoData = requireNotNull(ssoResponseData) @@ -267,6 +333,7 @@ class EnterpriseSignOnViewModel @Inject constructor( is SsoCallbackResult.MissingCode -> { showDefaultError() } + is SsoCallbackResult.Success -> { if (ssoCallbackResult.state == ssoData.state) { showLoading() @@ -288,6 +355,22 @@ class EnterpriseSignOnViewModel @Inject constructor( } } + private fun checkOrganizationDomainSsoDetails() { + mutableStateFlow.update { + it.copy( + dialogState = EnterpriseSignOnState.DialogState.Loading(R.string.loading.asText()), + ) + } + viewModelScope.launch { + val result = authRepository.getOrganizationDomainSsoDetails( + email = EnterpriseSignOnArgs(savedStateHandle).emailAddress, + ) + sendAction( + EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive(result), + ) + } + } + private suspend fun prepareAndLaunchCustomTab( organizationIdentifier: String, prevalidateSsoResult: PrevalidateSsoResult.Success, @@ -446,6 +529,13 @@ sealed class EnterpriseSignOnAction { */ data object OnSsoPrevalidationFailure : Internal() + /** + * A result was received when requesting an [OrganizationDomainSsoDetailsResult]. + */ + data class OnOrganizationDomainSsoDetailsReceive( + val organizationDomainSsoDetailsResult: OrganizationDomainSsoDetailsResult, + ) : Internal() + /** * A captcha callback result has been received */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceTest.kt new file mode 100644 index 0000000000..c7f2e1cc05 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceTest.kt @@ -0,0 +1,59 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.OrganizationApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson +import com.x8bit.bitwarden.data.platform.base.BaseServiceTest +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import retrofit2.create +import java.time.ZonedDateTime + +class OrganizationServiceTest : BaseServiceTest() { + private val organizationApi: OrganizationApi = retrofit.create() + + private val organizationService = OrganizationServiceImpl( + organizationApi = organizationApi, + ) + + @Suppress("MaxLineLength") + @Test + fun `getOrganizationDomainSsoDetails when response is success should return PrevalidateSsoResponseJson`() = + runTest { + val email = "test@gmail.com" + server.enqueue( + MockResponse().setResponseCode(200).setBody(ORGANIZATION_DOMAIN_SSO_DETAILS_JSON), + ) + val result = organizationService.getOrganizationDomainSsoDetails(email) + assertEquals(Result.success(ORGANIZATION_DOMAIN_SSO_BODY), result) + } + + @Test + fun `getOrganizationDomainSsoDetails when response is an error should return an error`() = + runTest { + val email = "test@gmail.com" + server.enqueue(MockResponse().setResponseCode(400)) + val result = organizationService.getOrganizationDomainSsoDetails(email) + assertTrue(result.isFailure) + } +} + +private const val ORGANIZATION_DOMAIN_SSO_DETAILS_JSON = """ +{ + "ssoAvailable": true, + "domainName": "bitwarden.com", + "organizationIdentifier": "Test Org", + "ssoRequired": false, + "verifiedDate": "2024-09-13T00:00:00.000Z" +} +""" + +private val ORGANIZATION_DOMAIN_SSO_BODY = OrganizationDomainSsoDetailsResponseJson( + isSsoAvailable = true, + organizationIdentifier = "Test Org", + domainName = "bitwarden.com", + isSsoRequired = false, + verifiedDate = ZonedDateTime.parse("2024-09-13T00:00Z"), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 591f958fb0..85fe676e00 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsRespon import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson @@ -29,6 +30,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService +import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0 import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_1 @@ -44,6 +46,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult +import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult @@ -100,6 +103,7 @@ class AuthRepositoryTest { private val identityService: IdentityService = mockk() private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk() private val newAuthRequestService: NewAuthRequestService = mockk() + private val organizationService: OrganizationService = mockk() private val mutableVaultStateFlow = MutableStateFlow(VAULT_STATE) private val vaultRepository: VaultRepository = mockk { every { vaultStateFlow } returns mutableVaultStateFlow @@ -167,6 +171,7 @@ class AuthRepositoryTest { identityService = identityService, haveIBeenPwnedService = haveIBeenPwnedService, newAuthRequestService = newAuthRequestService, + organizationService = organizationService, authSdkSource = authSdkSource, authDiskSource = fakeAuthDiskSource, environmentRepository = fakeEnvironmentRepository, @@ -1853,6 +1858,41 @@ class AuthRepositoryTest { } } + @Test + fun `getOrganizationDomainSsoDetails Failure should return Failure `() = runTest { + val email = "test@gmail.com" + val throwable = Throwable() + coEvery { + organizationService.getOrganizationDomainSsoDetails(email) + } returns Result.failure(throwable) + val result = repository.getOrganizationDomainSsoDetails(email) + assertEquals(OrganizationDomainSsoDetailsResult.Failure, result) + } + + @Test + fun `getOrganizationDomainSsoDetails Success should return Success`() = runTest { + val email = "test@gmail.com" + coEvery { + organizationService.getOrganizationDomainSsoDetails(email) + } returns Result.success( + OrganizationDomainSsoDetailsResponseJson( + isSsoAvailable = true, + organizationIdentifier = "Test Org", + domainName = "bitwarden.com", + isSsoRequired = false, + verifiedDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + ), + ) + val result = repository.getOrganizationDomainSsoDetails(email) + assertEquals( + OrganizationDomainSsoDetailsResult.Success( + isSsoAvailable = true, + organizationIdentifier = "Test Org", + ), + result, + ) + } + @Test fun `prevalidateSso Failure should return Failure `() = runTest { val organizationId = "organizationid" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt index 9cf9a94f6a..3533a2f072 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt @@ -7,6 +7,7 @@ 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.OrganizationDomainSsoDetailsResult 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 @@ -20,6 +21,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import io.mockk.awaits import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -34,6 +36,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +@Suppress("LargeClass") class EnterpriseSignOnViewModelTest : BaseViewModelTest() { private val mutableSsoCallbackResultFlow = bufferedMutableSharedFlow() @@ -43,6 +46,9 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { every { ssoCallbackResultFlow } returns mutableSsoCallbackResultFlow every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow every { rememberedOrgIdentifier } returns null + coEvery { + getOrganizationDomainSsoDetails(any()) + } just awaits } private val environmentRepository: EnvironmentRepository = FakeEnvironmentRepository() @@ -198,7 +204,35 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { @Test fun `LogInClick with no Internet should show error dialog`() = runTest { val viewModel = createViewModel(isNetworkConnected = false) - viewModel.eventFlow.test { + viewModel.actionChannel.trySend(EnterpriseSignOnAction.LogInClick) + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + title = R.string.internet_connection_required_title.asText(), + message = R.string.internet_connection_required_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `OrgIdentifierInputChange should update organization identifier`() = runTest { + val input = "input" + val viewModel = createViewModel() + viewModel.actionChannel.trySend(EnterpriseSignOnAction.OrgIdentifierInputChange(input)) + assertEquals( + DEFAULT_STATE.copy(orgIdentifierInput = input), + viewModel.stateFlow.value, + ) + } + + @Test + fun `DialogDismiss should clear the active dialog when DialogState is Error`() = runTest { + val viewModel = createViewModel(isNetworkConnected = false) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + viewModel.actionChannel.trySend(EnterpriseSignOnAction.LogInClick) assertEquals( DEFAULT_STATE.copy( @@ -207,62 +241,33 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { message = R.string.internet_connection_required_message.asText(), ), ), - viewModel.stateFlow.value, + awaitItem(), ) - } - } - @Test - fun `OrgIdentifierInputChange should update organization identifier`() = runTest { - val input = "input" - val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.actionChannel.trySend(EnterpriseSignOnAction.OrgIdentifierInputChange(input)) + viewModel.actionChannel.trySend(EnterpriseSignOnAction.DialogDismiss) assertEquals( - DEFAULT_STATE.copy(orgIdentifierInput = input), - viewModel.stateFlow.value, + DEFAULT_STATE, + awaitItem(), ) } } - @Test - fun `DialogDismiss should clear the active dialog when DialogState is Error`() { - val initialState = DEFAULT_STATE.copy( - dialogState = EnterpriseSignOnState.DialogState.Error( - message = "Error".asText(), - ), - ) - val viewModel = createViewModel(initialState) - assertEquals( - initialState, - viewModel.stateFlow.value, - ) - - viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss) - - assertEquals( - initialState.copy(dialogState = null), - viewModel.stateFlow.value, - ) - } - @Test fun `DialogDismiss should clear the active dialog when DialogState is Loading`() { - val initialState = DEFAULT_STATE.copy( - dialogState = EnterpriseSignOnState.DialogState.Loading( - message = "Loading".asText(), - ), + val viewModel = createViewModel( + dismissInitialDialog = false, ) - val viewModel = createViewModel(initialState) assertEquals( - initialState, + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.Loading(R.string.loading.asText()), + ), viewModel.stateFlow.value, ) viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss) assertEquals( - initialState.copy(dialogState = null), + DEFAULT_STATE.copy(dialogState = null), viewModel.stateFlow.value, ) } @@ -310,7 +315,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val viewModel = createViewModel( ssoData = DEFAULT_SSO_DATA, - emailAddress = DEFAULT_EMAIL, ) val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") @@ -368,7 +372,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val viewModel = createViewModel( initialState = initialState, ssoData = DEFAULT_SSO_DATA, - emailAddress = DEFAULT_EMAIL, ) val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") @@ -426,7 +429,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val viewModel = createViewModel( initialState = initialState, ssoData = DEFAULT_SSO_DATA, - emailAddress = DEFAULT_EMAIL, ) val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") @@ -481,7 +483,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val viewModel = createViewModel( initialState = initialState, ssoData = DEFAULT_SSO_DATA, - emailAddress = DEFAULT_EMAIL, ) val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") @@ -557,7 +558,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { 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", @@ -589,21 +589,138 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `OrganizationDomainSsoDetails failure should make a request, hide the dialog, and update the org input based on the remembered org`() = runTest { + coEvery { + authRepository.getOrganizationDomainSsoDetails(any()) + } returns OrganizationDomainSsoDetailsResult.Failure + + coEvery { + authRepository.rememberedOrgIdentifier + } returns "Bitwarden" + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden"), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getOrganizationDomainSsoDetails(DEFAULT_EMAIL) + authRepository.rememberedOrgIdentifier + } + } + + @Suppress("MaxLineLength") + @Test + fun `OrganizationDomainSsoDetails success with no SSO available should make a request, hide the dialog, and update the org input based on the remembered org`() = runTest { + val orgDetails = OrganizationDomainSsoDetailsResult.Success( + isSsoAvailable = false, + organizationIdentifier = "Bitwarden without SSO", + ) + + coEvery { + authRepository.getOrganizationDomainSsoDetails(any()) + } returns orgDetails + + coEvery { + authRepository.rememberedOrgIdentifier + } returns "Bitwarden" + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden"), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getOrganizationDomainSsoDetails(DEFAULT_EMAIL) + authRepository.rememberedOrgIdentifier + } + } + + @Suppress("MaxLineLength") + @Test + fun `OrganizationDomainSsoDetails success with blank identifier should make a request, show the error dialog, and update the org input based on the remembered org`() = runTest { + val orgDetails = OrganizationDomainSsoDetailsResult.Success( + isSsoAvailable = true, + organizationIdentifier = "", + ) + + coEvery { + authRepository.getOrganizationDomainSsoDetails(any()) + } returns orgDetails + + coEvery { + authRepository.rememberedOrgIdentifier + } returns "Bitwarden" + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + message = R.string.organization_sso_identifier_required.asText(), + ), + orgIdentifierInput = "Bitwarden", + ), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getOrganizationDomainSsoDetails(DEFAULT_EMAIL) + authRepository.rememberedOrgIdentifier + } + } + + @Suppress("MaxLineLength") + @Test + fun `OrganizationDomainSsoDetails success with valid organization should make a request then attempt to login`() = runTest { + val orgDetails = OrganizationDomainSsoDetailsResult.Success( + isSsoAvailable = true, + organizationIdentifier = "Bitwarden with SSO", + ) + + coEvery { + authRepository.getOrganizationDomainSsoDetails(any()) + } returns orgDetails + + // Just hang on this request; login is tested elsewhere + coEvery { + authRepository.prevalidateSso(any()) + } just awaits + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy( + orgIdentifierInput = "Bitwarden with SSO", + dialogState = EnterpriseSignOnState.DialogState.Loading( + message = R.string.logging_in.asText(), + ), + ), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getOrganizationDomainSsoDetails(DEFAULT_EMAIL) + } + } + @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, - "email_address" to emailAddress, + "email_address" to DEFAULT_EMAIL, "ssoData" to ssoData, "ssoCallbackResult" to ssoCallbackResult, ), ), isNetworkConnected: Boolean = true, + dismissInitialDialog: Boolean = true, ): EnterpriseSignOnViewModel = EnterpriseSignOnViewModel( authRepository = authRepository, environmentRepository = environmentRepository, @@ -611,6 +728,13 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { networkConnectionManager = FakeNetworkConnectionManager(isNetworkConnected), savedStateHandle = savedStateHandle, ) + .also { + if (dismissInitialDialog) { + // A loading dialog is shown on initialization, so allow tests to automatically + // dismiss it. + it.trySendAction(EnterpriseSignOnAction.DialogDismiss) + } + } companion object { private val DEFAULT_STATE = EnterpriseSignOnState(