diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSource.kt index bc3dfc911c..a6a7a6b513 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSource.kt @@ -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() } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSourceImpl.kt index 562d932bfe..f4ec6bffee 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSourceImpl.kt @@ -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) } + } + } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt index 35f9069e25..a7f3812b38 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt @@ -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() } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt index d088b220bd..41eeacb626 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt @@ -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(replay = 1) @@ -68,4 +70,8 @@ class DebugMenuRepositoryImpl( settingsDiskSource.hasUserLoggedInOrCreatedAccount = false userStateUpdateTrigger.invoke() } + + override fun clearSsoCookies() { + cookieDiskSource.clearCookies() + } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt index 5a8c4faa60..aa5e3cc114 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt @@ -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, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt index df09ccd89a..f36fb388fe 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt @@ -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)) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt index cf00f08b5a..fcffa3895e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt @@ -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. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSourceTest.kt index 3b05fd161a..ed2fda53a2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/CookieDiskSourceTest.kt @@ -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" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt index 4723611130..b1416141ff 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt @@ -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 { + 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" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt index f4ac4dd941..12accf0ad1 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt @@ -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 diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt index 9d313e40de..452a761691 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt @@ -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 { diff --git a/ui/src/main/res/values/strings_non_localized.xml b/ui/src/main/res/values/strings_non_localized.xml index 380de36c5d..83fc1b80c9 100644 --- a/ui/src/main/res/values/strings_non_localized.xml +++ b/ui/src/main/res/values/strings_non_localized.xml @@ -41,6 +41,7 @@ Archive Items Send Email Verification Trigger cookie acquisition + Clear SSO cookies