BIT-765: Add access token storage (#138)

This commit is contained in:
Brian Yencho
2023-10-20 14:46:01 -05:00
committed by GitHub
parent 26d2687dba
commit 3ec772447b
15 changed files with 963 additions and 43 deletions

View File

@@ -1,6 +1,18 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
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
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@@ -8,8 +20,15 @@ import org.junit.jupiter.api.Test
class AuthDiskSourceTest {
private val fakeSharedPreferences = FakeSharedPreferences()
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
private val authDiskSource = AuthDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
json = json,
)
@Test
@@ -31,4 +50,145 @@ class AuthDiskSourceTest {
fakeSharedPreferences.edit().putString(rememberedEmailKey, null).apply()
assertNull(authDiskSource.rememberedEmailAddress)
}
@Test
fun `userState should pull from and update SharedPreferences`() {
val userStateKey = "bwPreferencesStorage:state"
// Shared preferences and the repository start with the same value.
assertNull(authDiskSource.userState)
assertNull(fakeSharedPreferences.getString(userStateKey, null))
// Updating the repository updates shared preferences
authDiskSource.userState = USER_STATE
assertEquals(
json.parseToJsonElement(
USER_STATE_JSON,
),
json.parseToJsonElement(
fakeSharedPreferences.getString(userStateKey, null)!!,
),
)
// Update SharedPreferences updates the repository
fakeSharedPreferences.edit().putString(userStateKey, null).apply()
assertNull(authDiskSource.userState)
}
@Test
fun `userStateFlow should react to changes in userState`() = runTest {
authDiskSource.userStateFlow.test {
// The initial values of the Flow and the property are in sync
assertNull(authDiskSource.userState)
assertNull(awaitItem())
// Updating the repository updates shared preferences
authDiskSource.userState = USER_STATE
assertEquals(USER_STATE, awaitItem())
}
}
}
private const val USER_STATE_JSON = """
{
"activeUserId": "activeUserId",
"accounts": {
"activeUserId": {
"profile": {
"userId": "activeUserId",
"email": "email",
"emailVerified": true,
"name": "name",
"stamp": "stamp",
"orgIdentifier": "organizationId",
"avatarColor": "avatarColorHex",
"hasPremiumPersonally": true,
"forcePasswordResetReason": "adminForcePasswordReset",
"kdfType": 1,
"kdfIterations": 600000,
"kdfMemory": 16,
"kdfParallelism": 4,
"accountDecryptionOptions": {
"HasMasterPassword": true,
"TrustedDeviceOption": {
"EncryptedPrivateKey": "encryptedPrivateKey",
"EncryptedUserKey": "encryptedUserKey",
"HasAdminApproval": true,
"HasLoginApprovingDevice": true,
"HasManageResetPasswordPermission": true
},
"KeyConnectorOption": {
"KeyConnectorUrl": "keyConnectorUrl"
}
}
},
"tokens": {
"accessToken": "accessToken",
"refreshToken": "refreshToken"
},
"settings": {
"environmentUrls": {
"base": "base",
"api": "api",
"identity": "identity",
"icon": "icon",
"notifications": "notifications",
"webVault": "webVault",
"events": "events"
}
}
}
}
}
"""
private val USER_STATE = UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to AccountJson(
profile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = "stamp",
organizationId = "organizationId",
avatarColorHex = "avatarColorHex",
hasPremium = true,
forcePasswordResetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasManageResetPasswordPermission = true,
),
keyConnectorUserDecryptionOptions = KeyConnectorUserDecryptionOptionsJson(
keyConnectorUrl = "keyConnectorUrl",
),
),
),
tokens = AccountJson.Tokens(
accessToken = "accessToken",
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson(
base = "base",
api = "api",
identity = "identity",
icon = "icon",
notifications = "notifications",
webVault = "webVault",
events = "events",
),
),
),
),
)

View File

@@ -5,7 +5,10 @@ import com.bitwarden.core.Kdf
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RsaKeyPair
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.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.PBKDF2_SHA256
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
@@ -17,6 +20,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import io.mockk.clearMocks
@@ -24,8 +28,15 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
@@ -35,7 +46,7 @@ class AuthRepositoryTest {
private val accountsService: AccountsService = mockk()
private val identityService: IdentityService = mockk()
private val authInterceptor = mockk<AuthTokenInterceptor>()
private val authInterceptor = AuthTokenInterceptor()
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val authSdkSource = mockk<AuthSdkSource> {
coEvery {
@@ -63,17 +74,25 @@ class AuthRepositoryTest {
)
}
@OptIn(ExperimentalCoroutinesApi::class)
private val repository = AuthRepositoryImpl(
accountsService = accountsService,
identityService = identityService,
authSdkSource = authSdkSource,
authDiskSource = fakeAuthDiskSource,
authTokenInterceptor = authInterceptor,
dispatcher = UnconfinedTestDispatcher(),
)
@BeforeEach
fun beforeEach() {
clearMocks(identityService, accountsService, authInterceptor)
clearMocks(identityService, accountsService)
mockkStatic(GET_TOKEN_RESPONSE_EXTENSIONS_PATH)
}
@AfterEach
fun tearDown() {
unmockkStatic(GET_TOKEN_RESPONSE_EXTENSIONS_PATH)
}
@Test
@@ -162,9 +181,7 @@ class AuthRepositoryTest {
@Test
fun `login get token succeeds should return Success and update AuthState`() = runTest {
val successResponse = mockk<GetTokenResponseJson.Success> {
every { accessToken } returns ACCESS_TOKEN
}
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
@@ -176,11 +193,13 @@ class AuthRepositoryTest {
)
}
.returns(Result.success(successResponse))
every { authInterceptor.authToken = ACCESS_TOKEN } returns Unit
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(previousUserState = null)
} returns SINGLE_USER_STATE_1
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
verify { authInterceptor.authToken = ACCESS_TOKEN }
assertEquals(ACCESS_TOKEN, authInterceptor.authToken)
coVerify { accountsService.preLogin(email = EMAIL) }
coVerify {
identityService.getToken(
@@ -365,11 +384,9 @@ class AuthRepositoryTest {
}
@Test
fun `logout should change AuthState to be Unauthenticated`() = runTest {
fun `logout for single account should clear the access token`() = runTest {
// First login:
val successResponse = mockk<GetTokenResponseJson.Success> {
every { accessToken } returns ACCESS_TOKEN
}
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
@@ -379,36 +396,189 @@ class AuthRepositoryTest {
passwordHash = PASSWORD_HASH,
captchaToken = null,
)
}
.returns(Result.success(successResponse))
} returns Result.success(successResponse)
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(previousUserState = null)
} returns SINGLE_USER_STATE_1
every { authInterceptor.authToken = ACCESS_TOKEN } returns Unit
repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
assertEquals(ACCESS_TOKEN, authInterceptor.authToken)
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
// Then call logout:
repository.authStateFlow.test {
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), awaitItem())
repository.logout()
assertEquals(AuthState.Unauthenticated, awaitItem())
assertNull(authInterceptor.authToken)
assertNull(fakeAuthDiskSource.userState)
}
}
@Test
fun `logout for multiple accounts should update current access token`() = runTest {
// First populate multiple user accounts
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
// Then login:
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
coEvery {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
)
} returns Result.success(successResponse)
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(previousUserState = SINGLE_USER_STATE_2)
} returns MULTI_USER_STATE
repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
assertEquals(ACCESS_TOKEN, authInterceptor.authToken)
assertEquals(MULTI_USER_STATE, fakeAuthDiskSource.userState)
// Then call logout:
repository.authStateFlow.test {
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), awaitItem())
repository.logout()
assertEquals(AuthState.Authenticated(ACCESS_TOKEN_2), awaitItem())
assertEquals(ACCESS_TOKEN_2, authInterceptor.authToken)
assertEquals(SINGLE_USER_STATE_2, fakeAuthDiskSource.userState)
}
}
companion object {
private const val EMAIL = "test@test.com"
private const val GET_TOKEN_RESPONSE_EXTENSIONS_PATH =
"com.x8bit.bitwarden.data.auth.repository.util.GetTokenResponseExtensionsKt"
private const val EMAIL = "test@bitwarden.com"
private const val PASSWORD = "password"
private const val PASSWORD_HASH = "passwordHash"
private const val ACCESS_TOKEN = "accessToken"
private const val ACCESS_TOKEN_2 = "accessToken2"
private const val CAPTCHA_KEY = "captcha"
private const val DEFAULT_KDF_ITERATIONS = 600000
private const val ENCRYPTED_USER_KEY = "encryptedUserKey"
private const val PUBLIC_KEY = "PublicKey"
private const val PRIVATE_KEY = "privateKey"
private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181"
private const val USER_ID_2 = "b9d32ec0-6497-4582-9798-b350f53bfa02"
private val PRE_LOGIN_SUCCESS = PreLoginResponseJson(
kdfParams = PreLoginResponseJson.KdfParams.Pbkdf2(iterations = 1u),
)
private val GET_TOKEN_RESPONSE_SUCCESS = GetTokenResponseJson.Success(
accessToken = ACCESS_TOKEN,
refreshToken = "refreshToken",
tokenType = "Bearer",
expiresInSeconds = 3600,
key = "key",
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
privateKey = "privateKey",
shouldForcePasswordReset = true,
shouldResetMasterPassword = true,
masterPasswordPolicyOptions = null,
userDecryptionOptions = null,
)
private val ACCOUNT_1 = AccountJson(
profile = AccountJson.Profile(
userId = USER_ID_1,
email = "test@bitwarden.com",
isEmailVerified = true,
name = "Bitwarden Tester",
hasPremium = false,
stamp = null,
organizationId = null,
avatarColorHex = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = null,
),
tokens = AccountJson.Tokens(
accessToken = ACCESS_TOKEN,
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = null,
),
)
private val ACCOUNT_2 = AccountJson(
profile = AccountJson.Profile(
userId = USER_ID_2,
email = "test2@bitwarden.com",
isEmailVerified = true,
name = "Bitwarden Tester 2",
hasPremium = false,
stamp = null,
organizationId = null,
avatarColorHex = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.PBKDF2_SHA256,
kdfIterations = 400000,
kdfMemory = null,
kdfParallelism = null,
userDecryptionOptions = null,
),
tokens = AccountJson.Tokens(
accessToken = ACCESS_TOKEN_2,
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = null,
),
)
private val SINGLE_USER_STATE_1 = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1,
),
)
private val SINGLE_USER_STATE_2 = UserStateJson(
activeUserId = USER_ID_2,
accounts = mapOf(
USER_ID_2 to ACCOUNT_2,
),
)
private val MULTI_USER_STATE = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1,
USER_ID_2 to ACCOUNT_2,
),
)
}
}
private class FakeAuthDiskSource : AuthDiskSource {
override var rememberedEmailAddress: String? = null
override var userState: UserStateJson? = null
set(value) {
field = value
mutableUserStateFlow.tryEmit(value)
}
override val userStateFlow: Flow<UserStateJson?>
get() = mutableUserStateFlow.onSubscription { emit(userState) }
private val mutableUserStateFlow =
MutableSharedFlow<UserStateJson?>(
replay = 1,
extraBufferCapacity = Int.MAX_VALUE,
)
}

View File

@@ -0,0 +1,151 @@
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.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.repository.model.JwtTokenDataJson
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class GetTokenResponseExtensionsTest {
@BeforeEach
fun beforeEach() {
mockkStatic(JWT_TOKEN_UTILS_PATH)
}
@AfterEach
fun tearDown() {
unmockkStatic(JWT_TOKEN_UTILS_PATH)
}
@Test
fun `toUserState with a null previous state creates a new single user state`() {
every { parseJwtTokenDataOrNull(ACCESS_TOKEN_1) } returns JWT_TOKEN_DATA
assertEquals(
SINGLE_USER_STATE_1,
GET_TOKEN_RESPONSE_SUCCESS.toUserState(previousUserState = null),
)
}
@Test
fun `toUserState with a non-null previous state updates the previous state`() {
every { parseJwtTokenDataOrNull(ACCESS_TOKEN_1) } returns JWT_TOKEN_DATA
assertEquals(
MULTI_USER_STATE,
GET_TOKEN_RESPONSE_SUCCESS.toUserState(previousUserState = SINGLE_USER_STATE_2),
)
}
}
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"
private const val JWT_TOKEN_UTILS_PATH =
"com.x8bit.bitwarden.data.auth.repository.util.JwtTokenUtilsKt"
private val JWT_TOKEN_DATA = JwtTokenDataJson(
userId = "2a135b23-e1fb-42c9-bec3-573857bc8181",
email = "test@bitwarden.com",
isEmailVerified = true,
name = "Bitwarden Tester",
expirationAsEpochTime = 1697495714,
hasPremium = false,
authenticationMethodsReference = listOf("Application"),
)
private val GET_TOKEN_RESPONSE_SUCCESS = GetTokenResponseJson.Success(
accessToken = ACCESS_TOKEN_1,
refreshToken = "refreshToken",
tokenType = "Bearer",
expiresInSeconds = 3600,
key = "key",
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
privateKey = "privateKey",
shouldForcePasswordReset = true,
shouldResetMasterPassword = true,
masterPasswordPolicyOptions = null,
userDecryptionOptions = null,
)
private val ACCOUNT_1 = AccountJson(
profile = AccountJson.Profile(
userId = USER_ID_1,
email = "test@bitwarden.com",
isEmailVerified = true,
name = "Bitwarden Tester",
hasPremium = false,
stamp = null,
organizationId = null,
avatarColorHex = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = null,
),
tokens = AccountJson.Tokens(
accessToken = ACCESS_TOKEN_1,
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = null,
),
)
private val ACCOUNT_2 = AccountJson(
profile = AccountJson.Profile(
userId = USER_ID_2,
email = "test2@bitwarden.com",
isEmailVerified = true,
name = "Bitwarden Tester 2",
hasPremium = false,
stamp = null,
organizationId = null,
avatarColorHex = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.PBKDF2_SHA256,
kdfIterations = 400000,
kdfMemory = null,
kdfParallelism = null,
userDecryptionOptions = null,
),
tokens = AccountJson.Tokens(
accessToken = ACCESS_TOKEN_2,
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = null,
),
)
private val SINGLE_USER_STATE_1 = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1,
),
)
private val SINGLE_USER_STATE_2 = UserStateJson(
activeUserId = USER_ID_2,
accounts = mapOf(
USER_ID_2 to ACCOUNT_2,
),
)
private val MULTI_USER_STATE = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1,
USER_ID_2 to ACCOUNT_2,
),
)

View File

@@ -7,6 +7,7 @@ import android.content.SharedPreferences
*/
class FakeSharedPreferences : SharedPreferences {
private val sharedPreferences: MutableMap<String, Any?> = mutableMapOf()
private val listeners = mutableSetOf<SharedPreferences.OnSharedPreferenceChangeListener>()
override fun contains(key: String): Boolean =
sharedPreferences.containsKey(key)
@@ -36,17 +37,13 @@ class FakeSharedPreferences : SharedPreferences {
override fun registerOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener,
) {
throw NotImplementedError(
"registerOnSharedPreferenceChangeListener is not currently implemented.",
)
listeners += listener
}
override fun unregisterOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener,
) {
throw NotImplementedError(
"unregisterOnSharedPreferenceChangeListener is not currently implemented.",
)
listeners -= listener
}
private inline fun <reified T> getValue(
@@ -61,6 +58,13 @@ class FakeSharedPreferences : SharedPreferences {
sharedPreferences.apply {
clear()
putAll(pendingSharedPreferences)
// Notify listeners
listeners.forEach { listener ->
pendingSharedPreferences.keys.forEach { key ->
listener.onSharedPreferenceChanged(this@FakeSharedPreferences, key)
}
}
}
}