Handle SSO prevalidation and custom tab launch (#743)

This commit is contained in:
Sean Weiser 2024-01-24 10:18:21 -06:00 committed by Álison Fernandes
parent 0422d3fdd8
commit 30ab22f826
20 changed files with 602 additions and 22 deletions

View file

@ -1,12 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import retrofit2.Call
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
/**
* Defines raw calls under the /identity API.
@ -29,6 +32,11 @@ interface IdentityApi {
@Field(value = "captchaResponse") captchaResponse: String?,
): Result<GetTokenResponseJson.Success>
@GET("/account/prevalidate")
suspend fun prevalidateSso(
@Query("domainHint") organizationIdentifier: String,
): Result<PrevalidateSsoResponseJson>
/**
* This call needs to be synchronous so we need it to return a [Call] directly. The identity
* service will wrap it up for us.

View file

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Response body from the SSO prevalidate request.
*/
@Serializable
data class PrevalidateSsoResponseJson(
@SerialName("token") val token: String?,
)

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
/**
@ -23,6 +24,15 @@ interface IdentityService {
captchaToken: String?,
): Result<GetTokenResponseJson>
/**
* Prevalidates the organization identifier used in an SSO request.
*
* @param organizationIdentifier The SSO organization identifier.
*/
suspend fun prevalidateSso(
organizationIdentifier: String,
): Result<PrevalidateSsoResponseJson>
/**
* Synchronously makes a request to get refresh the access token.
*

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
@ -46,6 +47,13 @@ class IdentityServiceImpl constructor(
) ?: throw throwable
}
override suspend fun prevalidateSso(
organizationIdentifier: String,
): Result<PrevalidateSsoResponseJson> = api
.prevalidateSso(
organizationIdentifier = organizationIdentifier,
)
override fun refreshTokenSynchronously(
refreshToken: String,
): Result<RefreshTokenResponseJson> = api

View file

@ -6,6 +6,7 @@ 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.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
@ -102,6 +103,13 @@ interface AuthRepository : AuthenticatorProvider {
*/
fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult)
/**
* Prevalidates the organization identifier used in an SSO request.
*/
suspend fun prevalidateSso(
organizationIdentifier: String,
): PrevalidateSsoResult
/**
* Set the value of [ssoCallbackResultFlow].
*/

View file

@ -23,6 +23,7 @@ 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.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
@ -388,6 +389,23 @@ class AuthRepositoryImpl(
mutableCaptchaTokenFlow.tryEmit(tokenResult)
}
override suspend fun prevalidateSso(
organizationIdentifier: String,
): PrevalidateSsoResult = identityService
.prevalidateSso(
organizationIdentifier = organizationIdentifier,
)
.fold(
onSuccess = {
if (it.token.isNullOrBlank()) {
PrevalidateSsoResult.Failure
} else {
PrevalidateSsoResult.Success(it.token)
}
},
onFailure = { PrevalidateSsoResult.Failure },
)
override fun setSsoCallbackResult(result: SsoCallbackResult) {
mutableSsoCallbackResultFlow.tryEmit(result)
}

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Possible SSO prevalidation results.
*/
sealed class PrevalidateSsoResult {
/**
* Prevalidation was successful and returned [token].
*/
data class Success(
val token: String,
) : PrevalidateSsoResult()
/**
* There was an error in prevalidation.
*/
data object Failure : PrevalidateSsoResult()
}

View file

