[PM-33227] feat: Add Clear SSO Cookies button to debug menu (#6620)

This commit is contained in:
Patrick Honkonen
2026-03-09 16:35:59 -04:00
committed by GitHub
parent ee40623911
commit 6570115d9e
12 changed files with 120 additions and 1 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

@@ -119,6 +119,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

@@ -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

@@ -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

@@ -143,6 +143,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

@@ -41,6 +41,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>