diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index a9c9f6a271..361e3b9d22 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.auth.datasource.disk +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import kotlinx.coroutines.flow.Flow @@ -209,4 +210,19 @@ interface AuthDiskSource { * Stores the [policies] for the given [userId]. */ fun storePolicies(userId: String, policies: List?) + + /** + * Gets the account tokens for the given [userId]. + */ + fun getAccountTokens(userId: String): AccountTokensJson? + + /** + * Emits updates that track [getAccountTokens]. This will replay the last known value, if any. + */ + fun getAccountTokensFlow(userId: String): Flow + + /** + * Stores the [accountTokens] for the given [userId]. + */ + fun storeAccountTokens(userId: String, accountTokens: AccountTokensJson?) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index a7a473a311..031261adb3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk import android.content.SharedPreferences +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY import com.x8bit.bitwarden.data.platform.datasource.disk.BaseEncryptedDiskSource @@ -16,6 +17,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.util.UUID +private const val ACCOUNT_TOKENS_KEY = "$ENCRYPTED_BASE_KEY:accountTokens" private const val BIOMETRICS_UNLOCK_KEY = "$ENCRYPTED_BASE_KEY:userKeyBiometricUnlock" private const val USER_AUTO_UNLOCK_KEY_KEY = "$ENCRYPTED_BASE_KEY:userKeyAutoUnlock" private const val UNIQUE_APP_ID_KEY = "$BASE_KEY:appId" @@ -60,6 +62,8 @@ class AuthDiskSourceImpl( mutableMapOf?>>() private val mutablePoliciesFlowMap = mutableMapOf?>>() + private val mutableAccountTokensFlowMap = + mutableMapOf>() override val uniqueAppId: String get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId() @@ -111,6 +115,7 @@ class AuthDiskSourceImpl( storeUserBiometricUnlockKey(userId = userId, biometricsKey = null) storeMasterPasswordHash(userId = userId, passwordHash = null) storePolicies(userId = userId, policies = null) + storeAccountTokens(userId = userId, accountTokens = null) } override fun getLastActiveTimeMillis(userId: String): Long? = @@ -308,6 +313,22 @@ class AuthDiskSourceImpl( getMutablePoliciesFlow(userId = userId).tryEmit(policies) } + override fun getAccountTokens(userId: String): AccountTokensJson? = + getEncryptedString(key = "${ACCOUNT_TOKENS_KEY}_$userId") + ?.let { json.decodeFromStringOrNull(it) } + + override fun getAccountTokensFlow(userId: String): Flow = + getMutableAccountTokensFlow(userId = userId) + .onSubscription { emit(getAccountTokens(userId = userId)) } + + override fun storeAccountTokens(userId: String, accountTokens: AccountTokensJson?) { + putEncryptedString( + key = "${ACCOUNT_TOKENS_KEY}_$userId", + value = accountTokens?.let { json.encodeToString(it) }, + ) + getMutableAccountTokensFlow(userId = userId).tryEmit(accountTokens) + } + private fun generateAndStoreUniqueAppId(): String = UUID .randomUUID() @@ -329,4 +350,11 @@ class AuthDiskSourceImpl( mutablePoliciesFlowMap.getOrPut(userId) { bufferedMutableSharedFlow(replay = 1) } + + private fun getMutableAccountTokensFlow( + userId: String, + ): MutableSharedFlow = + mutableAccountTokensFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index b77e02bc01..a6725eb67f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -190,6 +190,13 @@ class AuthDiskSourceTest { userId = userId, policies = listOf(createMockPolicy()), ) + authDiskSource.storeAccountTokens( + userId = userId, + accountTokens = AccountTokensJson( + accessToken = "accessToken", + refreshToken = "refreshToken", + ), + ) authDiskSource.clearData(userId = userId) @@ -202,6 +209,7 @@ class AuthDiskSourceTest { assertNull(authDiskSource.getOrganizationKeys(userId = userId)) assertNull(authDiskSource.getOrganizations(userId = userId)) assertNull(authDiskSource.getPolicies(userId = userId)) + assertNull(authDiskSource.getAccountTokens(userId = userId)) } @Test @@ -826,6 +834,71 @@ class AuthDiskSourceTest { json.parseToJsonElement(requireNotNull(actual)), ) } + + @Test + fun `getAccountTokens should pull from SharedPreferences`() { + val baseKey = "bwSecureStorage:accountTokens" + val mockUserId = "mockUserId" + val accountTokens = AccountTokensJson( + accessToken = "accessToken", + refreshToken = "refreshToken", + ) + fakeEncryptedSharedPreferences.edit { + putString("${baseKey}_$mockUserId", json.encodeToString(accountTokens)) + } + val actual = authDiskSource.getAccountTokens(userId = mockUserId) + assertEquals(accountTokens, actual) + } + + @Test + fun `getAccountTokensFlow should react to changes from storeAccountTokens`() = runTest { + val mockUserId = "mockUserId" + val accountTokens = AccountTokensJson( + accessToken = "accessToken", + refreshToken = "refreshToken", + ) + authDiskSource.getAccountTokensFlow(userId = mockUserId).test { + // The initial values of the Flow and the property are in sync + assertNull(authDiskSource.getAccountTokens(userId = mockUserId)) + assertNull(awaitItem()) + + // Updating the repository updates shared preferences + authDiskSource.storeAccountTokens( + userId = mockUserId, + accountTokens = accountTokens, + ) + assertEquals(accountTokens, awaitItem()) + + // clear the repository clears shared preferences + authDiskSource.storeAccountTokens( + userId = mockUserId, + accountTokens = null, + ) + assertNull(awaitItem()) + } + } + + @Test + fun `storeAccountTokens should update SharedPreferences`() { + val baseKey = "bwSecureStorage:accountTokens" + val mockUserId = "mockUserId" + val accountTokens = AccountTokensJson( + accessToken = "accessToken", + refreshToken = "refreshToken", + ) + authDiskSource.storeAccountTokens( + userId = mockUserId, + accountTokens = accountTokens, + ) + val actual = fakeEncryptedSharedPreferences.getString( + key = "${baseKey}_$mockUserId", + defaultValue = null, + ) + assertEquals( + json.encodeToJsonElement(accountTokens), + json.parseToJsonElement(requireNotNull(actual)), + ) + } } private const val USER_STATE_JSON = """ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 61a7f8700e..1ac38c5333 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.util import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson @@ -20,6 +21,8 @@ class FakeAuthDiskSource : AuthDiskSource { mutableMapOf?>>() private val mutablePoliciesFlowMap = mutableMapOf?>>() + private val mutableAccountTokensFlowMap = + mutableMapOf>() private val mutableUserStateFlow = bufferedMutableSharedFlow(replay = 1) private val storedLastActiveTimeMillis = mutableMapOf() @@ -33,6 +36,7 @@ class FakeAuthDiskSource : AuthDiskSource { private val storedOrganizations = mutableMapOf?>() private val storedOrganizationKeys = mutableMapOf?>() + private val storedAccountTokens = mutableMapOf() private val storedBiometricKeys = mutableMapOf() private val storedMasterPasswordHashes = mutableMapOf() private val storedPolicies = mutableMapOf?>() @@ -57,10 +61,13 @@ class FakeAuthDiskSource : AuthDiskSource { storedEncryptedPins.remove(userId) storedOrganizations.remove(userId) storedPolicies.remove(userId) + storedAccountTokens.remove(userId) storedBiometricKeys.remove(userId) storedOrganizationKeys.remove(userId) + mutableOrganizationsFlowMap.remove(userId) mutablePoliciesFlowMap.remove(userId) + mutableAccountTokensFlowMap.remove(userId) } override fun getLastActiveTimeMillis(userId: String): Long? = @@ -180,6 +187,18 @@ class FakeAuthDiskSource : AuthDiskSource { getMutablePoliciesFlow(userId = userId).tryEmit(policies) } + override fun getAccountTokens(userId: String): AccountTokensJson? = + storedAccountTokens[userId] + + override fun getAccountTokensFlow(userId: String): Flow = + getMutableAccountTokensFlow(userId = userId) + .onSubscription { emit(getAccountTokens(userId)) } + + override fun storeAccountTokens(userId: String, accountTokens: AccountTokensJson?) { + storedAccountTokens[userId] = accountTokens + getMutableAccountTokensFlow(userId = userId).tryEmit(accountTokens) + } + /** * Assert that the given [userState] matches the currently tracked value. */ @@ -304,5 +323,12 @@ class FakeAuthDiskSource : AuthDiskSource { bufferedMutableSharedFlow(replay = 1) } + private fun getMutableAccountTokensFlow( + userId: String, + ): MutableSharedFlow = + mutableAccountTokensFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + //endregion Private helper functions }