mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
Handle SSO prevalidation and custom tab launch (#743)
This commit is contained in:
parent
0422d3fdd8
commit
30ab22f826
20 changed files with 602 additions and 22 deletions
|
@ -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.
|
||||
|
|
|
@ -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?,
|
||||
)
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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].
|
||||
*/
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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`() {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue