BIT-1915: Migrate account tokens to encrypted shared preferences (#1039)

This commit is contained in:
David Perez 2024-02-21 12:26:47 -06:00 committed by Álison Fernandes
parent 2e2b80470c
commit 7b7a1d15f5
18 changed files with 257 additions and 205 deletions

View file

@ -51,12 +51,6 @@ class AuthDiskSourceImpl(
),
AuthDiskSource {
init {
// We must migrate if necessary before any of the migrated values would be initialized
// and accessed.
legacySecureStorageMigrator.migrateIfNecessary()
}
private val inMemoryPinProtectedUserKeys = mutableMapOf<String, String?>()
private val mutableOrganizationsFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
@ -64,6 +58,27 @@ class AuthDiskSourceImpl(
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Policy>?>>()
private val mutableAccountTokensFlowMap =
mutableMapOf<String, MutableSharedFlow<AccountTokensJson?>>()
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
override var userState: UserStateJson?
get() = getString(key = STATE_KEY)?.let { json.decodeFromStringOrNull(it) }
set(value) {
putString(
key = STATE_KEY,
value = value?.let { json.encodeToString(value) },
)
mutableUserStateFlow.tryEmit(value)
}
init {
// We must migrate if necessary before any of the migrated values would be initialized
// and accessed.
legacySecureStorageMigrator.migrateIfNecessary()
// We must migrate the tokens from being stored in the UserState(shared preferences) to
// being stored separately in encrypted shared preferences.
migrateAccountTokens()
}
override val uniqueAppId: String
get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId()
@ -86,22 +101,10 @@ class AuthDiskSourceImpl(
)
}
override var userState: UserStateJson?
get() = getString(key = STATE_KEY)?.let { json.decodeFromStringOrNull(it) }
set(value) {
putString(
key = STATE_KEY,
value = value?.let { json.encodeToString(value) },
)
mutableUserStateFlow.tryEmit(value)
}
override val userStateFlow: Flow<UserStateJson?>
get() = mutableUserStateFlow
.onSubscription { emit(userState) }
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
override fun clearData(userId: String) {
storeLastActiveTimeMillis(userId = userId, lastActiveTimeMillis = null)
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
@ -357,4 +360,21 @@ class AuthDiskSourceImpl(
mutableAccountTokensFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun migrateAccountTokens() {
userState
?.accounts
.orEmpty()
.values
.forEach { accountJson ->
@Suppress("DEPRECATION")
accountJson.tokens?.let { storeAccountTokens(accountJson.profile.userId, it) }
}
userState = userState?.copy(
accounts = userState
?.accounts
?.mapValues { (_, accountJson) -> accountJson.copy(tokens = null) }
.orEmpty(),
)
}
}

View file

@ -17,17 +17,16 @@ data class AccountJson(
@SerialName("profile")
val profile: Profile,
@Deprecated(
"This is always null except the first time after migrating from the Xamarin app. " +
"Please use the accountTokens stored in the AuthDiskSource.",
)
@SerialName("tokens")
val tokens: AccountTokensJson,
val tokens: AccountTokensJson? = null,
@SerialName("settings")
val settings: Settings,
) {
/**
* Whether or not the account should be considered logged in.
*/
val isLoggedIn: Boolean get() = tokens.accessToken != null
/**
* Represents a user's personal profile.
*

View file

@ -16,4 +16,9 @@ data class AccountTokensJson(
@SerialName("refreshToken")
val refreshToken: String?,
)
) {
/**
* Returns `true` if the user is logged in, `false otherwise.
*/
val isLoggedIn: Boolean get() = accessToken != null
}

View file

@ -5,7 +5,6 @@ import android.widget.Toast
import androidx.annotation.StringRes
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
@ -72,25 +71,10 @@ class UserLogoutManagerImpl(
}
override fun softLogout(userId: String) {
val userState = authDiskSource.userState ?: return
val updatedAccount = userState
.accounts[userId]
// Clear the tokens for the current user if present
?.copy(
tokens = AccountTokensJson(
accessToken = null,
refreshToken = null,
),
)
authDiskSource.userState = userState
.copy(
accounts = userState
.accounts
.toMutableMap()
.apply {
updatedAccount?.let { set(userId, updatedAccount) }
},
)
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = null,
)
// Save any data that will still need to be retained after otherwise clearing all dat
val vaultTimeoutInMinutes = settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)

View file

@ -6,6 +6,7 @@ import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
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.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
@ -51,6 +52,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
@ -75,6 +77,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -83,6 +86,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -148,17 +153,22 @@ class AuthRepositoryImpl(
override val activeUserId: String? get() = authDiskSource.userState?.activeUserId
@OptIn(ExperimentalCoroutinesApi::class)
override val authStateFlow: StateFlow<AuthState> = authDiskSource
.userStateFlow
.map { userState ->
userState
?.activeAccount
?.tokens
?.accessToken
?.let {
AuthState.Authenticated(accessToken = it)
.activeUserIdChangesFlow
.flatMapLatest { activeUserId ->
activeUserId
?.let { userId ->
authDiskSource
.getAccountTokensFlow(userId)
.map { accountTokens ->
accountTokens
?.accessToken
?.let { AuthState.Authenticated(it) }
?: AuthState.Unauthenticated
}
}
?: AuthState.Unauthenticated
?: flowOf(AuthState.Unauthenticated)
}
.stateIn(
scope = unconfinedScope,
@ -186,6 +196,7 @@ class AuthRepositoryImpl(
hasPendingAccountAddition = hasPendingAccountAddition,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isLoggedInProvider = ::isUserLoggedIn,
)
}
.filter {
@ -204,6 +215,7 @@ class AuthRepositoryImpl(
hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isLoggedInProvider = ::isUserLoggedIn,
),
)
@ -522,6 +534,13 @@ class AuthRepositoryImpl(
// it is validated.
}
authDiskSource.storeAccountTokens(
userId = userStateJson.activeUserId,
accountTokens = AccountTokensJson(
accessToken = loginResponse.accessToken,
refreshToken = loginResponse.refreshToken,
),
)
authDiskSource.userState = userStateJson
authDiskSource.storeUserKey(
userId = userStateJson.activeUserId,
@ -548,16 +567,20 @@ class AuthRepositoryImpl(
override fun refreshAccessTokenSynchronously(userId: String): Result<RefreshTokenResponseJson> {
val refreshToken = authDiskSource
.userState
?.accounts
?.get(userId)
?.tokens
.getAccountTokens(userId = userId)
?.refreshToken
?: return IllegalStateException("Must be logged in.").asFailure()
return identityService
.refreshTokenSynchronously(refreshToken)
.onSuccess {
// Update the existing UserState with updated token information
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = it.accessToken,
refreshToken = it.refreshToken,
),
)
authDiskSource.userState = it.toUserStateJson(
userId = userId,
previousUserState = requireNotNull(authDiskSource.userState),
@ -978,6 +1001,10 @@ class AuthRepositoryImpl(
userId: String,
): Boolean = authDiskSource.getUserBiometricUnlockKey(userId = userId) != null
private fun isUserLoggedIn(
userId: String,
): Boolean = authDiskSource.getAccountTokens(userId = userId)?.isLoggedIn == true
private fun getVaultUnlockType(
userId: String,
): VaultUnlockType =

View file

@ -61,10 +61,7 @@ val AuthDiskSource.userOrganizationsListFlow: Flow<List<UserOrganizations>>
val AuthDiskSource.userSwitchingChangesFlow: Flow<UserSwitchingData>
get() {
var lastActiveUserId: String? = null
return this
.userStateFlow
.map { it?.activeUserId }
.distinctUntilChanged()
return activeUserIdChangesFlow
.map { activeUserId ->
val previousActiveUserId = lastActiveUserId
lastActiveUserId = activeUserId
@ -74,3 +71,12 @@ val AuthDiskSource.userSwitchingChangesFlow: Flow<UserSwitchingData>
)
}
}
/**
* Returns a [Flow] that emits every time the active user ID is changed.
*/
val AuthDiskSource.activeUserIdChangesFlow: Flow<String?>
get() = this
.userStateFlow
.map { it?.activeUserId }
.distinctUntilChanged()

View file

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@ -19,9 +18,7 @@ fun GetTokenResponseJson.Success.toUserState(
environmentUrlData: EnvironmentUrlDataJson,
): UserStateJson {
val accessToken = this.accessToken
@Suppress("UnsafeCallOnNullableType")
val jwtTokenData = parseJwtTokenDataOrNull(jwtToken = accessToken)!!
val jwtTokenData = requireNotNull(parseJwtTokenDataOrNull(jwtToken = accessToken))
val userId = jwtTokenData.userId
val account = AccountJson(
@ -45,10 +42,6 @@ fun GetTokenResponseJson.Success.toUserState(
kdfParallelism = this.kdfParallelism,
userDecryptionOptions = this.userDecryptionOptions,
),
tokens = AccountTokensJson(
accessToken = accessToken,
refreshToken = this.refreshToken,
),
settings = AccountJson.Settings(
environmentUrlData = environmentUrlData,
),

View file

@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.auth.repository.util
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.auth.datasource.network.model.RefreshTokenResponseJson
@ -27,10 +26,6 @@ fun RefreshTokenResponseJson.toUserStateJson(
name = jwtTokenData.name,
hasPremium = jwtTokenData.hasPremium,
),
tokens = AccountTokensJson(
accessToken = accessToken,
refreshToken = this.refreshToken,
),
)
// Update the existing UserState.

View file

@ -43,12 +43,14 @@ fun UserStateJson.toUpdatedUserStateJson(
/**
* Converts the given [UserStateJson] to a [UserState] using the given [vaultState].
*/
@Suppress("LongParameterList")
fun UserStateJson.toUserState(
vaultState: List<VaultUnlockData>,
userOrganizationsList: List<UserOrganizations>,
hasPendingAccountAddition: Boolean,
isBiometricsEnabledProvider: (userId: String) -> Boolean,
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
isLoggedInProvider: (userId: String) -> Boolean,
): UserState =
UserState(
activeUserId = this.activeUserId,
@ -71,7 +73,7 @@ fun UserStateJson.toUserState(
.environmentUrlData
.toEnvironmentUrlsOrDefault(),
isPremium = accountJson.profile.hasPremium == true,
isLoggedIn = accountJson.isLoggedIn,
isLoggedIn = isLoggedInProvider(userId),
isVaultUnlocked = vaultUnlocked && !needsPasswordReset,
needsPasswordReset = needsPasswordReset,
organizations = userOrganizationsList

View file

@ -100,8 +100,6 @@ class PushManagerImpl @Inject constructor(
private val activeUserId: String?
get() = authDiskSource.userState?.activeUserId
private val isLoggedIn: Boolean
get() = authDiskSource.userState?.activeAccount?.isLoggedIn == true
init {
authDiskSource
@ -153,7 +151,7 @@ class PushManagerImpl @Inject constructor(
-> {
val payload: NotificationPayload.SyncCipherNotification =
json.decodeFromJsonElement(notification.payload)
if (!isLoggedIn || !payload.userMatchesNotification(userId)) return
if (!isLoggedIn(userId) || !payload.userMatchesNotification(userId)) return
mutableSyncCipherUpsertSharedFlow.tryEmit(
SyncCipherUpsertData(
cipherId = payload.id,
@ -170,7 +168,7 @@ class PushManagerImpl @Inject constructor(
-> {
val payload: NotificationPayload.SyncCipherNotification =
json.decodeFromJsonElement(notification.payload)
if (!isLoggedIn || !payload.userMatchesNotification(userId)) return
if (!isLoggedIn(userId) || !payload.userMatchesNotification(userId)) return
mutableSyncCipherDeleteSharedFlow.tryEmit(
SyncCipherDeleteData(payload.id),
)
@ -188,7 +186,7 @@ class PushManagerImpl @Inject constructor(
-> {
val payload: NotificationPayload.SyncFolderNotification =
json.decodeFromJsonElement(notification.payload)
if (!isLoggedIn || !payload.userMatchesNotification(userId)) return
if (!isLoggedIn(userId) || !payload.userMatchesNotification(userId)) return
mutableSyncFolderUpsertSharedFlow.tryEmit(
SyncFolderUpsertData(
folderId = payload.id,
@ -201,7 +199,7 @@ class PushManagerImpl @Inject constructor(
NotificationType.SYNC_FOLDER_DELETE -> {
val payload: NotificationPayload.SyncFolderNotification =
json.decodeFromJsonElement(notification.payload)
if (!isLoggedIn || !payload.userMatchesNotification(userId)) return
if (!isLoggedIn(userId) || !payload.userMatchesNotification(userId)) return
mutableSyncFolderDeleteSharedFlow.tryEmit(
SyncFolderDeleteData(payload.id),
@ -209,7 +207,7 @@ class PushManagerImpl @Inject constructor(
}
NotificationType.SYNC_ORG_KEYS -> {
if (!isLoggedIn) return
if (!isLoggedIn(userId)) return
mutableSyncOrgKeysSharedFlow.tryEmit(Unit)
}
@ -218,7 +216,7 @@ class PushManagerImpl @Inject constructor(
-> {
val payload: NotificationPayload.SyncSendNotification =
json.decodeFromJsonElement(notification.payload)
if (!isLoggedIn || !payload.userMatchesNotification(userId)) return
if (!isLoggedIn(userId) || !payload.userMatchesNotification(userId)) return
mutableSyncSendUpsertSharedFlow.tryEmit(
SyncSendUpsertData(
sendId = payload.id,
@ -231,7 +229,7 @@ class PushManagerImpl @Inject constructor(
NotificationType.SYNC_SEND_DELETE -> {
val payload: NotificationPayload.SyncSendNotification =
json.decodeFromJsonElement(notification.payload)
if (!isLoggedIn || !payload.userMatchesNotification(userId)) return
if (!isLoggedIn(userId) || !payload.userMatchesNotification(userId)) return
mutableSyncSendDeleteSharedFlow.tryEmit(
SyncSendDeleteData(payload.id),
)
@ -242,8 +240,8 @@ class PushManagerImpl @Inject constructor(
override fun registerPushTokenIfNecessary(token: String) {
pushDiskSource.registeredPushToken = token
if (!isLoggedIn) return
val userId = activeUserId ?: return
if (!isLoggedIn(userId)) return
ioScope.launch {
registerPushTokenIfNecessaryInternal(
userId = userId,
@ -254,8 +252,8 @@ class PushManagerImpl @Inject constructor(
@Suppress("ReturnCount")
override fun registerStoredPushTokenIfNecessary() {
if (!isLoggedIn) return
val userId = activeUserId ?: return
if (!isLoggedIn(userId)) return
// If the last registered token is from less than a day before, skip this for now
val lastRegistration = pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toInstant()
@ -305,6 +303,10 @@ class PushManagerImpl @Inject constructor(
},
)
}
private fun isLoggedIn(
userId: String,
): Boolean = authDiskSource.getAccountTokens(userId)?.isLoggedIn == true
}
private fun NotificationPayload.userMatchesNotification(userId: String?): Boolean {

View file

@ -20,6 +20,7 @@ import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.encodeToJsonElement
@ -48,7 +49,7 @@ class AuthDiskSourceTest {
@Test
fun `initialization should kick off a legacy migration if necessary`() {
every { legacySecureStorageMigrator.migrateIfNecessary() }
verify(exactly = 1) { legacySecureStorageMigrator.migrateIfNecessary() }
}
@Test
@ -150,6 +151,9 @@ class AuthDiskSourceTest {
assertNull(authDiskSource.userState)
assertNull(awaitItem())
// Extra emission from migration logic
assertNull(awaitItem())
// Updating the repository updates shared preferences
authDiskSource.userState = USER_STATE
assertEquals(USER_STATE, awaitItem())

View file

@ -32,6 +32,7 @@ import org.junit.jupiter.api.extension.ExtendWith
@ExtendWith(MainDispatcherExtension::class)
class UserLogoutManagerTest {
private val authDiskSource: AuthDiskSource = mockk {
every { storeAccountTokens(userId = any(), accountTokens = null) } just runs
every { userState = any() } just runs
every { clearData(any()) } just runs
}
@ -121,11 +122,10 @@ class UserLogoutManagerTest {
@Suppress("MaxLineLength")
@Test
fun `softLogout should clear most data associated with the given user and remove token data from the account in the user state`() {
fun `softLogout should clear most data associated with the given user and remove token data in the authDiskSource`() {
val userId = USER_ID_1
val vaultTimeoutInMinutes = 360
val vaultTimeoutAction = VaultTimeoutAction.LOCK
every { authDiskSource.userState } returns SINGLE_USER_STATE_1
every {
settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)
} returns vaultTimeoutInMinutes
@ -135,22 +135,7 @@ class UserLogoutManagerTest {
userLogoutManager.softLogout(userId = userId)
val updatedAccount = ACCOUNT_1
.copy(
tokens = AccountTokensJson(
accessToken = null,
refreshToken = null,
),
)
val updatedUserState = SINGLE_USER_STATE_1
.copy(
accounts = SINGLE_USER_STATE_1
.accounts
.toMutableMap().apply {
set(userId, updatedAccount)
},
)
verify { authDiskSource.userState = updatedUserState }
verify { authDiskSource.storeAccountTokens(userId = USER_ID_1, accountTokens = null) }
assertDataCleared(userId = userId)
verify {

View file

@ -238,46 +238,49 @@ class AuthRepositoryTest {
}
@Test
fun `authStateFlow should react to user state changes`() {
assertEquals(
AuthState.Unauthenticated,
repository.authStateFlow.value,
)
fun `authStateFlow should react to user state changes and account token changes`() = runTest {
repository.authStateFlow.test {
assertEquals(AuthState.Unauthenticated, awaitItem())
// Update the active user updates the state
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
assertEquals(
AuthState.Authenticated(ACCESS_TOKEN),
repository.authStateFlow.value,
)
// Store the tokens, nothing happens yet since there is technically no active user yet
fakeAuthDiskSource.storeAccountTokens(
userId = USER_ID_1,
accountTokens = ACCOUNT_TOKENS_1,
)
expectNoEvents()
// Update the active user, we are now authenticated
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), awaitItem())
// Updating the non-active user does not update the state
fakeAuthDiskSource.userState = MULTI_USER_STATE
assertEquals(
AuthState.Authenticated(ACCESS_TOKEN),
repository.authStateFlow.value,
)
// Adding a tokens for the non-active user does not update the state
fakeAuthDiskSource.storeAccountTokens(
userId = USER_ID_2,
accountTokens = ACCOUNT_TOKENS_2,
)
expectNoEvents()
// Adding a non-active user does not update the state
fakeAuthDiskSource.userState = MULTI_USER_STATE
expectNoEvents()
// Clearing the tokens of the active state results in the Unauthenticated state
val updatedAccount = ACCOUNT_1.copy(
tokens = AccountTokensJson(
accessToken = null,
refreshToken = null,
),
)
val updatedState = MULTI_USER_STATE.copy(
accounts = MULTI_USER_STATE
.accounts
.toMutableMap()
.apply {
set(USER_ID_1, updatedAccount)
},
)
fakeAuthDiskSource.userState = updatedState
assertEquals(
AuthState.Unauthenticated,
repository.authStateFlow.value,
)
// Changing the active users tokens causes an update
val newAccessToken = "new_access_token"
fakeAuthDiskSource.storeAccountTokens(
userId = USER_ID_1,
accountTokens = ACCOUNT_TOKENS_1.copy(accessToken = newAccessToken),
)
assertEquals(AuthState.Authenticated(newAccessToken), awaitItem())
// Change the active user causes an update
fakeAuthDiskSource.userState = MULTI_USER_STATE.copy(activeUserId = USER_ID_2)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN_2), awaitItem())
// Clearing the tokens of the active state results in the Unauthenticated state
fakeAuthDiskSource.storeAccountTokens(
userId = USER_ID_2,
accountTokens = null,
)
assertEquals(AuthState.Unauthenticated, awaitItem())
}
}
@Test
@ -297,6 +300,7 @@ class AuthRepositoryTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isLoggedInProvider = { false },
),
repository.userStateFlow.value,
)
@ -319,6 +323,7 @@ class AuthRepositoryTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
isLoggedInProvider = { false },
),
repository.userStateFlow.value,
)
@ -332,6 +337,7 @@ class AuthRepositoryTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
isLoggedInProvider = { false },
),
repository.userStateFlow.value,
)
@ -357,6 +363,7 @@ class AuthRepositoryTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isLoggedInProvider = { false },
),
repository.userStateFlow.value,
)
@ -535,6 +542,7 @@ class AuthRepositoryTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isLoggedInProvider = { false },
)
val finalUserState = SINGLE_USER_STATE_2.toUserState(
vaultState = VAULT_UNLOCK_DATA,
@ -542,6 +550,7 @@ class AuthRepositoryTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isLoggedInProvider = { false },
)
val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams()
coEvery {
@ -648,7 +657,10 @@ class AuthRepositoryTest {
@Test
fun `refreshTokenSynchronously returns failure and logs out on failure`() = runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
fakeAuthDiskSource.storeAccountTokens(
userId = USER_ID_1,
accountTokens = ACCOUNT_TOKENS_1,
)
coEvery {
identityService.refreshTokenSynchronously(REFRESH_TOKEN)
} returns Throwable("Fail").asFailure()
@ -662,6 +674,10 @@ class AuthRepositoryTest {
@Test
fun `refreshTokenSynchronously returns success and update user state on success`() = runTest {
fakeAuthDiskSource.storeAccountTokens(
userId = USER_ID_1,
accountTokens = ACCOUNT_TOKENS_1,
)
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
coEvery {
identityService.refreshTokenSynchronously(REFRESH_TOKEN)
@ -2674,6 +2690,7 @@ class AuthRepositoryTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isLoggedInProvider = { false },
)
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
assertEquals(
@ -2704,6 +2721,7 @@ class AuthRepositoryTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isLoggedInProvider = { false },
)
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
assertEquals(
@ -2732,6 +2750,7 @@ class AuthRepositoryTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isLoggedInProvider = { false },
)
fakeAuthDiskSource.userState = MULTI_USER_STATE
assertEquals(
@ -3043,6 +3062,7 @@ class AuthRepositoryTest {
@Test
fun `syncOrgKeysFlow emissions should refresh access token and sync`() {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
fakeAuthDiskSource.storeAccountTokens(userId = USER_ID_1, accountTokens = ACCOUNT_TOKENS_1)
coEvery {
identityService.refreshTokenSynchronously(REFRESH_TOKEN)
} returns REFRESH_TOKEN_RESPONSE_JSON.asSuccess()
@ -3210,10 +3230,6 @@ class AuthRepositoryTest {
kdfParallelism = 4,
userDecryptionOptions = null,
),
tokens = AccountTokensJson(
accessToken = ACCESS_TOKEN,
refreshToken = REFRESH_TOKEN,
),
settings = AccountJson.Settings(
environmentUrlData = null,
),
@ -3235,10 +3251,6 @@ class AuthRepositoryTest {
kdfParallelism = null,
userDecryptionOptions = null,
),
tokens = AccountTokensJson(
accessToken = ACCESS_TOKEN_2,
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = null,
),
@ -3262,6 +3274,14 @@ class AuthRepositoryTest {
USER_ID_2 to ACCOUNT_2,
),
)
private val ACCOUNT_TOKENS_1: AccountTokensJson = AccountTokensJson(
accessToken = ACCESS_TOKEN,
refreshToken = REFRESH_TOKEN,
)
private val ACCOUNT_TOKENS_2: AccountTokensJson = AccountTokensJson(
accessToken = ACCESS_TOKEN_2,
refreshToken = "refreshToken",
)
private val USER_ORGANIZATIONS = listOf(
UserOrganizations(
userId = USER_ID_1,

View file

@ -14,6 +14,7 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class AuthDiskSourceExtensionsTest {
@ -192,6 +193,24 @@ class AuthDiskSourceExtensionsTest {
)
}
}
@Test
fun `activeUserIdChangesFlow should emit changes when active user changes`() = runTest {
authDiskSource.activeUserIdChangesFlow.test {
assertNull(awaitItem())
authDiskSource.userState = MOCK_USER_STATE
assertEquals(MOCK_USER_ID, awaitItem())
authDiskSource.userState = MOCK_USER_STATE.copy(
accounts = mapOf(
MOCK_USER_ID to MOCK_ACCOUNT,
"mockId-2" to mockk(),
),
)
expectNoEvents()
authDiskSource.userState = null
assertNull(awaitItem())
}
}
}
private const val MOCK_USER_ID: String = "mockId-1"

View file

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
@ -55,7 +54,6 @@ class GetTokenResponseExtensionsTest {
}
private const val ACCESS_TOKEN_1 = "accessToken1"
private const val ACCESS_TOKEN_2 = "accessToken2"
private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181"
private const val USER_ID_2 = "b9d32ec0-6497-4582-9798-b350f53bfa02"
@ -103,10 +101,6 @@ private val ACCOUNT_1 = AccountJson(
kdfParallelism = 4,
userDecryptionOptions = null,
),
tokens = AccountTokensJson(
accessToken = ACCESS_TOKEN_1,
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
),
@ -128,10 +122,6 @@ private val ACCOUNT_2 = AccountJson(
kdfParallelism = null,
userDecryptionOptions = null,
),
tokens = AccountTokensJson(
accessToken = ACCESS_TOKEN_2,
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
),

View file

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
@ -54,9 +53,7 @@ class RefreshTokenResponseJsonTest {
}
}
private const val ACCESS_TOKEN = "accessToken"
private const val ACCESS_TOKEN_UPDATED = "updatedAccessToken"
private const val REFRESH_TOKEN = "refreshToken"
private const val REFRESH_TOKEN_UPDATED = "updatedRefreshToken"
private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181"
private const val USER_ID_2 = "b9d32ec0-6497-4582-9798-b350f53bfa02"
@ -95,10 +92,6 @@ private val ACCOUNT_1 = AccountJson(
kdfParallelism = 4,
userDecryptionOptions = null,
),
tokens = AccountTokensJson(
accessToken = ACCESS_TOKEN,
refreshToken = REFRESH_TOKEN,
),
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
),
@ -112,10 +105,6 @@ private val ACCOUNT_1_UPDATED = ACCOUNT_1.copy(
name = JWT_TOKEN_DATA.name,
hasPremium = JWT_TOKEN_DATA.hasPremium,
),
tokens = AccountTokensJson(
accessToken = ACCESS_TOKEN_UPDATED,
refreshToken = REFRESH_TOKEN_UPDATED,
),
)
private val ACCOUNT_2 = AccountJson(
@ -135,10 +124,6 @@ private val ACCOUNT_2 = AccountJson(
kdfParallelism = null,
userDecryptionOptions = null,
),
tokens = AccountTokensJson(
accessToken = "accessToken2",
refreshToken = "refreshToken2",
),
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
),

View file

@ -163,6 +163,7 @@ class UserStateJsonExtensionsTest {
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
isLoggedInProvider = { true },
),
)
}
@ -234,6 +235,7 @@ class UserStateJsonExtensionsTest {
hasPendingAccountAddition = true,
isBiometricsEnabledProvider = { true },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isLoggedInProvider = { false },
),
)
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.manager
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
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.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
@ -27,7 +28,6 @@ import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
@ -78,10 +78,12 @@ class PushManagerTest {
@BeforeEach
fun setup() {
val account = mockk<AccountJson> {
every { isLoggedIn } returns false
}
authDiskSource.userState = UserStateJson(userId, mapOf(userId to account))
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
authDiskSource.storeAccountTokens(userId, accountTokens)
authDiskSource.userState = UserStateJson(userId, mapOf(userId to mockk<AccountJson>()))
}
@Test
@ -159,10 +161,12 @@ class PushManagerTest {
@Test
fun `onMessageReceived logout should emit to logoutFlow`() = runTest {
val account = mockk<AccountJson> {
every { isLoggedIn } returns true
}
authDiskSource.userState = UserStateJson(userId, mapOf(userId to account))
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
authDiskSource.storeAccountTokens(userId, accountTokens)
authDiskSource.userState = UserStateJson(userId, mapOf(userId to mockk<AccountJson>()))
pushManager.logoutFlow.test {
pushManager.onMessageReceived(LOGOUT_NOTIFICATION_JSON)
@ -178,10 +182,9 @@ class PushManagerTest {
@BeforeEach
fun setUp() {
val userId = "any user ID"
val account = mockk<AccountJson> {
every { isLoggedIn } returns false
}
authDiskSource.userState = UserStateJson(userId, mapOf(userId to account))
authDiskSource.storeAccountTokens(userId, null)
authDiskSource.userState =
UserStateJson(userId, mapOf(userId to mockk<AccountJson>()))
}
@Test
@ -242,9 +245,12 @@ class PushManagerTest {
@BeforeEach
fun setUp() {
val userId = "078966a2-93c2-4618-ae2a-0a2394c88d37"
val account = mockk<AccountJson> {
every { isLoggedIn } returns true
}
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
authDiskSource.storeAccountTokens(userId, accountTokens)
val account = mockk<AccountJson>()
authDiskSource.userState = UserStateJson(userId, mapOf(userId to account))
}
@ -410,9 +416,12 @@ class PushManagerTest {
@BeforeEach
fun setUp() {
val userId = "bad user ID"
val account = mockk<AccountJson> {
every { isLoggedIn } returns true
}
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
authDiskSource.storeAccountTokens(userId, accountTokens)
val account = mockk<AccountJson>()
authDiskSource.userState = UserStateJson(userId, mapOf(userId to account))
}
@ -550,9 +559,12 @@ class PushManagerTest {
@BeforeEach
fun setUp() {
val userId = "any user ID"
val account = mockk<AccountJson> {
every { isLoggedIn } returns true
}
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
authDiskSource.storeAccountTokens(userId, accountTokens)
val account = mockk<AccountJson>()
authDiskSource.userState = UserStateJson(userId, mapOf(userId to account))
}
@ -620,9 +632,8 @@ class PushManagerTest {
@BeforeEach
fun setUp() {
val userId = "any user ID"
val account = mockk<AccountJson> {
every { isLoggedIn } returns false
}
authDiskSource.storeAccountTokens(userId = userId, accountTokens = null)
val account = mockk<AccountJson>()
authDiskSource.userState = UserStateJson(userId, mapOf(userId to account))
}
@ -677,9 +688,12 @@ class PushManagerTest {
@BeforeEach
fun setUp() {
pushDiskSource.storeCurrentPushToken(userId, existingToken)
val account = mockk<AccountJson> {
every { isLoggedIn } returns true
}
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
authDiskSource.storeAccountTokens(userId, accountTokens)
val account = mockk<AccountJson>()
authDiskSource.userState = UserStateJson(userId, mapOf(userId to account))
}