@ -1,8 +1,51 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
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"
/**
* Generates a URI for the SSO custom tab.
*
* @param identityBaseUrl The base URl for the identity service.
* @param organizationIdentifier The SSO organization identifier.
* @param token The prevalidated SSO token.
* @param state Random state used to verify the validity of the response.
* @param codeVerifier A random string used to generate the code challenge.
*/
fun generateUriForSso(
identityBaseUrl: String,
organizationIdentifier: String,
token: String,
state: String,
codeVerifier: String,
): String {
val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8")
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
val encodedToken = URLEncoder.encode(token, "UTF-8")
val codeChallenge = Base64.getEncoder().encodeToString(
MessageDigest
.getInstance("SHA-256")
.digest(codeVerifier.toByteArray()),
)
return "$identityBaseUrl/connect/authorize" +
"?client_id=mobile" +
"&redirect_uri=$redirectUri" +
"&response_type=code" +
"&scope=api%20offline_access" +
"&state=$state" +
"&code_challenge=$codeChallenge" +
"&code_challenge_method=S256" +
"&response_mode=query" +
"&domain_hint=$encodedOrganizationIdentifier" +
"&ssoToken=$encodedToken"
}
/**
* Retrieves an [SsoCallbackResult] from an Intent. There are three possible cases.

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.platform.datasource.network.interceptor
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseIdentityUrl
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
import javax.inject.Inject
import javax.inject.Singleton
@ -42,24 +43,20 @@ class BaseUrlInterceptors @Inject constructor() {
// Determine the required base URLs
val apiUrl: String
val identityUrl: String
val eventsUrl: String
if (baseUrl.isNotEmpty()) {
apiUrl = "$baseUrl/api"
identityUrl = "$baseUrl/identity"
eventsUrl = "$baseUrl/events"
} else {
apiUrl =
environmentUrlData.api.orNullIfBlank() ?: "https://api.bitwarden.com"
identityUrl =
environmentUrlData.identity.orNullIfBlank() ?: "https://identity.bitwarden.com"
eventsUrl =
environmentUrlData.events.orNullIfBlank() ?: "https://events.bitwarden.com"
}
// Update the base URLs
apiInterceptor.baseUrl = apiUrl
identityInterceptor.baseUrl = identityUrl
identityInterceptor.baseUrl = environmentUrlData.baseIdentityUrl
eventsInterceptor.baseUrl = eventsUrl
}
}

View file

@ -4,10 +4,22 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJso
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import java.net.URI
private const val DEFAULT_IDENTITY_URL: String = "https://identity.bitwarden.com"
private const val DEFAULT_WEB_VAULT_URL: String = "https://vault.bitwarden.com"
private const val DEFAULT_WEB_SEND_URL: String = "https://send.bitwarden.com/#"
private const val DEFAULT_ICON_URL: String = "https://icons.bitwarden.net/"
/**
* Returns the base identity URL or the default value if one is not present.
*/
val EnvironmentUrlDataJson.baseIdentityUrl: String
get() =
this
.identity
.takeIf { !it.isNullOrBlank() }
?: base.takeIf { it.isNotBlank() }?.let { "$it/identity" }
?: DEFAULT_IDENTITY_URL
/**
* Returns the base web vault URL. This will check for a custom [EnvironmentUrlDataJson.webVault]
* before falling back to the [EnvironmentUrlDataJson.base]. This can still return null if both are

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.tools.generator.repository.utils
import com.bitwarden.generators.PasswordGeneratorRequest
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult
/**
* Generates a random string of length [length] using [GeneratorRepository.generatePassword].
*/
suspend fun GeneratorRepository.generateRandomString(length: Int): String {
return this.generatePassword(
passwordGeneratorRequest = PasswordGeneratorRequest(
lowercase = true,
uppercase = true,
numbers = true,
special = false,
length = length.toUByte(),
avoidAmbiguous = false,
minLowercase = null,
minUppercase = null,
minNumber = null,
minSpecial = null,
),
shouldSave = false,
)
.let {
when (it) {
is GeneratedPasswordResult.InvalidRequest -> throw IllegalArgumentException()
is GeneratedPasswordResult.Success -> it.generatedString
}
}
}

View file

