PM-26577: Support multiple schemes for Duo, WebAuthn, and SSO callbacks

This commit is contained in:
David Perez 2026-01-08 15:09:05 -06:00
parent 1d35004999
commit d702bbf52e
No known key found for this signature in database
GPG Key ID: 3E29BD8B1BF090AC
12 changed files with 240 additions and 105 deletions

View File

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
@ -78,6 +79,14 @@ private fun Uri?.getDuoCallbackTokenResult(): DuoCallbackTokenResult {
}
}
/**
* Generates a [Uri] to display a duo challenge for Bitwarden authentication.
*/
fun generateUriForDuo(
authUrl: String,
appLinksScheme: String,
): Uri = "$authUrl&deeplinkScheme=$appLinksScheme".toUri()
/**
* Sealed class representing the result of Duo callback token extraction.
*/

View File

@ -17,25 +17,26 @@ private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "sso-callback"
const val SSO_URI: String = "bitwarden://$CALLBACK"
/**
* Generates a URI for the SSO custom tab.
*
* @param identityBaseUrl The base URl for the identity service.
* @param redirectUrl The redirect URI used in the SSO request.
* @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.
*/
@Suppress("LongParameterList")
fun generateUriForSso(
identityBaseUrl: String,
redirectUrl: String,
organizationIdentifier: String,
token: String,
state: String,
codeVerifier: String,
): Uri {
val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8")
val redirectUri = URLEncoder.encode(redirectUrl, "UTF-8")
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
val encodedToken = URLEncoder.encode(token, "UTF-8")

View File

@ -8,7 +8,6 @@ import com.bitwarden.annotation.OmitFromCoverage
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URLEncoder
import java.util.Base64
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
@ -17,8 +16,6 @@ private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "webauthn-callback"
private const val CALLBACK_URI = "bitwarden://$CALLBACK"
/**
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
*
@ -79,29 +76,31 @@ private fun Uri?.getWebAuthResult(): WebAuthResult =
/**
* Generates a [Uri] to display a web authn challenge for Bitwarden authentication.
*/
@Suppress("LongParameterList")
fun generateUriForWebAuth(
baseUrl: String,
callbackScheme: String,
data: JsonObject,
headerText: String,
buttonText: String,
returnButtonText: String,
): Uri {
val json = buildJsonObject {
put(key = "callbackUri", value = CALLBACK_URI)
put(key = "data", value = data.toString())
put(key = "headerText", value = headerText)
put(key = "btnText", value = buttonText)
put(key = "btnReturnText", value = returnButtonText)
put(key = "mobile", value = true)
}
val base64Data = Base64
.getEncoder()
.encodeToString(json.toString().toByteArray(Charsets.UTF_8))
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
val url = baseUrl +
"/webauthn-mobile-connector.html" +
"?data=$base64Data" +
"&parent=$parentParam" +
"&v=2"
"&client=mobile" +
"&v=2" +
"&deeplinkScheme=$callbackScheme"
return url.toUri()
}

View File

@ -4,8 +4,10 @@ import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.data.repository.util.appLinksScheme
import com.bitwarden.data.repository.util.baseIdentityUrl
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.data.repository.util.ssoAppLinksRedirectUrl
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
@ -14,7 +16,6 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
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.generateUriForSso
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
@ -342,14 +343,13 @@ class EnterpriseSignOnViewModel @Inject constructor(
if (ssoCallbackResult.state == ssoData.state) {
showLoading()
viewModelScope.launch {
val result = authRepository
.login(
email = savedStateHandle.toEnterpriseSignOnArgs().emailAddress,
ssoCode = ssoCallbackResult.code,
ssoCodeVerifier = ssoData.codeVerifier,
ssoRedirectUri = SSO_URI,
organizationIdentifier = state.orgIdentifierInput,
)
val result = authRepository.login(
email = savedStateHandle.toEnterpriseSignOnArgs().emailAddress,
ssoCode = ssoCallbackResult.code,
ssoCodeVerifier = ssoData.codeVerifier,
ssoRedirectUri = ssoData.redirectUri,
organizationIdentifier = state.orgIdentifierInput,
)
sendAction(EnterpriseSignOnAction.Internal.OnLoginResult(result))
}
} else {
@ -385,18 +385,22 @@ class EnterpriseSignOnViewModel @Inject constructor(
) {
val codeVerifier = generatorRepository.generateRandomString(RANDOM_STRING_LENGTH)
val environmentData = environmentRepository.environment.environmentUrlData
val redirectUrl = environmentData.ssoAppLinksRedirectUrl
// Save this for later so that we can validate the SSO callback response
val generatedSsoState = generatorRepository
.generateRandomString(RANDOM_STRING_LENGTH)
.also {
ssoResponseData = SsoResponseData(
redirectUri = redirectUrl,
codeVerifier = codeVerifier,
state = it,
)
}
val uri = generateUriForSso(
identityBaseUrl = environmentRepository.environment.environmentUrlData.baseIdentityUrl,
identityBaseUrl = environmentData.baseIdentityUrl,
redirectUrl = redirectUrl,
organizationIdentifier = organizationIdentifier,
token = prevalidateSsoResult.token,
state = generatedSsoState,
@ -408,7 +412,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
sendAction(
EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult(
uri = uri,
scheme = "bitwarden",
scheme = environmentData.appLinksScheme,
),
)
}
@ -612,6 +616,7 @@ sealed class EnterpriseSignOnAction {
/**
* Data needed by the SSO flow to verify and continue the process after receiving a response.
*
* @property redirectUri The redirect URI used in the SSO request.
* @property state A "state" maintained throughout the SSO process to verify that the response from
* the server is valid and matches what was originally sent in the request.
* @property codeVerifier A random string used to generate the code challenge for the initial SSO
@ -619,6 +624,7 @@ sealed class EnterpriseSignOnAction {
*/
@Parcelize
data class SsoResponseData(
val redirectUri: String,
val state: String,
val codeVerifier: String,
) : Parcelable

View File

@ -6,6 +6,7 @@ import androidx.annotation.DrawableRes
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.data.repository.util.appLinksScheme
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
@ -23,6 +24,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForDuo
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
@ -173,71 +175,15 @@ class TwoFactorLoginViewModel @Inject constructor(
}
/**
* Navigates to the Duo webpage if appropriate, else processes the login.
* Navigates to the two-factor auth webpage if appropriate, else processes the login.
*/
@Suppress("LongMethod")
private fun handleContinueButtonClick() {
when (state.authMethod) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
-> {
val authUrl = authRepository.twoFactorResponse.twoFactorDuoAuthUrl
// The url should not be empty unless the environment is somehow not supported.
authUrl
?.let {
sendEvent(
event = TwoFactorLoginEvent.NavigateToDuo(
uri = it.toUri(),
scheme = "bitwarden",
),
)
}
?: mutableStateFlow.update {
@Suppress("MaxLineLength")
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString
.error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance
.asText(),
),
)
}
}
TwoFactorAuthMethod.WEB_AUTH -> {
sendEvent(
event = authRepository
.twoFactorResponse
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.let {
val uri = generateUriForWebAuth(
baseUrl = environmentRepository
.environment
.environmentUrlData
.baseWebVaultUrlOrDefault,
data = it,
headerText = resourceManager.getString(
resId = BitwardenString.fido2_title,
),
buttonText = resourceManager.getString(
resId = BitwardenString.fido2_authenticate_web_authn,
),
returnButtonText = resourceManager.getString(
resId = BitwardenString.fido2_return_to_app,
),
)
TwoFactorLoginEvent.NavigateToWebAuth(uri = uri, scheme = "bitwarden")
}
?: TwoFactorLoginEvent.ShowSnackbar(
message = BitwardenString
.there_was_an_error_starting_web_authn_two_factor_authentication
.asText(),
),
)
}
-> handleDuoContinueButtonClick()
TwoFactorAuthMethod.WEB_AUTH -> handleWebAuthnContinueButtonClick()
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.YUBI_KEY,
@ -248,6 +194,73 @@ class TwoFactorLoginViewModel @Inject constructor(
}
}
/**
* Navigates to the Duo webpage if appropriate, or displays the error dialog.
*/
private fun handleDuoContinueButtonClick() {
// The url should not be empty unless the environment is somehow not supported.
authRepository
.twoFactorResponse
.twoFactorDuoAuthUrl
?.let {
val environmentData = environmentRepository.environment.environmentUrlData
val appLinksScheme = environmentData.appLinksScheme
sendEvent(
event = TwoFactorLoginEvent.NavigateToDuo(
uri = generateUriForDuo(authUrl = it, appLinksScheme = appLinksScheme),
scheme = appLinksScheme,
),
)
}
?: mutableStateFlow.update {
@Suppress("MaxLineLength")
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString
.error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance
.asText(),
),
)
}
}
/**
* Navigates to the Web Authn webpage if appropriate, or displays the error snackbar.
*/
private fun handleWebAuthnContinueButtonClick() {
sendEvent(
event = authRepository
.twoFactorResponse
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.let {
val environmentData = environmentRepository.environment.environmentUrlData
val appLinksScheme = environmentData.appLinksScheme
val uri = generateUriForWebAuth(
baseUrl = environmentData.baseWebVaultUrlOrDefault,
callbackScheme = appLinksScheme,
data = it,
headerText = resourceManager.getString(
resId = BitwardenString.fido2_title,
),
buttonText = resourceManager.getString(
resId = BitwardenString.fido2_authenticate_web_authn,
),
returnButtonText = resourceManager.getString(
resId = BitwardenString.fido2_return_to_app,
),
)
TwoFactorLoginEvent.NavigateToWebAuth(uri = uri, scheme = appLinksScheme)
}
?: TwoFactorLoginEvent.ShowSnackbar(
message = BitwardenString
.there_was_an_error_starting_web_authn_two_factor_authentication
.asText(),
),
)
}
/**
* Dismiss the view.
*/

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
@ -9,6 +10,14 @@ import org.junit.jupiter.api.assertNull
class DuoUtilsTest {
@Test
fun `generateUriForDuo should return a valid URI`() {
val authUrl = "https://vault.bitwarden.com"
val appLinksScheme = "https"
val actualUri = generateUriForDuo(authUrl = authUrl, appLinksScheme = appLinksScheme)
assertEquals(Uri.parse("$authUrl&deeplinkScheme=$appLinksScheme"), actualUri)
}
@Test
fun `getDuoCallbackTokenResult should return null when action is not VIEW`() {
val intent = mockk<Intent> {

View File

@ -4,15 +4,16 @@ import android.content.Intent
import android.net.Uri
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class SsoUtilsTest {
@Test
fun `generateUriForSso should generate the correct URI`() {
val identityBaseUrl = "https://identity.bitwarden.com"
val redirectUrl = "https://bitwarden.com/sso-callback"
val organizationIdentifier = "Test Organization"
val token = "Test Token"
val state = "test_state"
@ -31,6 +32,7 @@ class SsoUtilsTest {
val uri = generateUriForSso(
identityBaseUrl = identityBaseUrl,
redirectUrl = redirectUrl,
organizationIdentifier = organizationIdentifier,
token = token,
state = state,

View File

@ -2,20 +2,20 @@ package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class WebAuthUtilsTest : BitwardenComposeTest() {
class WebAuthUtilsTest {
@Test
fun `generateUriForWebAuth should return valid Uri`() {
val baseUrl = "https://vault.bitwarden.com"
val actualUri = generateUriForWebAuth(
baseUrl = baseUrl,
callbackScheme = "https",
data = JsonObject(emptyMap()),
headerText = "header",
buttonText = "button",
@ -26,8 +26,9 @@ class WebAuthUtilsTest : BitwardenComposeTest() {
"?data=eyJjYWxsYmFja1VyaSI6ImJpdHdhcmRlbjovL3dlYmF1dGhuLWNhbGxiYWNrIiwiZ" +
"GF0YSI6Int9IiwiaGVhZGVyVGV4dCI6ImhlYWRlciIsImJ0blRleHQiOiJidXR0b24iLCJi" +
"dG5SZXR1cm5UZXh0IjoicmV0dXJuQnV0dG9uIn0=" +
"&parent=bitwarden%3A%2F%2Fwebauthn-callback" +
"&v=2"
"&client=mobile" +
"&v=2" +
"&deeplinkScheme=https"
val expectedUri = Uri.parse(expectedUrl)
assertEquals(expectedUri, actualUri)
}

View File

@ -163,7 +163,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
val ssoUri: Uri = mockk()
every {
generateUriForSso(any(), any(), any(), any(), any())
generateUriForSso(any(), any(), any(), any(), any(), any())
} returns ssoUri
val viewModel = createViewModel(state)
@ -186,7 +186,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
)
assertEquals(
EnterpriseSignOnEvent.NavigateToSsoLogin(uri = ssoUri, scheme = "bitwarden"),
EnterpriseSignOnEvent.NavigateToSsoLogin(uri = ssoUri, scheme = "https"),
eventFlow.awaitItem(),
)
}
@ -385,7 +385,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
email = "test@gmail.com",
ssoCode = "lmn",
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
ssoRedirectUri = "https://bitwarden.com/sso-callback",
organizationIdentifier = orgIdentifier,
)
}
@ -451,7 +451,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
email = "test@gmail.com",
ssoCode = "lmn",
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
ssoRedirectUri = "https://bitwarden.com/sso-callback",
organizationIdentifier = orgIdentifier,
)
}
@ -474,7 +474,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
)
val viewModel = createViewModel(
ssoData = DEFAULT_SSO_DATA,
ssoData = DEFAULT_SSO_DATA.copy(redirectUri = "bitwarden://sso-callback"),
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
@ -548,7 +548,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
)
val viewModel = createViewModel(
ssoData = DEFAULT_SSO_DATA,
ssoData = DEFAULT_SSO_DATA.copy(redirectUri = "bitwarden://sso-callback"),
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
@ -622,7 +622,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
)
val viewModel = createViewModel(
ssoData = DEFAULT_SSO_DATA,
ssoData = DEFAULT_SSO_DATA.copy(redirectUri = "bitwarden://sso-callback"),
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
@ -739,7 +739,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
email = "test@gmail.com",
ssoCode = "lmn",
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
ssoRedirectUri = "https://bitwarden.com/sso-callback",
organizationIdentifier = orgIdentifier,
)
}
@ -792,7 +792,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
email = "test@gmail.com",
ssoCode = "lmn",
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
ssoRedirectUri = "https://bitwarden.com/sso-callback",
organizationIdentifier = "Bitwarden",
)
}
@ -848,7 +848,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
email = "test@gmail.com",
ssoCode = "lmn",
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
ssoRedirectUri = "https://bitwarden.com/sso-callback",
organizationIdentifier = "Bitwarden",
)
}
@ -912,7 +912,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
email = "test@gmail.com",
ssoCode = "lmn",
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
ssoRedirectUri = "https://bitwarden.com/sso-callback",
organizationIdentifier = orgIdentifier,
)
}
@ -1269,6 +1269,7 @@ private val DEFAULT_STATE = EnterpriseSignOnState(
orgIdentifierInput = "",
)
private val DEFAULT_SSO_DATA = SsoResponseData(
redirectUri = "https://bitwarden.com/sso-callback",
state = "abc",
codeVerifier = "def",
)

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.data.repository.util.appLinksScheme
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.TwoFactorAuthMethod
@ -18,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForDuo
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
@ -28,7 +30,6 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
@ -67,6 +68,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
fun setUp() {
mockkStatic(
::generateUriForWebAuth,
::generateUriForDuo,
SavedStateHandle::toTwoFactorLoginArgs,
)
mockkStatic(Uri::class)
@ -76,6 +78,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
fun tearDown() {
unmockkStatic(
::generateUriForWebAuth,
::generateUriForDuo,
SavedStateHandle::toTwoFactorLoginArgs,
)
unmockkStatic(Uri::class)
@ -418,22 +421,21 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
)
every { authRepository.twoFactorResponse } returns response
val mockkUri = mockk<Uri>()
every {
generateUriForDuo(authUrl = "bitwarden.com", appLinksScheme = "https")
} returns mockkUri
val viewModel = createViewModel(
state = DEFAULT_STATE.copy(
authMethod = TwoFactorAuthMethod.DUO,
),
)
every { Uri.parse("bitwarden.com") } returns mockkUri
viewModel.eventFlow.test {
viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick)
assertEquals(
TwoFactorLoginEvent.NavigateToDuo(uri = mockkUri, scheme = "bitwarden"),
TwoFactorLoginEvent.NavigateToDuo(uri = mockkUri, scheme = "https"),
awaitItem(),
)
}
verify {
Uri.parse("bitwarden.com")
}
}
@Suppress("MaxLineLength")
@ -500,6 +502,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
every {
generateUriForWebAuth(
baseUrl = Environment.Us.environmentUrlData.baseWebVaultUrlOrDefault,
callbackScheme = Environment.Us.environmentUrlData.appLinksScheme,
data = data,
headerText = headerText,
buttonText = buttonText,
@ -512,7 +515,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
viewModel.eventFlow.test {
viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick)
assertEquals(
TwoFactorLoginEvent.NavigateToWebAuth(uri = mockkUri, scheme = "bitwarden"),
TwoFactorLoginEvent.NavigateToWebAuth(uri = mockkUri, scheme = "https"),
awaitItem(),
)
}

View File

@ -31,6 +31,34 @@ val EnvironmentUrlDataJson.baseApiUrl: String
}
}
/**
* Returns the scheme used for app-links within the app.
*/
val EnvironmentUrlDataJson.appLinksScheme: String
get() = when (this.environmentRegion) {
EnvironmentRegion.UNITED_STATES,
EnvironmentRegion.EUROPEAN_UNION,
-> "https"
EnvironmentRegion.SELF_HOSTED -> "bitwarden"
}
/**
* Returns the sso app-link URI for the current environment.
*/
val EnvironmentUrlDataJson.ssoAppLinksRedirectUrl: String
get() = appLinksRedirectUrl(kind = "sso")
/**
* Returns the app-link URI for the current environment and [kind].
*/
private fun EnvironmentUrlDataJson.appLinksRedirectUrl(kind: String): String =
when (this.environmentRegion) {
EnvironmentRegion.UNITED_STATES -> "https://bitwarden.com/$kind-callback"
EnvironmentRegion.EUROPEAN_UNION -> "https://bitwarden.eu/$kind-callback"
EnvironmentRegion.SELF_HOSTED -> "bitwarden://$kind-callback"
}
/**
* Returns the base events URL or the default value if one is not present.
*/

