From e5e0464929db5987226c54e0b52bd86563a375b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Mon, 4 Nov 2024 10:53:57 +0000 Subject: [PATCH] [PM-12406] Introduce new endpoint and replace SSO details response flow (#4177) --- .../api/UnauthenticatedOrganizationApi.kt | 10 ++ ...rganizationDomainSsoDetailsResponseJson.kt | 14 +- ...fiedOrganizationDomainSsoDetailsRequest.kt | 14 ++ ...iedOrganizationDomainSsoDetailsResponse.kt | 35 +++++ .../network/service/OrganizationService.kt | 9 ++ .../service/OrganizationServiceImpl.kt | 11 ++ .../data/auth/repository/AuthRepository.kt | 8 + .../auth/repository/AuthRepositoryImpl.kt | 17 ++ .../OrganizationDomainSsoDetailsResult.kt | 4 + ...ifiedOrganizationDomainSsoDetailsResult.kt | 22 +++ .../data/platform/manager/model/FlagKey.kt | 9 ++ .../EnterpriseSignOnViewModel.kt | 88 ++++++++++- .../components/FeatureFlagListItems.kt | 2 + app/src/main/res/values/strings.xml | 1 + .../service/OrganizationServiceTest.kt | 62 +++++++- .../auth/repository/AuthRepositoryTest.kt | 46 ++++++ .../EnterpriseSignOnViewModelTest.kt | 148 ++++++++++++++++++ .../debugmenu/DebugMenuViewModelTest.kt | 2 + 18 files changed, 490 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsRequest.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsResponse.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifiedOrganizationDomainSsoDetailsResult.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedOrganizationApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedOrganizationApi.kt index 20f3c70bf..ec26cb922 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedOrganizationApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedOrganizationApi.kt @@ -2,6 +2,8 @@ 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 com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse import retrofit2.http.Body import retrofit2.http.POST @@ -16,4 +18,12 @@ interface UnauthenticatedOrganizationApi { suspend fun getClaimedDomainOrganizationDetails( @Body body: OrganizationDomainSsoDetailsRequestJson, ): Result + + /** + * Checks for the verfied organization domains of an email for SSO purposes. + */ + @POST("/organizations/domain/sso/verified") + suspend fun getVerifiedOrganizationDomainsByEmail( + @Body body: VerifiedOrganizationDomainSsoDetailsRequest, + ): Result } 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 index ad03c30f4..df4dc3806 100644 --- 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 @@ -1,16 +1,26 @@ 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 organizationIdentifier The organization's identifier. + * @property verifiedDate The date the domain was verified. */ @Serializable data class OrganizationDomainSsoDetailsResponseJson( - @SerialName("ssoAvailable") val isSsoAvailable: Boolean, - @SerialName("organizationIdentifier") val organizationIdentifier: String, + @SerialName("ssoAvailable") + val isSsoAvailable: Boolean, + + @SerialName("organizationIdentifier") + val organizationIdentifier: String, + + @SerialName("verifiedDate") + @Contextual + val verifiedDate: ZonedDateTime?, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsRequest.kt new file mode 100644 index 000000000..653f1686a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsRequest.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 verified domain SSO info. + * + * @param email The email address to check against. + */ +@Serializable +data class VerifiedOrganizationDomainSsoDetailsRequest( + @SerialName("email") val email: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsResponse.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsResponse.kt new file mode 100644 index 000000000..7f0337795 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsResponse.kt @@ -0,0 +1,35 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Response object returned when requesting organization verified domain SSO details. + * + * @property verifiedOrganizationDomainSsoDetails The list of verified organization domain SSO + * details. + */ +@Serializable +data class VerifiedOrganizationDomainSsoDetailsResponse( + @SerialName("data") + val verifiedOrganizationDomainSsoDetails: List, +) { + /** + * Response body for an organization verified domain SSO details. + * + * @property organizationName The name of the organization. + * @property organizationIdentifier The identifier of the organization. + * @property domainName The name of the domain. + */ + @Serializable + data class VerifiedOrganizationDomainSsoDetail( + @SerialName("organizationName") + val organizationName: String, + + @SerialName("organizationIdentifier") + val organizationIdentifier: String, + + @SerialName("domainName") + val domainName: String, + ) +} 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 index d25d6765d..0febe90f8 100644 --- 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 @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationAutoEnrollStatusResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationKeysResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse /** * Provides an API for querying organization endpoints. @@ -38,4 +39,12 @@ interface OrganizationService { suspend fun getOrganizationKeys( organizationId: String, ): Result + + /** + * Request organization verified domain details for an [email] needed for SSO + * requests. + */ + suspend fun getVerifiedOrganizationDomainSsoDetails( + 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 index 8de08bd27..f4d857da9 100644 --- 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 @@ -7,6 +7,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomain import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationKeysResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationResetPasswordEnrollRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse /** * Default implementation of [OrganizationService]. @@ -52,4 +54,13 @@ class OrganizationServiceImpl( .getOrganizationKeys( organizationId = organizationId, ) + + override suspend fun getVerifiedOrganizationDomainSsoDetails( + email: String, + ): Result = unauthenticatedOrganizationApi + .getVerifiedOrganizationDomainsByEmail( + body = VerifiedOrganizationDomainSsoDetailsRequest( + 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 fc316f28d..b9102b146 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 @@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult +import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult @@ -329,6 +330,13 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { email: String, ): OrganizationDomainSsoDetailsResult + /** + * Get the verified organization domain SSO details for the given [email]. + */ + suspend fun getVerifiedOrganizationDomainSsoDetails( + email: String, + ): VerifiedOrganizationDomainSsoDetailsResult + /** * 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 88f22b081..bc76f5131 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 @@ -68,6 +68,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult @@ -1123,11 +1124,27 @@ class AuthRepositoryImpl( OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = it.isSsoAvailable, organizationIdentifier = it.organizationIdentifier, + verifiedDate = it.verifiedDate, ) }, onFailure = { OrganizationDomainSsoDetailsResult.Failure }, ) + override suspend fun getVerifiedOrganizationDomainSsoDetails( + email: String, + ): VerifiedOrganizationDomainSsoDetailsResult = organizationService + .getVerifiedOrganizationDomainSsoDetails( + email = email, + ) + .fold( + onSuccess = { + VerifiedOrganizationDomainSsoDetailsResult.Success( + verifiedOrganizationDomainSsoDetails = it.verifiedOrganizationDomainSsoDetails, + ) + }, + onFailure = { VerifiedOrganizationDomainSsoDetailsResult.Failure }, + ) + override suspend fun prevalidateSso( organizationIdentifier: String, ): PrevalidateSsoResult = identityService 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 index 27bdcc399..70afde04f 100644 --- 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 @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.auth.repository.model +import java.time.ZonedDateTime + /** * Response types when checking for an email's claimed domain organization. */ @@ -9,10 +11,12 @@ sealed class OrganizationDomainSsoDetailsResult { * * @property isSsoAvailable Indicates if SSO is available for the email address. * @property organizationIdentifier The claimed organization identifier for the email address. + * @property verifiedDate The date and time when the domain was verified. */ data class Success( val isSsoAvailable: Boolean, val organizationIdentifier: String, + val verifiedDate: ZonedDateTime?, ) : OrganizationDomainSsoDetailsResult() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifiedOrganizationDomainSsoDetailsResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifiedOrganizationDomainSsoDetailsResult.kt new file mode 100644 index 000000000..bab873666 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifiedOrganizationDomainSsoDetailsResult.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail + +/** + * Response types when checking for an email's claimed domain organization. + */ +sealed class VerifiedOrganizationDomainSsoDetailsResult { + /** + * The request was successful. + * + * @property verifiedOrganizationDomainSsoDetails The verified organization domain SSO details. + */ + data class Success( + val verifiedOrganizationDomainSsoDetails: List, + ) : VerifiedOrganizationDomainSsoDetailsResult() + + /** + * The request failed. + */ + data object Failure : VerifiedOrganizationDomainSsoDetailsResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt index 861711879..60d81bd42 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt @@ -32,6 +32,7 @@ sealed class FlagKey { OnboardingCarousel, ImportLoginsFlow, SshKeyCipherItems, + VerifiedSsoDomainEndpoint, ) } } @@ -89,6 +90,14 @@ sealed class FlagKey { override val defaultValue: Boolean = false override val isRemotelyConfigured: Boolean = true } + /** + * Data object holding the feature flag key for the new verified SSO domain endpoint feature. + */ + data object VerifiedSsoDomainEndpoint : FlagKey() { + override val keyName: String = "pm-12337-refactor-sso-details-endpoint" + override val defaultValue: Boolean = false + override val isRemotelyConfigured: Boolean = true + } /** * Data object holding the key for a [Boolean] flag to be used in tests. 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 243c61327..1ae84ce2a 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 @@ -9,12 +9,15 @@ 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.model.VerifiedOrganizationDomainSsoDetailsResult 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.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.baseIdentityUrl import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository @@ -43,6 +46,7 @@ private const val RANDOM_STRING_LENGTH = 64 class EnterpriseSignOnViewModel @Inject constructor( private val authRepository: AuthRepository, private val environmentRepository: EnvironmentRepository, + private val featureFlagManager: FeatureFlagManager, private val generatorRepository: GeneratorRepository, private val networkConnectionManager: NetworkConnectionManager, private val savedStateHandle: SavedStateHandle, @@ -123,6 +127,10 @@ class EnterpriseSignOnViewModel @Inject constructor( is EnterpriseSignOnAction.Internal.OnReceiveCaptchaToken -> { handleOnReceiveCaptchaToken(action) } + + is EnterpriseSignOnAction.Internal.OnVerifiedOrganizationDomainSsoDetailsReceive -> { + handleOnVerifiedOrganizationDomainSsoDetailsReceive(action) + } } } @@ -209,6 +217,54 @@ class EnterpriseSignOnViewModel @Inject constructor( } } + private fun handleOnVerifiedOrganizationDomainSsoDetailsReceive( + action: EnterpriseSignOnAction.Internal.OnVerifiedOrganizationDomainSsoDetailsReceive, + ) { + when (val orgDetails = action.verifiedOrgDomainSsoDetailsResult) { + is VerifiedOrganizationDomainSsoDetailsResult.Failure -> { + mutableStateFlow.update { + it.copy( + dialogState = null, + orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "", + ) + } + } + + is VerifiedOrganizationDomainSsoDetailsResult.Success -> { + handleOnVerifiedOrganizationDomainSsoDetailsSuccess(orgDetails) + } + } + } + + private fun handleOnVerifiedOrganizationDomainSsoDetailsSuccess( + orgDetails: VerifiedOrganizationDomainSsoDetailsResult.Success, + ) { + if (orgDetails.verifiedOrganizationDomainSsoDetails.isEmpty()) { + mutableStateFlow.update { + it.copy( + dialogState = null, + orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "", + ) + } + return + } + + val organizationIdentifier = orgDetails + .verifiedOrganizationDomainSsoDetails + .first() + .organizationIdentifier + + mutableStateFlow.update { + it.copy( + orgIdentifierInput = organizationIdentifier, + ) + } + + // If the email address is associated with a claimed organization we can proceed to the + // prevalidation step. + prevalidateSso() + } + private fun handleOnOrganizationDomainSsoDetailsReceive( action: EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive, ) { @@ -226,7 +282,7 @@ class EnterpriseSignOnViewModel @Inject constructor( private fun handleOnOrganizationDomainSsoDetailsSuccess( orgDetails: OrganizationDomainSsoDetailsResult.Success, ) { - if (!orgDetails.isSsoAvailable) { + if (!orgDetails.isSsoAvailable || orgDetails.verifiedDate == null) { mutableStateFlow.update { it.copy( dialogState = null, @@ -375,12 +431,23 @@ class EnterpriseSignOnViewModel @Inject constructor( ) } viewModelScope.launch { - val result = authRepository.getOrganizationDomainSsoDetails( - email = EnterpriseSignOnArgs(savedStateHandle).emailAddress, - ) - sendAction( - EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive(result), - ) + if (featureFlagManager.getFeatureFlag(key = FlagKey.VerifiedSsoDomainEndpoint)) { + val result = authRepository.getVerifiedOrganizationDomainSsoDetails( + email = EnterpriseSignOnArgs(savedStateHandle).emailAddress, + ) + sendAction( + EnterpriseSignOnAction.Internal.OnVerifiedOrganizationDomainSsoDetailsReceive( + result, + ), + ) + } else { + val result = authRepository.getOrganizationDomainSsoDetails( + email = EnterpriseSignOnArgs(savedStateHandle).emailAddress, + ) + sendAction( + EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive(result), + ) + } } } @@ -561,6 +628,13 @@ sealed class EnterpriseSignOnAction { * A captcha callback result has been received */ data class OnReceiveCaptchaToken(val tokenResult: CaptchaCallbackTokenResult) : Internal() + + /** + * A result was received when requesting an [VerifiedOrganizationDomainSsoDetailsResult]. + */ + data class OnVerifiedOrganizationDomainSsoDetailsReceive( + val verifiedOrgDomainSsoDetailsResult: VerifiedOrganizationDomainSsoDetailsResult, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt index 26b276ccb..98e7342db 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt @@ -28,6 +28,7 @@ fun FlagKey.ListItemContent( FlagKey.OnboardingFlow, FlagKey.ImportLoginsFlow, FlagKey.SshKeyCipherItems, + FlagKey.VerifiedSsoDomainEndpoint, -> BooleanFlagItem( label = flagKey.getDisplayLabel(), key = flagKey as FlagKey, @@ -71,4 +72,5 @@ private fun FlagKey.getDisplayLabel(): String = when (this) { FlagKey.OnboardingFlow -> stringResource(R.string.onboarding_flow) FlagKey.ImportLoginsFlow -> stringResource(R.string.import_logins_flow) FlagKey.SshKeyCipherItems -> stringResource(R.string.ssh_key_cipher_item_types) + FlagKey.VerifiedSsoDomainEndpoint -> stringResource(R.string.verified_sso_domain_verified) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c817e004..8949e5404 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1073,6 +1073,7 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Verified SSO Domain Endpoint Logins imported Remember to delete your imported password file from your computer SSH key 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 index 8c3e0f549..aea0efb7d 100644 --- 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 @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedOrgan import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationAutoEnrollStatusResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationKeysResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import com.x8bit.bitwarden.data.platform.util.asSuccess import kotlinx.coroutines.test.runTest @@ -13,6 +14,7 @@ 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 authenticatedOrganizationApi: AuthenticatedOrganizationApi = retrofit.create() @@ -55,7 +57,9 @@ class OrganizationServiceTest : BaseServiceTest() { runTest { val email = "test@gmail.com" server.enqueue( - MockResponse().setResponseCode(200).setBody(ORGANIZATION_DOMAIN_SSO_DETAILS_JSON), + MockResponse() + .setResponseCode(200) + .setBody(ORGANIZATION_DOMAIN_SSO_DETAILS_JSON), ) val result = organizationService.getOrganizationDomainSsoDetails(email) assertEquals(ORGANIZATION_DOMAIN_SSO_BODY.asSuccess(), result) @@ -74,7 +78,9 @@ class OrganizationServiceTest : BaseServiceTest() { fun `getOrganizationAutoEnrollStatus when response is success should return valid response`() = runTest { server.enqueue( - MockResponse().setResponseCode(200).setBody(ORGANIZATION_AUTO_ENROLL_STATUS_JSON), + MockResponse() + .setResponseCode(200) + .setBody(ORGANIZATION_AUTO_ENROLL_STATUS_JSON), ) val result = organizationService.getOrganizationAutoEnrollStatus("orgId") assertEquals(ORGANIZATION_AUTO_ENROLL_STATUS_RESPONSE.asSuccess(), result) @@ -91,7 +97,9 @@ class OrganizationServiceTest : BaseServiceTest() { @Test fun `getOrganizationKeys when response is success should return valid response`() = runTest { server.enqueue( - MockResponse().setResponseCode(200).setBody(ORGANIZATION_KEYS_JSON), + MockResponse() + .setResponseCode(200) + .setBody(ORGANIZATION_KEYS_JSON), ) val result = organizationService.getOrganizationKeys("orgId") assertEquals(ORGANIZATION_KEYS_RESPONSE.asSuccess(), result) @@ -103,6 +111,30 @@ class OrganizationServiceTest : BaseServiceTest() { val result = organizationService.getOrganizationKeys("orgId") assertTrue(result.isFailure) } + + @Suppress("MaxLineLength") + @Test + fun `getVerifiedOrganizationDomainSsoDetails when response is success should return valid response`() = + runTest { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(ORGANIZATION_VERIFIED_DOMAIN_SSO_DETAILS_JSON), + ) + val result = + organizationService.getVerifiedOrganizationDomainSsoDetails("example@bitwarden.com") + assertEquals(ORGANIZATION_VERIFIED_DOMAIN_SSO_DETAILS_RESPONSE.asSuccess(), result) + } + + @Suppress("MaxLineLength") + @Test + fun `getVerifiedOrganizationDomainSsoDetails when response is an error should return an error`() = + runTest { + server.enqueue(MockResponse().setResponseCode(400)) + val result = + organizationService.getVerifiedOrganizationDomainSsoDetails("example@bitwarden.com") + assertTrue(result.isFailure) + } } private const val ORGANIZATION_AUTO_ENROLL_STATUS_JSON = """ @@ -130,6 +162,7 @@ private const val ORGANIZATION_DOMAIN_SSO_DETAILS_JSON = """ private val ORGANIZATION_DOMAIN_SSO_BODY = OrganizationDomainSsoDetailsResponseJson( isSsoAvailable = true, organizationIdentifier = "Test Org", + verifiedDate = ZonedDateTime.parse("2024-09-13T00:00:00.000Z"), ) private const val ORGANIZATION_KEYS_JSON = """ @@ -143,3 +176,26 @@ private val ORGANIZATION_KEYS_RESPONSE = OrganizationKeysResponseJson( privateKey = "privateKey", publicKey = "publicKey", ) + +private const val ORGANIZATION_VERIFIED_DOMAIN_SSO_DETAILS_JSON = """ +{ + "data": [ + { + "organizationIdentifier": "Test Identifier", + "organizationName": "Bitwarden", + "domainName": "bitwarden.com" + } + ] +} +""" + +private val ORGANIZATION_VERIFIED_DOMAIN_SSO_DETAILS_RESPONSE = + VerifiedOrganizationDomainSsoDetailsResponse( + verifiedOrganizationDomainSsoDetails = listOf( + VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail( + organizationIdentifier = "Test Identifier", + organizationName = "Bitwarden", + domainName = "bitwarden.com", + ), + ), + ) 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 2e437eeb6..887c7139a 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 @@ -43,6 +43,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserD import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService @@ -85,6 +86,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult @@ -149,6 +151,7 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.ZonedDateTime @Suppress("LargeClass") class AuthRepositoryTest { @@ -5217,6 +5220,7 @@ class AuthRepositoryTest { } returns OrganizationDomainSsoDetailsResponseJson( isSsoAvailable = true, organizationIdentifier = "Test Org", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), ) .asSuccess() val result = repository.getOrganizationDomainSsoDetails(email) @@ -5224,11 +5228,53 @@ class AuthRepositoryTest { OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = true, organizationIdentifier = "Test Org", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), ), result, ) } + @Suppress("MaxLineLength") + @Test + fun `getVerifiedOrganizationDomainSsoDetails Success should return Success`() = runTest { + val email = "test@gmail.com" + coEvery { + organizationService.getVerifiedOrganizationDomainSsoDetails(email) + } returns VerifiedOrganizationDomainSsoDetailsResponse( + verifiedOrganizationDomainSsoDetails = listOf( + VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail( + organizationIdentifier = "Test Identifier", + organizationName = "Bitwarden", + domainName = "bitwarden.com", + ), + ), + ).asSuccess() + val result = repository.getVerifiedOrganizationDomainSsoDetails(email) + assertEquals( + VerifiedOrganizationDomainSsoDetailsResult.Success( + verifiedOrganizationDomainSsoDetails = listOf( + VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail( + organizationIdentifier = "Test Identifier", + organizationName = "Bitwarden", + domainName = "bitwarden.com", + ), + ), + ), + result, + ) + } + + @Test + fun `getVerifiedOrganizationDomainSsoDetails Failure should return Failure `() = runTest { + val email = "test@gmail.com" + val throwable = Throwable() + coEvery { + organizationService.getVerifiedOrganizationDomainSsoDetails(email) + } returns throwable.asFailure() + val result = repository.getVerifiedOrganizationDomainSsoDetails(email) + assertEquals(VerifiedOrganizationDomainSsoDetailsResult.Failure, 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 0b8497bdf..4e8aac044 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 @@ -4,14 +4,18 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse 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.model.VerifiedOrganizationDomainSsoDetailsResult 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.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.util.FakeNetworkConnectionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository @@ -34,6 +38,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.ZonedDateTime @Suppress("LargeClass") class EnterpriseSignOnViewModelTest : BaseViewModelTest() { @@ -54,6 +59,12 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { private val generatorRepository: GeneratorRepository = FakeGeneratorRepository() + private val featureFlagManager = mockk() { + every { + getFeatureFlag(FlagKey.VerifiedSsoDomainEndpoint) + } returns false + } + @BeforeEach fun setUp() { mockkStatic(::generateUriForSso) @@ -694,6 +705,37 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val orgDetails = OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = false, organizationIdentifier = "Bitwarden without SSO", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), + ) + + 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 no verified date available should make a request, hide the dialog, and update the org input based on the remembered org`() = + runTest { + val orgDetails = OrganizationDomainSsoDetailsResult.Success( + isSsoAvailable = true, + organizationIdentifier = "Bitwarden without SSO", + verifiedDate = null, ) coEvery { @@ -723,6 +765,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val orgDetails = OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = true, organizationIdentifier = "", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), ) coEvery { @@ -757,6 +800,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val orgDetails = OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = true, organizationIdentifier = "Bitwarden with SSO", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), ) coEvery { @@ -784,6 +828,109 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `VerifiedOrganizationDomainSsoDetails success with valid organization should make a request then attempt to login`() = + runTest { + val orgDetails = VerifiedOrganizationDomainSsoDetailsResult.Success( + verifiedOrganizationDomainSsoDetails = listOf( + VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail( + organizationIdentifier = "Bitwarden with SSO", + organizationName = "Bitwarden", + domainName = "bitwarden.com", + ), + ), + ) + + coEvery { + authRepository.getVerifiedOrganizationDomainSsoDetails(any()) + } returns orgDetails + + coEvery { + featureFlagManager.getFeatureFlag(FlagKey.VerifiedSsoDomainEndpoint) + } returns true + + // 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.getVerifiedOrganizationDomainSsoDetails(DEFAULT_EMAIL) + } + } + + @Suppress("MaxLineLength") + @Test + fun `VerifiedOrganizationDomainSsoDetails success with no verified domains should make a request, hide the dialog, and update the org input based on the remembered org`() = + runTest { + val orgDetails = VerifiedOrganizationDomainSsoDetailsResult.Success( + verifiedOrganizationDomainSsoDetails = emptyList(), + ) + + coEvery { + authRepository.getVerifiedOrganizationDomainSsoDetails(any()) + } returns orgDetails + + coEvery { + featureFlagManager.getFeatureFlag(FlagKey.VerifiedSsoDomainEndpoint) + } returns true + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy( + orgIdentifierInput = "", + dialogState = null, + ), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getVerifiedOrganizationDomainSsoDetails(DEFAULT_EMAIL) + } + } + + @Suppress("MaxLineLength") + @Test + fun `VerifiedOrganizationDomainSsoDetails failure should make a request, hide dialog, load from remembered org identifier`() = + runTest { + coEvery { + authRepository.getVerifiedOrganizationDomainSsoDetails(any()) + } returns VerifiedOrganizationDomainSsoDetailsResult.Failure + + coEvery { + featureFlagManager.getFeatureFlag(FlagKey.VerifiedSsoDomainEndpoint) + } returns true + + coEvery { + authRepository.rememberedOrgIdentifier + } returns "Bitwarden" + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy( + orgIdentifierInput = "Bitwarden", + dialogState = null, + ), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getVerifiedOrganizationDomainSsoDetails(DEFAULT_EMAIL) + } + } + @Suppress("LongParameterList") private fun createViewModel( initialState: EnterpriseSignOnState? = null, @@ -802,6 +949,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ): EnterpriseSignOnViewModel = EnterpriseSignOnViewModel( authRepository = authRepository, environmentRepository = environmentRepository, + featureFlagManager = featureFlagManager, generatorRepository = generatorRepository, networkConnectionManager = FakeNetworkConnectionManager(isNetworkConnected), savedStateHandle = savedStateHandle, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt index 68e509623..3bda50b54 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt @@ -113,6 +113,7 @@ private val DEFAULT_MAP_VALUE: Map, Any> = mapOf( FlagKey.OnboardingFlow to true, FlagKey.ImportLoginsFlow to true, FlagKey.SshKeyCipherItems to true, + FlagKey.VerifiedSsoDomainEndpoint to true, ) private val UPDATED_MAP_VALUE: Map, Any> = mapOf( @@ -122,6 +123,7 @@ private val UPDATED_MAP_VALUE: Map, Any> = mapOf( FlagKey.OnboardingFlow to false, FlagKey.ImportLoginsFlow to false, FlagKey.SshKeyCipherItems to false, + FlagKey.VerifiedSsoDomainEndpoint to false, ) private val DEFAULT_STATE = DebugMenuState(