@ -42,6 +42,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
/**
* The top level composable for the Enterprise Single Sign On screen.
@ -51,6 +53,7 @@ import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
@Composable
fun EnterpriseSignOnScreen(
onNavigateBack: () -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: EnterpriseSignOnViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -61,6 +64,10 @@ fun EnterpriseSignOnScreen(
is EnterpriseSignOnEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
is EnterpriseSignOnEvent.NavigateToSsoLogin -> {
intentManager.startCustomTabsActivity(event.uri)
}
}
}

View file

@ -1,29 +1,44 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import android.net.Uri
import android.os.Parcelable
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.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
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
import com.x8bit.bitwarden.data.platform.repository.util.baseIdentityUrl
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.utils.generateRandomString
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_SSO_STATE = "ssoState"
private const val KEY_STATE = "state"
private const val RANDOM_STRING_LENGTH = 64
/**
* Manages application state for the enterprise single sign on screen.
*/
@HiltViewModel
class EnterpriseSignOnViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val generatorRepository: GeneratorRepository,
private val networkConnectionManager: NetworkConnectionManager,
savedStateHandle: SavedStateHandle,
private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<EnterpriseSignOnState, EnterpriseSignOnEvent, EnterpriseSignOnAction>(
initialState = savedStateHandle[KEY_STATE]
?: EnterpriseSignOnState(
@ -32,6 +47,25 @@ class EnterpriseSignOnViewModel @Inject constructor(
),
) {
/**
* 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.
*/
private var ssoState: String?
get() = savedStateHandle[KEY_SSO_STATE]
set(value) {
savedStateHandle[KEY_SSO_STATE] = value
}
init {
authRepository
.ssoCallbackResultFlow
.onEach {
handleSsoCallbackResult(it)
}
.launchIn(viewModelScope)
}
override fun handleAction(action: EnterpriseSignOnAction) {
when (action) {
EnterpriseSignOnAction.CloseButtonClick -> handleCloseButtonClicked()
@ -40,6 +74,14 @@ class EnterpriseSignOnViewModel @Inject constructor(
is EnterpriseSignOnAction.OrgIdentifierInputChange -> {
handleOrgIdentifierInputChanged(action)
}
is EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult -> {
handleOnGenerateUriForSsoResult(action)
}
EnterpriseSignOnAction.Internal.OnSsoPrevalidationFailure -> {
handleOnSsoPrevalidationFailure()
}
}
}
@ -67,7 +109,8 @@ class EnterpriseSignOnViewModel @Inject constructor(
return
}
if (state.orgIdentifierInput.isBlank()) {
val organizationIdentifier = state.orgIdentifierInput
if (organizationIdentifier.isBlank()) {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
@ -80,19 +123,45 @@ class EnterpriseSignOnViewModel @Inject constructor(
return
}
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
R.string.logging_in.asText(),
),
)
}
viewModelScope.launch {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
R.string.logging_in.asText(),
),
)
val prevalidateSsoResult = authRepository.prevalidateSso(organizationIdentifier)
when (prevalidateSsoResult) {
is PrevalidateSsoResult.Failure -> {
sendAction(EnterpriseSignOnAction.Internal.OnSsoPrevalidationFailure)
}
is PrevalidateSsoResult.Success -> {
prepareAndLaunchCustomTab(
organizationIdentifier = organizationIdentifier,
prevalidateSsoResult = prevalidateSsoResult,
)
}
}
// TODO The delay and hide are temporary until the actual SSO flow is implemented (see
// BIT-816)
@Suppress("MagicNumber")
delay(2000)
mutableStateFlow.update { it.copy(dialogState = null) }
}
}
private fun handleOnGenerateUriForSsoResult(
action: EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult,
) {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(EnterpriseSignOnEvent.NavigateToSsoLogin(action.uri))
}
private fun handleOnSsoPrevalidationFailure() {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = R.string.login_sso_error.asText(),
),
)
}
}
@ -101,6 +170,34 @@ 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 suspend fun prepareAndLaunchCustomTab(
organizationIdentifier: String,
prevalidateSsoResult: PrevalidateSsoResult.Success,
) {
val codeVerifier = generatorRepository.generateRandomString(RANDOM_STRING_LENGTH)
// Save this for later so that we can validate the SSO callback response
val generatedSsoState = generatorRepository
.generateRandomString(RANDOM_STRING_LENGTH)
.also { ssoState = it }
val uri = generateUriForSso(
identityBaseUrl = environmentRepository.environment.environmentUrlData.baseIdentityUrl,
organizationIdentifier = organizationIdentifier,
token = prevalidateSsoResult.token,
state = generatedSsoState,
codeVerifier = codeVerifier,
)
// Hide any dialog since we're about to launch a custom tab and could return without getting
// a result due to user intervention
sendAction(EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult(Uri.parse(uri)))
}
}
/**
@ -144,6 +241,11 @@ sealed class EnterpriseSignOnEvent {
*/
data object NavigateBack : EnterpriseSignOnEvent()
/**
* Navigates to a custom tab for SSO login using [uri].
*/
data class NavigateToSsoLogin(val uri: Uri) : EnterpriseSignOnEvent()
/**
* Shows a toast with the given [message].
*/
@ -177,4 +279,19 @@ sealed class EnterpriseSignOnAction {
data class OrgIdentifierInputChange(
val input: String,
) : EnterpriseSignOnAction()
/**
* Internal actions for the view model.
*/
sealed class Internal : EnterpriseSignOnAction() {
/**
* A [uri] has been generated to request an SSO result.
*/
data class OnGenerateUriForSsoResult(val uri: Uri) : Internal()
/**
* SSO prevalidation failed.
*/
data object OnSsoPrevalidationFailure : Internal()
}
}

View file

@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.MasterPasswordPolicyOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
@ -82,6 +83,24 @@ class IdentityServiceTest : BaseServiceTest() {
assertEquals(Result.success(INVALID_LOGIN), result)
}
@Test
fun `prevalidateSso when response is success should return PrevalidateSsoResponseJson`() =
runTest {
val organizationId = "organizationId"
server.enqueue(MockResponse().setResponseCode(200).setBody(PREVALIDATE_SSO_JSON))
val result = identityService.prevalidateSso(organizationId)
assertEquals(Result.success(PREVALIDATE_SSO_BODY), result)
}
@Test
fun `prevalidateSso when response is an error should return an error`() =
runTest {
val organizationId = "organizationId"
server.enqueue(MockResponse().setResponseCode(400))
val result = identityService.prevalidateSso(organizationId)
assertTrue(result.isFailure)
}
@Suppress("MaxLineLength")
@Test
fun `refreshTokenSynchronously when response is success should return RefreshTokenResponseJson`() {
@ -105,6 +124,16 @@ class IdentityServiceTest : BaseServiceTest() {
}
}
private const val PREVALIDATE_SSO_JSON = """
{
"token": "2ff00750-e2d6-47a6-ae54-67b981e78030"
}
"""
private val PREVALIDATE_SSO_BODY = PrevalidateSsoResponseJson(
token = "2ff00750-e2d6-47a6-ae54-67b981e78030",
)
private const val REFRESH_TOKEN_JSON = """
{
"access_token": "accessToken",

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
@ -32,6 +33,7 @@ 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.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
@ -1020,6 +1022,37 @@ class AuthRepositoryTest {
}
}
@Test
fun `prevalidateSso Failure should return Failure `() = runTest {
val organizationId = "organizationid"
val throwable = Throwable()
coEvery {
identityService.prevalidateSso(organizationId)
} returns Result.failure(throwable)
val result = repository.prevalidateSso(organizationId)
assertEquals(PrevalidateSsoResult.Failure, result)
}
@Test
fun `prevalidateSso Success with a blank token should return Failure`() = runTest {
val organizationId = "organizationid"
coEvery {
identityService.prevalidateSso(organizationId)
} returns Result.success(PrevalidateSsoResponseJson(token = ""))
val result = repository.prevalidateSso(organizationId)
assertEquals(PrevalidateSsoResult.Failure, result)
}
@Test
fun `prevalidateSso Success with a valid token should return Success`() = runTest {
val organizationId = "organizationid"
coEvery {
identityService.prevalidateSso(organizationId)
} returns Result.success(PrevalidateSsoResponseJson(token = "token"))
val result = repository.prevalidateSso(organizationId)
assertEquals(PrevalidateSsoResult.Success(token = "token"), result)
}
@Suppress("MaxLineLength")
@Test
fun `logout for the active account should call logout on the UserLogoutManager and clear the user's in memory vault data`() {

View file

@ -9,6 +9,37 @@ import org.junit.jupiter.api.Assertions.assertNull
class SsoUtilsTest {
@Test
fun `generateUriForSso should generate the correct URI`() {
val identityBaseUrl = "https://identity.bitwarden.com"
val organizationIdentifier = "Test Organization"
val token = "Test Token"
val state = "test_state"
val codeVerifier = "test_code_verifier"
val uri = generateUriForSso(
identityBaseUrl = identityBaseUrl,
organizationIdentifier = organizationIdentifier,
token = token,
state = state,
codeVerifier = codeVerifier,
)
assertEquals(
"https://identity.bitwarden.com/connect/authorize" +
"?client_id=mobile" +
"&redirect_uri=bitwarden%3A%2F%2Fsso-callback" +
"&response_type=code" +
"&scope=api%20offline_access" +
"&state=test_state" +
"&code_challenge=Qq1fGD0HhxwbmeMrqaebgn1qhvKeguQPXqLdpmixaM4=" +
"&code_challenge_method=S256" +
"&response_mode=query" +
"&domain_hint=Test+Organization" +
"&ssoToken=Test+Token",
uri,
)
}
@Test
fun `getSsoCallbackResult should return null when data is null`() {
val intent = mockk<Intent> {

View file

@ -7,6 +7,39 @@ import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class EnvironmentUrlsDataJsonExtensionsTest {
@Test
fun `baseIdentityUrl should return identity if value is present`() {
assertEquals(
DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseIdentityUrl,
"identity",
)
}
@Test
fun `baseIdentityUrl should return base value if identity is null`() {
assertEquals(
DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA
.copy(identity = null)
.baseIdentityUrl,
"base/identity",
)
}
@Test
fun `baseIdentityUrl should return default url if base is empty and identity is null`() {
val expectedUrl = "https://identity.bitwarden.com"
assertEquals(
expectedUrl,
DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA
.copy(
base = "",
identity = null,
)
.baseIdentityUrl,
)
}
@Test
fun `baseWebVaultUrlOrNull should return webVault when populated`() {
val result = DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseWebVaultUrlOrNull

View file

@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.tools.generator.repository.util
import com.bitwarden.generators.PasswordGeneratorRequest
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.utils.generateRandomString
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class GeneratorRepositoryExtensionsTest {
private val generatorRepository: GeneratorRepository = mockk {
coEvery {
generatePassword(any(), any())
} returns GeneratedPasswordResult.Success("abc")
}
@Test
fun `generateRandomString should call generatePassword with the correct parameters`() =
runTest {
generatorRepository.generateRandomString(64)
coVerify(exactly = 1) {
generatorRepository.generatePassword(
passwordGeneratorRequest = PasswordGeneratorRequest(
lowercase = true,
uppercase = true,
numbers = true,
special = false,
length = 64.toUByte(),
avoidAmbiguous = false,
minLowercase = null,
minUppercase = null,
minNumber = null,
minSpecial = null,
),
shouldSave = false,
)
}
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import android.net.Uri
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
@ -14,8 +15,11 @@ import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow
@ -32,12 +36,17 @@ class EnterpriseSignOnScreenTest : BaseComposeTest() {
every { stateFlow } returns mutableStateFlow
}
private val intentManager: IntentManager = mockk {
every { startCustomTabsActivity(any()) } just runs
}
@Before
fun setup() {
composeTestRule.setContent {
EnterpriseSignOnScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
intentManager = intentManager,
)
}
}
@ -84,6 +93,15 @@ class EnterpriseSignOnScreenTest : BaseComposeTest() {
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToSsoLogin should call startCustomTabsActivity`() {
val ssoUri = Uri.parse("https://identity.bitwarden.com/sso-test")
mutableEventFlow.tryEmit(EnterpriseSignOnEvent.NavigateToSsoLogin(ssoUri))
verify(exactly = 1) {
intentManager.startCustomTabsActivity(ssoUri)
}
}
@Test
fun `error dialog should be shown or hidden according to the state`() {
composeTestRule.onNode(isDialog()).assertDoesNotExist()

View file

@ -1,19 +1,57 @@
package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
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
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
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.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
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
class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
private val mutableSsoCallbackResultFlow = bufferedMutableSharedFlow<SsoCallbackResult>()
private val authRepository: AuthRepository = mockk {
every { ssoCallbackResultFlow } returns mutableSsoCallbackResultFlow
}
private val environmentRepository: EnvironmentRepository = FakeEnvironmentRepository()
private val generatorRepository: GeneratorRepository = FakeGeneratorRepository()
private val savedStateHandle = SavedStateHandle()
@BeforeEach
fun setUp() {
mockkStatic(::generateUriForSso)
mockkStatic(Uri::parse)
}
@AfterEach
fun tearDown() {
unmockkStatic(::generateUriForSso)
unmockkStatic(Uri::parse)
}
@Test
fun `initial state should be correct when not pulling from handle`() = runTest {
val viewModel = createViewModel()
@ -47,18 +85,70 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `LogInClick with valid organization should emit ShowToast, show a loading dialog, and then hide the dialog`() =
fun `LogInClick with valid organization and failed prevalidation should emit ShowToast, show a loading dialog, and then show an error`() =
runTest {
val state = DEFAULT_STATE.copy(orgIdentifierInput = "Test")
val organizationId = "Test"
val state = DEFAULT_STATE.copy(orgIdentifierInput = organizationId)
coEvery {
authRepository.prevalidateSso(organizationId)
} returns PrevalidateSsoResult.Failure
val viewModel = createViewModel(state)
viewModel.actionChannel.trySend(EnterpriseSignOnAction.LogInClick)
viewModel.stateFlow.test {
assertEquals(state, awaitItem())
viewModel.actionChannel.trySend(EnterpriseSignOnAction.LogInClick)
assertEquals(
state.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
state.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
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`() =
runTest {
val organizationId = "Test"
val state = DEFAULT_STATE.copy(orgIdentifierInput = organizationId)
coEvery {
authRepository.prevalidateSso(organizationId)
} returns PrevalidateSsoResult.Success(token = "token")
val ssoUri: Uri = mockk()
every {
generateUriForSso(any(), any(), any(), any(), any())
} returns "https://identity.bitwarden.com/sso-test"
every {
Uri.parse("https://identity.bitwarden.com/sso-test")
} returns ssoUri
val viewModel = createViewModel(state)
viewModel.stateFlow.test {
assertEquals(state, awaitItem())
viewModel.actionChannel.trySend(EnterpriseSignOnAction.LogInClick)
assertEquals(
state.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
@ -73,6 +163,16 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
awaitItem(),
)
}
viewModel.eventFlow.test {
assertEquals(
EnterpriseSignOnEvent.ShowToast("Not yet implemented."),
awaitItem(),
)
assertEquals(
EnterpriseSignOnEvent.NavigateToSsoLogin(ssoUri),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@ -183,6 +283,9 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
),
isNetworkConnected: Boolean = true,
): EnterpriseSignOnViewModel = EnterpriseSignOnViewModel(
authRepository = authRepository,
environmentRepository = environmentRepository,
generatorRepository = generatorRepository,
networkConnectionManager = FakeNetworkConnectionManager(isNetworkConnected),
savedStateHandle = savedStateHandle,
)