View File

@ -336,6 +336,69 @@ class EnvironmentUrlsDataJsonExtensionsTest {
DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.toBaseWebVaultImportUrl,
)
}
@Test
fun `appLinksScheme should return the correct scheme for US environment`() {
val expectedScheme = "https"
assertEquals(
expectedScheme,
EnvironmentUrlDataJson.DEFAULT_US.appLinksScheme,
)
}
@Test
fun `appLinksScheme should return the correct scheme for EU environment`() {
val expectedScheme = "https"
assertEquals(
expectedScheme,
EnvironmentUrlDataJson.DEFAULT_EU.appLinksScheme,
)
}
@Test
fun `appLinksScheme should return the correct scheme for custom environment`() {
val expectedScheme = "bitwarden"
assertEquals(
expectedScheme,
DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.appLinksScheme,
)
}
@Suppress("MaxLineLength")
@Test
fun `ssoAppLinksRedirectUrl should return the correct webauthn redirect url for US environment`() {
val expectedUrl = "https://bitwarden.com/sso-callback"
assertEquals(
expectedUrl,
EnvironmentUrlDataJson.DEFAULT_US.ssoAppLinksRedirectUrl,
)
}
@Suppress("MaxLineLength")
@Test
fun `ssoAppLinksRedirectUrl should return the correct webauthn redirect url for EU environment`() {
val expectedUrl = "https://bitwarden.eu/sso-callback"
assertEquals(
expectedUrl,
EnvironmentUrlDataJson.DEFAULT_EU.ssoAppLinksRedirectUrl,
)
}
@Suppress("MaxLineLength")
@Test
fun `ssoAppLinksRedirectUrl should return the correct webauthn redirect url for custom environment`() {
val expectedUrl = "bitwarden://sso-callback"
assertEquals(
expectedUrl,
DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.ssoAppLinksRedirectUrl,
)
}
}
private val DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA = EnvironmentUrlDataJson(