Compare commits

...

6 Commits

34 changed files with 758 additions and 25 deletions

View File

@@ -22,4 +22,9 @@ interface CookieDiskSource {
* @param config The [CookieConfigurationData] to persist, or `null` to delete.
*/
fun storeCookieConfig(hostname: String, config: CookieConfigurationData?)
/**
* Clears all stored cookie configurations across all hostnames.
*/
fun clearCookies()
}

View File

@@ -1,12 +1,14 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import androidx.core.content.edit
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
import kotlinx.serialization.json.Json
private const val CONFIG_PREFIX = "elb_cookie_config"
private const val ENCRYPTED_PREFIX = "bwSecureStorage:$CONFIG_PREFIX"
/**
* Implementation of [CookieDiskSource] using encrypted SharedPreferences.
@@ -15,7 +17,7 @@ private const val CONFIG_PREFIX = "elb_cookie_config"
*/
class CookieDiskSourceImpl(
sharedPreferences: SharedPreferences,
encryptedSharedPreferences: SharedPreferences,
private val encryptedSharedPreferences: SharedPreferences,
private val json: Json,
) : CookieDiskSource,
BaseEncryptedDiskSource(
@@ -33,4 +35,14 @@ class CookieDiskSourceImpl(
val key = CONFIG_PREFIX.appendIdentifier(hostname)
putEncryptedString(key, config?.let { json.encodeToString(it) })
}
override fun clearCookies() {
val keysToRemove = encryptedSharedPreferences
.all
.keys
.filter { it.startsWith(ENCRYPTED_PREFIX) }
encryptedSharedPreferences.edit {
keysToRemove.forEach { key -> remove(key) }
}
}
}

View File

@@ -49,4 +49,9 @@ interface DebugMenuRepository {
* @param userStateUpdateTrigger A passable lambda to trigger a user state update.
*/
fun modifyStateToShowOnboardingCarousel(userStateUpdateTrigger: () -> Unit)
/**
* Clears all stored SSO cookie configurations.
*/
fun clearSsoCookies()
}

View File

@@ -6,6 +6,7 @@ import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.getFlagValueOrDefault
@@ -20,6 +21,7 @@ class DebugMenuRepositoryImpl(
private val serverConfigRepository: ServerConfigRepository,
private val settingsDiskSource: SettingsDiskSource,
private val authDiskSource: AuthDiskSource,
private val cookieDiskSource: CookieDiskSource,
) : DebugMenuRepository {
private val mutableOverridesUpdatedFlow = bufferedMutableSharedFlow<Unit>(replay = 1)
@@ -68,4 +70,8 @@ class DebugMenuRepositoryImpl(
settingsDiskSource.hasUserLoggedInOrCreatedAccount = false
userStateUpdateTrigger.invoke()
}
override fun clearSsoCookies() {
cookieDiskSource.clearCookies()
}
}

View File

@@ -7,6 +7,7 @@ import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@@ -92,10 +93,12 @@ object PlatformRepositoryModule {
serverConfigRepository: ServerConfigRepository,
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
cookieDiskSource: CookieDiskSource,
): DebugMenuRepository = DebugMenuRepositoryImpl(
featureFlagOverrideDiskSource = featureFlagOverrideDiskSource,
serverConfigRepository = serverConfigRepository,
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
cookieDiskSource = cookieDiskSource,
)
}

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.network.exception.CookieRedirectException
/**
* Returns a user-friendly error message if this [Throwable] is an allow-listed
* exception type that carries one, or `null` otherwise.
*/
val Throwable.userFriendlyMessage: String?
get() = when (this) {
is CookieRedirectException -> message
else -> null
}

View File

@@ -11,7 +11,11 @@ sealed class ArchiveCipherResult {
data object Success : ArchiveCipherResult()
/**
* Generic error while archiving a cipher.
* Generic error while archiving a cipher. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(val error: Throwable) : ArchiveCipherResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : ArchiveCipherResult()
}

View File

@@ -13,7 +13,11 @@ sealed class CreateFolderResult {
data class Success(val folderView: FolderView) : CreateFolderResult()
/**
* Generic error while creating a folder.
* Generic error while creating a folder. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(val error: Throwable) : CreateFolderResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : CreateFolderResult()
}

View File

@@ -11,7 +11,11 @@ sealed class DeleteAttachmentResult {
data object Success : DeleteAttachmentResult()
/**
* Generic error while deleting an attachment.
* Generic error while deleting an attachment. The optional [errorMessage] may be
* displayed directly in the UI when present.
*/
data class Error(val error: Throwable) : DeleteAttachmentResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : DeleteAttachmentResult()
}

View File

@@ -11,7 +11,11 @@ sealed class DeleteCipherResult {
data object Success : DeleteCipherResult()
/**
* Generic error while deleting a cipher.
* Generic error while deleting a cipher. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(val error: Throwable) : DeleteCipherResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : DeleteCipherResult()
}

View File

@@ -11,7 +11,11 @@ sealed class DeleteFolderResult {
data object Success : DeleteFolderResult()
/**
* Generic error while deleting a folder.
* Generic error while deleting a folder. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(val error: Throwable) : DeleteFolderResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : DeleteFolderResult()
}

View File

@@ -11,7 +11,11 @@ sealed class DeleteSendResult {
data object Success : DeleteSendResult()
/**
* Generic error while deleting a send.
* Generic error while deleting a send. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(val error: Throwable) : DeleteSendResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : DeleteSendResult()
}

View File

@@ -22,9 +22,13 @@ sealed class ImportCredentialsResult {
data class SyncFailed(val error: Throwable) : ImportCredentialsResult()
/**
* Indicates there was an error importing the vault data.
* Indicates there was an error importing the vault data. The optional [errorMessage] may be
* displayed directly in the UI when present.
*
* @param error The error that occurred during import.
*/
data class Error(val error: Throwable) : ImportCredentialsResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : ImportCredentialsResult()
}

View File

@@ -11,7 +11,11 @@ sealed class RestoreCipherResult {
data object Success : RestoreCipherResult()
/**
* Generic error while restoring a cipher.
* Generic error while restoring a cipher. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(val error: Throwable) : RestoreCipherResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : RestoreCipherResult()
}

View File

@@ -10,7 +10,11 @@ sealed class ShareCipherResult {
data object Success : ShareCipherResult()
/**
* Generic error while sharing cipher.
* Generic error while sharing cipher. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(val error: Throwable) : ShareCipherResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : ShareCipherResult()
}

View File

@@ -11,7 +11,11 @@ sealed class UnarchiveCipherResult {
data object Success : UnarchiveCipherResult()
/**
* Generic error while unarchiving a cipher.
* Generic error while unarchiving a cipher. The optional [errorMessage] may be
* displayed directly in the UI when present.
*/
data class Error(val error: Throwable) : UnarchiveCipherResult()
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : UnarchiveCipherResult()
}

View File

@@ -140,6 +140,19 @@ fun DebugMenuScreen(
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.clear_sso_cookies),
onClick = {
viewModel.trySendAction(
DebugMenuAction.ClearSsoCookies,
)
},
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))

View File

@@ -64,6 +64,7 @@ class DebugMenuViewModel @Inject constructor(
DebugMenuAction.GenerateCrashClick -> handleCrashClick()
DebugMenuAction.GenerateErrorReportClick -> handleErrorReportClick()
DebugMenuAction.TriggerCookieAcquisition -> handleTriggerCookieAcquisition()
DebugMenuAction.ClearSsoCookies -> handleClearSsoCookies()
}
}
@@ -100,6 +101,10 @@ class DebugMenuViewModel @Inject constructor(
}
}
private fun handleClearSsoCookies() {
debugMenuRepository.clearSsoCookies()
}
private fun handleTriggerCookieAcquisition() {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(
data = CookieAcquisitionRequest(
@@ -196,6 +201,11 @@ sealed class DebugMenuAction {
*/
data object TriggerCookieAcquisition : DebugMenuAction()
/**
* The user has clicked clear SSO cookies button.
*/
data object ClearSsoCookies : DebugMenuAction()
/**
* Internal actions not triggered from the UI.
*/

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.glide
import android.content.Context
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.network.ssl.createMtlsOkHttpClient
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
@@ -9,6 +10,7 @@ import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.AppGlideModule
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -24,6 +26,7 @@ import java.io.InputStream
*
* The configuration mirrors the SSL setup used in RetrofitsImpl for API calls.
*/
@OmitFromCoverage
@GlideModule
class BitwardenAppGlideModule : AppGlideModule() {
@@ -37,6 +40,11 @@ class BitwardenAppGlideModule : AppGlideModule() {
* Provides access to the [CertificateManager] for mTLS certificate management.
*/
fun certificateManager(): CertificateManager
/**
* Provides access to the [NetworkCookieManager] for cookie-based authentication.
*/
fun networkCookieManager(): NetworkCookieManager
}
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
@@ -46,12 +54,20 @@ class BitwardenAppGlideModule : AppGlideModule() {
entryPoint = BitwardenGlideEntryPoint::class.java,
)
val certificateManager = entryPoint.certificateManager()
val networkCookieManager = entryPoint.networkCookieManager()
// Register OkHttpUrlLoader that uses our mTLS OkHttpClient
// Build OkHttpClient with mTLS and cookie support
val client = certificateManager
.createMtlsOkHttpClient()
.newBuilder()
.addNetworkInterceptor(GlideCookieInterceptor(networkCookieManager))
.build()
// Register OkHttpUrlLoader that uses our mTLS + cookie OkHttpClient
registry.replace(
GlideUrl::class.java,
InputStream::class.java,
OkHttpUrlLoader.Factory(certificateManager.createMtlsOkHttpClient()),
OkHttpUrlLoader.Factory(client),
)
}

View File

@@ -0,0 +1,49 @@
package com.x8bit.bitwarden.ui.platform.glide
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.provider.CookieProvider
import okhttp3.Interceptor
import okhttp3.Response
private const val HEADER_COOKIE = "Cookie"
private const val HTTP_302 = 302
/**
* Interceptor that attaches cookies to Glide image requests for enterprise environments
* requiring cookie-based authentication.
*
* Unlike [com.bitwarden.network.interceptor.CookieInterceptor], this interceptor does not
* trigger cookie acquisition. It throws [CookieRedirectException] on HTTP 302 responses
* to prevent Glide from following redirects and caching invalid content.
*
* @property cookieProvider Provider for retrieving cookies by hostname.
*/
class GlideCookieInterceptor(
private val cookieProvider: CookieProvider,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val hostname = originalRequest.url.host
val cookieHeader = cookieProvider
.getCookies(hostname)
.joinToString("; ") { "${it.name}=${it.value}" }
if (cookieHeader.isEmpty()) return chain.proceed(originalRequest).also(::check302)
val request = originalRequest
.newBuilder()
.header(HEADER_COOKIE, cookieHeader)
.build()
return chain.proceed(request).also(::check302)
}
private fun check302(response: Response) {
if (response.code == HTTP_302) {
response.close()
throw CookieRedirectException(
hostname = response.request.url.host,
)
}
}
}

View File

@@ -46,6 +46,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
@@ -1190,7 +1191,8 @@ class VaultViewModel @Inject constructor(
isPremium = state.isPremium,
hasMasterPassword = state.hasMasterPassword,
errorTitle = BitwardenString.an_error_has_occurred.asText(),
errorMessage = BitwardenString.generic_error_message.asText(),
errorMessage = vaultData.error.userFriendlyMessage?.asText()
?: BitwardenString.generic_error_message.asText(),
isRefreshing = false,
restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds,
isArchiveEnabled = state.isArchiveEnabled,

View File

@@ -138,6 +138,38 @@ class CookieDiskSourceTest {
assertEquals(config2, cookieDiskSource.getCookieConfig(hostname2))
}
@Test
fun `clearCookies should remove all stored cookie configs`() {
val hostname1 = "vault.bitwarden.com"
val hostname2 = "other.bitwarden.com"
val config1 = CookieConfigurationData(
hostname = hostname1,
cookies = listOf(
CookieConfigurationData.Cookie(name = "A", value = "1"),
),
)
val config2 = CookieConfigurationData(
hostname = hostname2,
cookies = listOf(
CookieConfigurationData.Cookie(name = "B", value = "2"),
),
)
cookieDiskSource.storeCookieConfig(hostname1, config1)
cookieDiskSource.storeCookieConfig(hostname2, config2)
cookieDiskSource.clearCookies()
assertNull(cookieDiskSource.getCookieConfig(hostname1))
assertNull(cookieDiskSource.getCookieConfig(hostname2))
}
@Test
fun `clearCookies should be safe to call when no cookies are stored`() {
cookieDiskSource.clearCookies()
assertNull(cookieDiskSource.getCookieConfig("vault.bitwarden.com"))
}
@Test
fun `storage should isolate configs by hostname`() {
val hostname1 = "vault.bitwarden.com"

View File

@@ -7,6 +7,7 @@ import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import io.mockk.every
@@ -42,11 +43,16 @@ class DebugMenuRepositoryTest {
every { hasUserLoggedInOrCreatedAccount = any() } just runs
}
private val mockCookieDiskSource = mockk<CookieDiskSource> {
every { clearCookies() } just runs
}
private val debugMenuRepository = DebugMenuRepositoryImpl(
featureFlagOverrideDiskSource = mockFeatureFlagOverrideDiskSource,
serverConfigRepository = mockServerConfigRepository,
settingsDiskSource = mockSettingsDiskSource,
authDiskSource = mockAuthDiskSource,
cookieDiskSource = mockCookieDiskSource,
)
@Test
@@ -169,6 +175,13 @@ class DebugMenuRepositoryTest {
mockSettingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = null)
}
}
@Test
fun `clearSsoCookies should call clearCookies on CookieDiskSource`() {
debugMenuRepository.clearSsoCookies()
verify(exactly = 1) {
mockCookieDiskSource.clearCookies()
}
}
}
private const val TEST_STRING_VALUE = "test"

View File

@@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.network.exception.CookieRedirectException
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.io.IOException
class ThrowableExtensionsTest {
@Test
fun `userFriendlyMessage should return message for CookieRedirectException`() {
val exception = CookieRedirectException(hostname = "example.com")
assertEquals(
"Your request was interrupted because the app needed to " +
"re-authenticate. Please try again.",
exception.userFriendlyMessage,
)
}
@Test
fun `userFriendlyMessage should return null for IOException`() {
val exception = IOException("io error")
assertNull(exception.userFriendlyMessage)
}
@Test
fun `userFriendlyMessage should return null for RuntimeException`() {
val exception = RuntimeException("runtime error")
assertNull(exception.userFriendlyMessage)
}
}

View File

@@ -149,6 +149,16 @@ class DebugMenuScreenTest : BitwardenComposeTest() {
verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
}
@Test
fun `clear SSO cookies should send ClearSsoCookies action`() {
composeTestRule
.onNodeWithText("Clear SSO cookies")
.performScrollTo()
.performClick()
verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.ClearSsoCookies) }
}
@Test
fun `reset all coach mark tours should send ResetCoachMarkTourStatuses action`() {
composeTestRule

View File

@@ -145,6 +145,15 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ClearSsoCookies should call clearSsoCookies on DebugMenuRepository`() {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.ClearSsoCookies)
verify(exactly = 1) {
mockDebugMenuRepository.clearSsoCookies()
}
}
@Test
fun `TriggerCookieAcquisition should set pending cookie acquisition`() =
runTest {

View File

@@ -44,4 +44,17 @@ class BitwardenAppGlideModuleTest {
assertTrue(hasCertificateManagerMethod)
}
@Test
fun `BitwardenGlideEntryPoint should declare networkCookieManager method`() {
val entryPointInterface = BitwardenAppGlideModule::class.java
.declaredClasses
.firstOrNull { it.simpleName == "BitwardenGlideEntryPoint" }
val methods = requireNotNull(entryPointInterface).declaredMethods
val hasNetworkCookieManagerMethod =
methods.any { it.name == "networkCookieManager" }
assertTrue(hasNetworkCookieManagerMethod)
}
}

View File

@@ -0,0 +1,163 @@
package com.x8bit.bitwarden.ui.platform.glide
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.interceptor.FakeInterceptorChain
import com.bitwarden.network.model.NetworkCookie
import com.bitwarden.network.provider.CookieProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class GlideCookieInterceptorTest {
private val mockCookieProvider: CookieProvider = mockk()
private val interceptor = GlideCookieInterceptor(
cookieProvider = mockCookieProvider,
)
@Test
fun `intercept should proceed without cookie header when no cookies available`() {
val originalRequest = Request.Builder()
.url("https://vault.bitwarden.com/icons/icon.png")
.build()
val chain = FakeInterceptorChain(originalRequest)
every {
mockCookieProvider.getCookies("vault.bitwarden.com")
} returns emptyList()
val response = interceptor.intercept(chain)
assertEquals(originalRequest, response.request)
assertNull(response.request.header("Cookie"))
}
@Test
fun `intercept should attach single cookie correctly`() {
val originalRequest = Request.Builder()
.url("https://vault.bitwarden.com/icons/icon.png")
.build()
val chain = FakeInterceptorChain(originalRequest)
every {
mockCookieProvider.getCookies("vault.bitwarden.com")
} returns listOf(
NetworkCookie(name = "awselb", value = "session123"),
)
val response = interceptor.intercept(chain)
assertEquals("awselb=session123", response.request.header("Cookie"))
}
@Test
fun `intercept should attach multiple cookies in correct format`() {
val originalRequest = Request.Builder()
.url("https://vault.bitwarden.com/icons/icon.png")
.build()
val chain = FakeInterceptorChain(originalRequest)
every {
mockCookieProvider.getCookies("vault.bitwarden.com")
} returns listOf(
NetworkCookie(name = "awselb", value = "session123"),
NetworkCookie(name = "awselbcors", value = "cors456"),
)
val response = interceptor.intercept(chain)
assertEquals(
"awselb=session123; awselbcors=cors456",
response.request.header("Cookie"),
)
}
@Test
fun `intercept should throw CookieRedirectException on 302 response without cookies`() {
val originalRequest = Request.Builder()
.url("https://vault.bitwarden.com/icons/icon.png")
.build()
val redirectResponse = Response.Builder()
.code(302)
.message("Found")
.protocol(Protocol.HTTP_1_1)
.request(originalRequest)
.header("Location", "https://idp.example.com/auth")
.build()
val chain = FakeInterceptorChain(
request = originalRequest,
responseProvider = { redirectResponse },
)
every {
mockCookieProvider.getCookies("vault.bitwarden.com")
} returns emptyList()
val exception = assertThrows<CookieRedirectException> {
interceptor.intercept(chain)
}
assertEquals("vault.bitwarden.com", exception.hostname)
}
@Test
fun `intercept should throw CookieRedirectException on 302 response with cookies`() {
val originalRequest = Request.Builder()
.url("https://vault.bitwarden.com/icons/icon.png")
.build()
val redirectResponse = Response.Builder()
.code(302)
.message("Found")
.protocol(Protocol.HTTP_1_1)
.request(originalRequest)
.header("Location", "https://idp.example.com/auth")
.build()
val chain = FakeInterceptorChain(
request = originalRequest,
responseProvider = { redirectResponse },
)
every {
mockCookieProvider.getCookies("vault.bitwarden.com")
} returns listOf(
NetworkCookie(name = "awselb", value = "session123"),
)
val exception = assertThrows<CookieRedirectException> {
interceptor.intercept(chain)
}
assertEquals("vault.bitwarden.com", exception.hostname)
}
@Test
fun `intercept should not call needsBootstrap or acquireCookies`() {
val originalRequest = Request.Builder()
.url("https://vault.bitwarden.com/icons/icon.png")
.build()
val chain = FakeInterceptorChain(originalRequest)
every {
mockCookieProvider.getCookies("vault.bitwarden.com")
} returns emptyList()
interceptor.intercept(chain)
verify(exactly = 0) {
mockCookieProvider.needsBootstrap(any())
mockCookieProvider.acquireCookies(any())
}
}
}

View File

@@ -7,6 +7,7 @@ import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.data.repository.util.baseIconUrl
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.createMockPolicy
@@ -1387,6 +1388,31 @@ class VaultViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Error with CookieRedirectException should show user-friendly message`() =
runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
error = CookieRedirectException(hostname = "example.com"),
),
)
val viewModel = createViewModel()
assertEquals(
createMockVaultState(
viewState = VaultState.ViewState.Error(
message = (
"Your request was interrupted because the app needed to " +
"re-authenticate. Please try again."
).asText(),
),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Error with items should update state to Content and show an error dialog`() =
@@ -1455,6 +1481,77 @@ class VaultViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Error with CookieRedirectException with items should show user-friendly error dialog`() =
runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
error = CookieRedirectException(hostname = "example.com"),
data = VaultData(
decryptCipherListResult = createMockDecryptCipherListResult(
number = 1,
successes = listOf(createMockCipherListView(number = 1)),
),
collectionViewList = listOf(createMockCollectionView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
),
)
val viewModel = createViewModel()
assertEquals(
createMockVaultState(
viewState = VaultState.ViewState.Content(
loginItemsCount = 1,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = "mockId-1",
name = "mockName-1".asText(),
itemCount = 1,
),
VaultState.ViewState.FolderItem(
id = null,
name = BitwardenString.folder_none.asText(),
itemCount = 0,
),
),
collectionItems = listOf(
VaultState.ViewState.CollectionItem(
id = "mockId-1",
name = "mockName-1",
itemCount = 1,
),
),
noFolderItems = listOf(),
trashItemsCount = 0,
totpItemsCount = 1,
itemTypesCount = 5,
sshKeyItemsCount = 0,
archivedItemsCount = 0,
archiveEnabled = true,
archiveSubText = null,
archiveEndIcon = null,
showCardGroup = true,
),
dialog = VaultState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = (
"Your request was interrupted because the app needed to " +
"re-authenticate. Please try again."
).asText(),
),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Error with empty items should update state to NoItems and show an error dialog`() =

View File

@@ -13,4 +13,6 @@ import java.io.IOException
*/
class CookieRedirectException(
val hostname: String,
) : IOException("HTTP 302 redirect detected for $hostname.")
) : IOException(
"Your request was interrupted because the app needed to re-authenticate. Please try again.",
)

View File

@@ -1,6 +1,9 @@
package com.bitwarden.network.model
import com.bitwarden.network.exception.CookieRedirectException
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
/**
@@ -45,8 +48,26 @@ sealed class BitwardenError {
*/
fun Throwable.toBitwardenError(): BitwardenError {
return when (this) {
// CookieRedirectException is a subclass of IOException thrown when SSO cookies
// expire in a load-balanced environment. It must be checked before IOException to
// avoid being classified as a generic Network error. We synthesize an Http error
// with a JSON body so the exception's message propagates through the existing
// parseErrorBodyOrNull pipeline used by service-layer recoverCatching blocks.
is CookieRedirectException -> {
BitwardenError.Http(
throwable = HttpException(
Response.error<Any>(
HTTP_CODE_BAD_REQUEST,
"""{"message": "${this.message}"}""".toResponseBody(),
),
),
)
}
is IOException -> BitwardenError.Network(this)
is HttpException -> BitwardenError.Http(this)
else -> BitwardenError.Other(this)
}
}
private const val HTTP_CODE_BAD_REQUEST: Int = 400

View File

@@ -0,0 +1,67 @@
package com.bitwarden.network.model
import com.bitwarden.network.exception.CookieRedirectException
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
class BitwardenErrorTest {
@Test
fun `toBitwardenError with CookieRedirectException should return Http with status 400`() {
val exception = CookieRedirectException(hostname = "example.com")
val result = exception.toBitwardenError()
assertTrue(result is BitwardenError.Http)
val httpError = result as BitwardenError.Http
assertEquals(400, httpError.code)
}
@Test
fun `toBitwardenError with CookieRedirectException should include message in body`() {
val exception = CookieRedirectException(hostname = "example.com")
val result = exception.toBitwardenError()
val httpError = result as BitwardenError.Http
val body = httpError.responseBodyString
assertTrue(body?.contains(exception.message.orEmpty()) == true)
}
@Test
fun `toBitwardenError with IOException should return Network`() {
val exception = IOException("network failure")
val result = exception.toBitwardenError()
assertTrue(result is BitwardenError.Network)
assertEquals(exception, result.throwable)
}
@Test
fun `toBitwardenError with HttpException should return Http`() {
val exception = HttpException(
Response.error<Unit>(400, "error".toResponseBody()),
)
val result = exception.toBitwardenError()
assertTrue(result is BitwardenError.Http)
assertEquals(exception, result.throwable)
}
@Test
fun `toBitwardenError with RuntimeException should return Other`() {
val exception = RuntimeException("unexpected")
val result = exception.toBitwardenError()
assertTrue(result is BitwardenError.Other)
assertEquals(exception, result.throwable)
}
}

View File

@@ -0,0 +1,99 @@
package com.bitwarden.network.util
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.model.BitwardenError
import com.bitwarden.network.model.CreateCipherResponseJson
import com.bitwarden.network.model.toBitwardenError
import kotlinx.serialization.json.Json
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
class ExceptionExtensionsTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
@Test
fun `parseErrorBodyOrNull with CookieRedirectException should extract message`() {
val expectedMessage = "Your request was interrupted because the app " +
"needed to re-authenticate. Please try again."
val error = CookieRedirectException(hostname = "example.com")
.toBitwardenError()
val result = error.parseErrorBodyOrNull<CreateCipherResponseJson.Invalid>(
codes = listOf(NetworkErrorCode.BAD_REQUEST),
json = json,
)
assertEquals(expectedMessage, result?.message)
}
@Test
fun `parseErrorBodyOrNull with Http and matching code should parse body`() {
val responseBody = """
{
"message": "Bad request",
"validationErrors": {
"Name": ["Name is required"]
}
}
""".trimIndent()
val error = BitwardenError.Http(
throwable = HttpException(
Response.error<Unit>(400, responseBody.toResponseBody()),
),
)
val result = error.parseErrorBodyOrNull<CreateCipherResponseJson.Invalid>(
codes = listOf(NetworkErrorCode.BAD_REQUEST),
json = json,
)
assertEquals("Bad request", result?.message)
assertEquals(
mapOf("Name" to listOf("Name is required")),
result?.validationErrors,
)
}
@Test
fun `parseErrorBodyOrNull with Http and non-matching code should return null`() {
val responseBody = """
{
"message": "Bad request",
"validationErrors": null
}
""".trimIndent()
val error = BitwardenError.Http(
throwable = HttpException(
Response.error<Unit>(400, responseBody.toResponseBody()),
),
)
val result = error.parseErrorBodyOrNull<CreateCipherResponseJson.Invalid>(
codes = listOf(NetworkErrorCode.UNAUTHORIZED),
json = json,
)
assertNull(result)
}
@Test
fun `parseErrorBodyOrNull with Network should return null`() {
val error = BitwardenError.Network(throwable = IOException("timeout"))
val result = error.parseErrorBodyOrNull<CreateCipherResponseJson.Invalid>(
codes = listOf(NetworkErrorCode.BAD_REQUEST),
json = json,
)
assertNull(result)
}
}

View File

@@ -42,6 +42,7 @@
<string name="archive_items">Archive Items</string>
<string name="send_email_verification">Send Email Verification</string>
<string name="trigger_cookie_acquisition">Trigger cookie acquisition</string>
<string name="clear_sso_cookies">Clear SSO cookies</string>
<!-- endregion Debug Menu -->
</resources>