[PM-20549] Introduce BitwardenServiceClient (#5091)

This commit is contained in:
Patrick Honkonen 2025-04-25 16:26:01 -04:00 committed by GitHub
parent cd11164544
commit 0f6d15d6a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 814 additions and 670 deletions

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.provider.AppIdProvider
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
@ -12,7 +13,7 @@ import java.time.Instant
* Primary access point for disk information.
*/
@Suppress("TooManyFunctions")
interface AuthDiskSource {
interface AuthDiskSource : AppIdProvider {
/**
* The currently persisted authenticator sync symmetric key. This key is used for
@ -20,13 +21,6 @@ interface AuthDiskSource {
*/
var authenticatorSyncSymmetricKey: ByteArray?
/**
* Retrieves a unique ID for the application that is stored locally. This will generate a new
* one if it does not yet exist and it will only be reset for new installs or when clearing
* application data.
*/
val uniqueAppId: String
/**
* The currently persisted saved email address (or `null` if not set).
*/

View File

@ -1,26 +1,17 @@
package com.x8bit.bitwarden.data.auth.datasource.network.di
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.AccountsService
import com.bitwarden.network.service.AccountsServiceImpl
import com.bitwarden.network.service.AuthRequestsService
import com.bitwarden.network.service.AuthRequestsServiceImpl
import com.bitwarden.network.service.DevicesService
import com.bitwarden.network.service.DevicesServiceImpl
import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.HaveIBeenPwnedServiceImpl
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.IdentityServiceImpl
import com.bitwarden.network.service.NewAuthRequestService
import com.bitwarden.network.service.NewAuthRequestServiceImpl
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.service.OrganizationServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import retrofit2.create
import javax.inject.Singleton
/**
@ -33,70 +24,42 @@ object AuthNetworkModule {
@Provides
@Singleton
fun providesAccountService(
retrofits: Retrofits,
json: Json,
): AccountsService = AccountsServiceImpl(
unauthenticatedAccountsApi = retrofits.unauthenticatedApiRetrofit.create(),
authenticatedAccountsApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedKeyConnectorApi = retrofits.createStaticRetrofit().create(),
authenticatedKeyConnectorApi = retrofits
.createStaticRetrofit(isAuthenticated = true)
.create(),
json = json,
)
bitwardenServiceClient: BitwardenServiceClient,
): AccountsService = bitwardenServiceClient.accountsService
@Provides
@Singleton
fun providesAuthRequestsService(
retrofits: Retrofits,
): AuthRequestsService = AuthRequestsServiceImpl(
authenticatedAuthRequestsApi = retrofits.authenticatedApiRetrofit.create(),
)
bitwardenServiceClient: BitwardenServiceClient,
): AuthRequestsService = bitwardenServiceClient.authRequestsService
@Provides
@Singleton
fun providesDevicesService(
retrofits: Retrofits,
): DevicesService = DevicesServiceImpl(
authenticatedDevicesApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedDevicesApi = retrofits.unauthenticatedApiRetrofit.create(),
)
bitwardenServiceClient: BitwardenServiceClient,
): DevicesService = bitwardenServiceClient.devicesService
@Provides
@Singleton
fun providesIdentityService(
retrofits: Retrofits,
json: Json,
): IdentityService = IdentityServiceImpl(
unauthenticatedIdentityApi = retrofits.unauthenticatedIdentityRetrofit.create(),
json = json,
)
bitwardenServiceClient: BitwardenServiceClient,
): IdentityService = bitwardenServiceClient.identityService
@Provides
@Singleton
fun providesHaveIBeenPwnedService(
retrofits: Retrofits,
): HaveIBeenPwnedService = HaveIBeenPwnedServiceImpl(
api = retrofits
.createStaticRetrofit(baseUrl = "https://api.pwnedpasswords.com")
.create(),
)
bitwardenServiceClient: BitwardenServiceClient,
): HaveIBeenPwnedService = bitwardenServiceClient.haveIBeenPwnedService
@Provides
@Singleton
fun providesNewAuthRequestService(
retrofits: Retrofits,
): NewAuthRequestService = NewAuthRequestServiceImpl(
authenticatedAuthRequestsApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedAuthRequestsApi = retrofits.unauthenticatedApiRetrofit.create(),
)
bitwardenServiceClient: BitwardenServiceClient,
): NewAuthRequestService = bitwardenServiceClient.newAuthRequestService
@Provides
@Singleton
fun providesOrganizationService(
retrofits: Retrofits,
): OrganizationService = OrganizationServiceImpl(
authenticatedOrganizationApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedOrganizationApi = retrofits.unauthenticatedApiRetrofit.create(),
)
bitwardenServiceClient: BitwardenServiceClient,
): OrganizationService = bitwardenServiceClient.organizationService
}

View File

@ -402,7 +402,11 @@ class AuthRepositoryImpl(
.syncOrgKeysFlow
.onEach {
val userId = activeUserId ?: return@onEach
refreshAccessTokenSynchronously(userId)
// TODO: [PM-20593] Investigate why tokens are explicitly refreshed.
refreshAccessTokenSynchronouslyInternal(
userId = userId,
logOutOnFailure = false,
)
vaultRepository.sync(forced = true)
}
// This requires the ioScope to ensure that refreshAccessTokenSynchronously
@ -755,33 +759,11 @@ class AuthRepositoryImpl(
orgIdentifier = organizationIdentifier,
)
override fun refreshAccessTokenSynchronously(userId: String): Result<RefreshTokenResponseJson> {
val refreshToken = authDiskSource
.getAccountTokens(userId = userId)
?.refreshToken
?: return IllegalStateException("Must be logged in.").asFailure()
return identityService
.refreshTokenSynchronously(refreshToken)
.flatMap { refreshTokenResponse ->
// Check to make sure the user is still logged in after making the request
authDiskSource
.userState
?.accounts
?.get(userId)
?.let { refreshTokenResponse.asSuccess() }
?: IllegalStateException("Must be logged in.").asFailure()
}
.onSuccess { refreshTokenResponse ->
// Update the existing UserState with updated token information
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = refreshTokenResponse.accessToken,
refreshToken = refreshTokenResponse.refreshToken,
),
)
}
}
override fun refreshAccessTokenSynchronously(userId: String): Result<RefreshTokenResponseJson> =
refreshAccessTokenSynchronouslyInternal(
userId = userId,
logOutOnFailure = true,
)
override fun logout(reason: LogoutReason) {
activeUserId?.let { userId -> logout(userId = userId, reason = reason) }
@ -1433,6 +1415,42 @@ class AuthRepositoryImpl(
onFailure = { LeaveOrganizationResult.Error(error = it) },
)
private fun refreshAccessTokenSynchronouslyInternal(
userId: String,
logOutOnFailure: Boolean,
): Result<RefreshTokenResponseJson> {
val refreshToken = authDiskSource
.getAccountTokens(userId = userId)
?.refreshToken
?: return IllegalStateException("Must be logged in.").asFailure()
return identityService
.refreshTokenSynchronously(refreshToken)
.flatMap { refreshTokenResponse ->
// Check to make sure the user is still logged in after making the request
authDiskSource
.userState
?.accounts
?.get(userId)
?.let { refreshTokenResponse.asSuccess() }
?: IllegalStateException("Must be logged in.").asFailure()
}
.onFailure {
if (logOutOnFailure) {
logout(userId = userId, reason = LogoutReason.TokenRefreshFail)
}
}
.onSuccess { refreshTokenResponse ->
// Update the existing UserState with updated token information
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = refreshTokenResponse.accessToken,
refreshToken = refreshTokenResponse.refreshToken,
),
)
}
}
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,

View File

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.util.parseJwtTokenDataOrNull
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson

View File

@ -1,13 +1,11 @@
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.di
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.network.service.DigitalAssetLinkServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.create
import javax.inject.Singleton
/**
@ -20,11 +18,7 @@ object Fido2NetworkModule {
@Provides
@Singleton
fun provideDigitalAssetLinkService(
retrofits: Retrofits,
bitwardenServiceClient: BitwardenServiceClient,
): DigitalAssetLinkService =
DigitalAssetLinkServiceImpl(
digitalAssetLinkApi = retrofits
.createStaticRetrofit()
.create(),
)
bitwardenServiceClient.digitalAssetLinkService
}

View File

@ -1,12 +1,12 @@
package com.x8bit.bitwarden.data.platform.datasource.network.authenticator
import com.bitwarden.network.model.RefreshTokenResponseJson
import com.bitwarden.network.provider.RefreshTokenProvider
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
/**
* A provider for all the functionality needed to properly refresh the users access token.
*/
interface AuthenticatorProvider {
interface AuthenticatorProvider : RefreshTokenProvider {
/**
* The currently active user's ID.
@ -17,12 +17,4 @@ interface AuthenticatorProvider {
* Attempts to logout the user based on the [userId].
*/
fun logout(userId: String, reason: LogoutReason)
/**
* Attempt to refresh the user's access token based on the [userId].
*
* This call is both synchronous and performs a network request. Make sure that you are calling
* from an appropriate thread.
*/
fun refreshAccessTokenSynchronously(userId: String): Result<RefreshTokenResponseJson>
}

View File

@ -1,73 +0,0 @@
package com.x8bit.bitwarden.data.platform.datasource.network.authenticator
import com.bitwarden.network.util.HEADER_KEY_AUTHORIZATION
import com.bitwarden.network.util.HEADER_VALUE_BEARER_PREFIX
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.util.parseJwtTokenDataOrNull
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Singleton
/**
* An authenticator used to refresh the access token when a 401 is returned from an API. Upon
* successfully getting a new access token, the original request is retried.
*/
@Singleton
class RefreshAuthenticator : Authenticator {
/**
* A provider required to update tokens.
*/
var authenticatorProvider: AuthenticatorProvider? = null
override fun authenticate(
route: Route?,
response: Response,
): Request? {
val accessToken = requireNotNull(
response
.request
.header(name = HEADER_KEY_AUTHORIZATION)
?.substringAfter(delimiter = HEADER_VALUE_BEARER_PREFIX),
)
return when (val userId = parseJwtTokenDataOrNull(accessToken)?.userId) {
null -> {
// We unable to get the user ID, let's just let the 401 pass through.
null
}
authenticatorProvider?.activeUserId -> {
// In order to prevent potential deadlocks or thread starvation we want the call
// to refresh the access token to be strictly synchronous with no internal thread
// hopping.
authenticatorProvider
?.refreshAccessTokenSynchronously(userId)
?.fold(
onFailure = {
authenticatorProvider?.logout(
userId = userId,
reason = LogoutReason.TokenRefreshFail,
)
null
},
onSuccess = {
response.request
.newBuilder()
.header(
name = HEADER_KEY_AUTHORIZATION,
value = "$HEADER_VALUE_BEARER_PREFIX${it.accessToken}",
)
.build()
},
)
}
else -> {
// We are no longer the active user, let's just cancel.
null
}
}
}
}

View File

@ -1,30 +1,24 @@
package com.x8bit.bitwarden.data.platform.datasource.network.di
import com.bitwarden.network.interceptor.AuthTokenInterceptor
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.bitwardenServiceClient
import com.bitwarden.network.interceptor.BaseUrlsProvider
import com.bitwarden.network.interceptor.HeadersInterceptor
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.ConfigServiceImpl
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.EventServiceImpl
import com.bitwarden.network.service.PushService
import com.bitwarden.network.service.PushServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.RetrofitsImpl
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_NAME
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_VERSION
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.util.isDevBuild
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import retrofit2.create
import java.time.Clock
import javax.inject.Singleton
/**
@ -38,70 +32,42 @@ object PlatformNetworkModule {
@Provides
@Singleton
fun providesConfigService(
retrofits: Retrofits,
): ConfigService = ConfigServiceImpl(retrofits.unauthenticatedApiRetrofit.create())
bitwardenServiceClient: BitwardenServiceClient,
): ConfigService = bitwardenServiceClient.configService
@Provides
@Singleton
fun providesEventService(
retrofits: Retrofits,
): EventService = EventServiceImpl(
eventApi = retrofits.authenticatedEventsRetrofit.create(),
)
bitwardenServiceClient: BitwardenServiceClient,
): EventService = bitwardenServiceClient.eventService
@Provides
@Singleton
fun providePushService(
retrofits: Retrofits,
authDiskSource: AuthDiskSource,
): PushService = PushServiceImpl(
pushApi = retrofits.authenticatedApiRetrofit.create(),
appId = authDiskSource.uniqueAppId,
)
bitwardenServiceClient: BitwardenServiceClient,
): PushService = bitwardenServiceClient.pushService
@Provides
@Singleton
fun providesAuthTokenInterceptor(
fun provideBitwardenServiceClient(
authTokenManager: AuthTokenManager,
): AuthTokenInterceptor = AuthTokenInterceptor(
authTokenProvider = authTokenManager,
)
@Provides
@Singleton
fun providesHeadersInterceptor(): HeadersInterceptor = HeadersInterceptor(
userAgent = HEADER_VALUE_USER_AGENT,
clientName = HEADER_VALUE_CLIENT_NAME,
clientVersion = HEADER_VALUE_CLIENT_VERSION,
)
@Provides
@Singleton
fun providesRefreshAuthenticator(): RefreshAuthenticator = RefreshAuthenticator()
@Provides
@Singleton
fun providesBaseUrlInterceptors(
baseUrlsProvider: BaseUrlsProvider,
): BaseUrlInterceptors =
BaseUrlInterceptors(baseUrlsProvider = baseUrlsProvider)
@Provides
@Singleton
fun provideRetrofits(
authTokenInterceptor: AuthTokenInterceptor,
baseUrlInterceptors: BaseUrlInterceptors,
headersInterceptor: HeadersInterceptor,
refreshAuthenticator: RefreshAuthenticator,
authDiskSource: AuthDiskSource,
certificateManager: CertificateManager,
json: Json,
): Retrofits =
RetrofitsImpl(
authTokenInterceptor = authTokenInterceptor,
baseUrlInterceptors = baseUrlInterceptors,
headersInterceptor = headersInterceptor,
refreshAuthenticator = refreshAuthenticator,
clock: Clock,
): BitwardenServiceClient = bitwardenServiceClient(
BitwardenServiceClientConfig(
clock = clock,
appIdProvider = authDiskSource,
clientData = BitwardenServiceClientConfig.ClientData(
userAgent = HEADER_VALUE_USER_AGENT,
clientName = HEADER_VALUE_CLIENT_NAME,
clientVersion = HEADER_VALUE_CLIENT_VERSION,
),
authTokenProvider = authTokenManager,
baseUrlsProvider = baseUrlsProvider,
certificateProvider = certificateManager,
json = json,
)
enableHttpBodyLogging = isDevBuild,
),
)
}

View File

@ -1,20 +0,0 @@
package com.x8bit.bitwarden.data.platform.datasource.network.ssl
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
/**
* Interface for managing SSL connections.
*/
interface SslManager {
/**
* The SSL context to use for SSL connections.
*/
val sslContext: SSLContext
/**
* The trust managers to use for SSL connections.
*/
val trustManagers: Array<TrustManager>
}

View File

@ -6,6 +6,7 @@ import androidx.core.content.getSystemService
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.data.manager.DispatcherManagerImpl
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.PushService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@ -17,7 +18,6 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.AppResumeManagerImpl
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
@ -244,14 +244,14 @@ object PlatformManagerModule {
authRepository: AuthRepository,
environmentRepository: EnvironmentRepository,
serverConfigRepository: ServerConfigRepository,
refreshAuthenticator: RefreshAuthenticator,
bitwardenServiceClient: BitwardenServiceClient,
dispatcherManager: DispatcherManager,
): NetworkConfigManager =
NetworkConfigManagerImpl(
authRepository = authRepository,
environmentRepository = environmentRepository,
serverConfigRepository = serverConfigRepository,
refreshAuthenticator = refreshAuthenticator,
bitwardenServiceClient = bitwardenServiceClient,
dispatcherManager = dispatcherManager,
)

View File

@ -2,8 +2,8 @@ package com.x8bit.bitwarden.data.platform.manager.network
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.BitwardenServiceClient
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.debounce
@ -19,7 +19,7 @@ class NetworkConfigManagerImpl(
authRepository: AuthRepository,
environmentRepository: EnvironmentRepository,
serverConfigRepository: ServerConfigRepository,
refreshAuthenticator: RefreshAuthenticator,
bitwardenServiceClient: BitwardenServiceClient,
dispatcherManager: DispatcherManager,
) : NetworkConfigManager {
@ -36,7 +36,6 @@ class NetworkConfigManagerImpl(
serverConfigRepository.getServerConfig(forceRefresh = true)
}
.launchIn(collectionScope)
refreshAuthenticator.authenticatorProvider = authRepository
bitwardenServiceClient.setRefreshTokenProvider(authRepository)
}
}

View File

@ -1,23 +1,15 @@
package com.x8bit.bitwarden.data.vault.datasource.network.di
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.CiphersServiceImpl
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.DownloadServiceImpl
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.FolderServiceImpl
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SendsServiceImpl
import com.bitwarden.network.service.SyncService
import com.bitwarden.network.service.SyncServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import retrofit2.create
import java.time.Clock
import javax.inject.Singleton
/**
@ -30,58 +22,30 @@ object VaultNetworkModule {
@Provides
@Singleton
fun provideCiphersService(
retrofits: Retrofits,
json: Json,
clock: Clock,
): CiphersService = CiphersServiceImpl(
azureApi = retrofits
.createStaticRetrofit()
.create(),
ciphersApi = retrofits.authenticatedApiRetrofit.create(),
json = json,
clock = clock,
)
bitwardenServiceClient: BitwardenServiceClient,
): CiphersService = bitwardenServiceClient.ciphersService
@Provides
@Singleton
fun providesFolderService(
retrofits: Retrofits,
json: Json,
): FolderService = FolderServiceImpl(
foldersApi = retrofits.authenticatedApiRetrofit.create(),
json = json,
)
bitwardenServiceClient: BitwardenServiceClient,
): FolderService = bitwardenServiceClient.folderService
@Provides
@Singleton
fun provideSendsService(
retrofits: Retrofits,
json: Json,
clock: Clock,
): SendsService = SendsServiceImpl(
azureApi = retrofits
.createStaticRetrofit()
.create(),
sendsApi = retrofits.authenticatedApiRetrofit.create(),
json = json,
clock = clock,
)
bitwardenServiceClient: BitwardenServiceClient,
): SendsService = bitwardenServiceClient.sendsService
@Provides
@Singleton
fun provideSyncService(
retrofits: Retrofits,
): SyncService = SyncServiceImpl(
syncApi = retrofits.authenticatedApiRetrofit.create(),
)
bitwardenServiceClient: BitwardenServiceClient,
): SyncService = bitwardenServiceClient.syncService
@Provides
@Singleton
fun provideDownloadService(
retrofits: Retrofits,
): DownloadService = DownloadServiceImpl(
downloadApi = retrofits
.createStaticRetrofit()
.create(),
)
bitwardenServiceClient: BitwardenServiceClient,
): DownloadService = bitwardenServiceClient.downloadService
}

View File

@ -3,11 +3,11 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.util.parseJwtTokenDataOrNull
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.parseJwtTokenDataOrNull
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest

View File

@ -2,13 +2,14 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.JwtTokenDataJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.network.util.parseJwtTokenDataOrNull
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
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.repository.model.JwtTokenDataJson
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.repository.model.JwtTokenDataJson
import com.bitwarden.network.model.JwtTokenDataJson
import com.bitwarden.network.util.parseJwtTokenDataOrNull
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test

View File

@ -4,15 +4,17 @@ import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.BitwardenServiceClient
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -38,8 +40,9 @@ class NetworkConfigManagerTest {
private val serverConfigRepository: ServerConfigRepository = mockk {
coEvery { getServerConfig(forceRefresh = true) } returns null
}
private val refreshAuthenticator = RefreshAuthenticator()
private val mockBitwardenServiceClient: BitwardenServiceClient = mockk {
every { setRefreshTokenProvider(any()) } just runs
}
private lateinit var networkConfigManager: NetworkConfigManager
@ -49,7 +52,7 @@ class NetworkConfigManagerTest {
authRepository = authRepository,
environmentRepository = environmentRepository,
serverConfigRepository = serverConfigRepository,
refreshAuthenticator = refreshAuthenticator,
bitwardenServiceClient = mockBitwardenServiceClient,
dispatcherManager = dispatcherManager,
)
}

View File

@ -3,14 +3,14 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import androidx.core.os.bundleOf
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.JwtTokenDataJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.util.parseJwtTokenDataOrNull
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.JwtTokenDataJson
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.parseJwtTokenDataOrNull
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentialsRequest

View File

@ -1,9 +1,11 @@
package com.bitwarden.authenticator.data.auth.datasource.disk
import com.bitwarden.network.provider.AppIdProvider
/**
* Primary access point for disk information.
*/
interface AuthDiskSource {
interface AuthDiskSource : AppIdProvider {
/**
* Retrieves the "last active time".

View File

@ -2,10 +2,12 @@ package com.bitwarden.authenticator.data.auth.datasource.disk
import android.content.SharedPreferences
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
import java.util.UUID
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey"
private const val LAST_ACTIVE_TIME_KEY = "lastActiveTime"
private const val BIOMETRICS_UNLOCK_KEY = "userKeyBiometricUnlock"
private const val UNIQUE_APP_ID_KEY = "appId"
/**
* Primary implementation of [AuthDiskSource].
@ -19,6 +21,9 @@ class AuthDiskSourceImpl(
),
AuthDiskSource {
override val uniqueAppId: String
get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId()
override fun getLastActiveTimeMillis(): Long? =
getLong(key = LAST_ACTIVE_TIME_KEY)
@ -50,4 +55,12 @@ class AuthDiskSourceImpl(
}
get() = getEncryptedString(AUTHENTICATOR_SYNC_SYMMETRIC_KEY)
?.toByteArray(Charsets.ISO_8859_1)
private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()
.toString()
.also {
putString(key = UNIQUE_APP_ID_KEY, value = it)
}
}

View File

@ -1,21 +1,27 @@
package com.bitwarden.authenticator.data.platform.datasource.network.di
import com.bitwarden.authenticator.data.platform.datasource.network.retrofit.Retrofits
import com.bitwarden.authenticator.data.platform.datasource.network.retrofit.RetrofitsImpl
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_NAME
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_VERSION
import com.bitwarden.authenticator.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.bitwardenServiceClient
import com.bitwarden.network.interceptor.AuthTokenProvider
import com.bitwarden.network.interceptor.BaseUrlsProvider
import com.bitwarden.network.interceptor.HeadersInterceptor
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.ConfigServiceImpl
import com.bitwarden.network.ssl.CertificateProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import retrofit2.create
import java.net.Socket
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.time.Clock
import javax.inject.Singleton
@Module
@ -28,8 +34,8 @@ object PlatformNetworkModule {
@Provides
@Singleton
fun providesConfigService(
retrofits: Retrofits,
): ConfigService = ConfigServiceImpl(retrofits.unauthenticatedApiRetrofit.create())
bitwardenServiceClient: BitwardenServiceClient,
): ConfigService = bitwardenServiceClient.configService
@Provides
@Singleton
@ -41,21 +47,35 @@ object PlatformNetworkModule {
@Provides
@Singleton
fun providesBaseUrlInterceptors(
fun provideBitwardenServiceClient(
baseUrlsProvider: BaseUrlsProvider,
): BaseUrlInterceptors =
BaseUrlInterceptors(baseUrlsProvider = baseUrlsProvider)
authDiskSource: AuthDiskSource,
clock: Clock,
): BitwardenServiceClient = bitwardenServiceClient(
BitwardenServiceClientConfig(
clock = clock,
appIdProvider = authDiskSource,
clientData = BitwardenServiceClientConfig.ClientData(
userAgent = HEADER_VALUE_USER_AGENT,
clientName = HEADER_VALUE_CLIENT_NAME,
clientVersion = HEADER_VALUE_CLIENT_VERSION,
),
baseUrlsProvider = baseUrlsProvider,
enableHttpBodyLogging = BuildConfig.DEBUG,
authTokenProvider = object : AuthTokenProvider {
override fun getActiveAccessTokenOrNull(): String? = null
},
certificateProvider = object : CertificateProvider {
override fun chooseClientAlias(
keyType: Array<out String>?,
issuers: Array<out Principal>?,
socket: Socket?,
) = ""
@Provides
@Singleton
fun provideRetrofits(
baseUrlInterceptors: BaseUrlInterceptors,
headersInterceptor: HeadersInterceptor,
json: Json,
): Retrofits =
RetrofitsImpl(
baseUrlInterceptors = baseUrlInterceptors,
headersInterceptor = headersInterceptor,
json = json,
)
override fun getCertificateChain(alias: String?): Array<X509Certificate>? = null
override fun getPrivateKey(alias: String?): PrivateKey? = null
},
),
)
}

View File

@ -1,30 +0,0 @@
package com.bitwarden.authenticator.data.platform.datasource.network.retrofit
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import retrofit2.Retrofit
import retrofit2.http.Url
/**
* A collection of various [Retrofit] instances that serve different purposes.
*/
interface Retrofits {
/**
* Allows access to "/api" calls that do not require authentication.
*
* The base URL can be dynamically determined via the [BaseUrlInterceptors].
*/
val unauthenticatedApiRetrofit: Retrofit
/**
* Allows access to static API calls (ex: external APIs).
*
* @param isAuthenticated Indicates if the [Retrofit] instance should use authentication.
* @param baseUrl The static base url associated with this retrofit instance. This can be
* overridden with the [Url] annotation.
*/
fun createStaticRetrofit(
isAuthenticated: Boolean = false,
baseUrl: String = "https://api.bitwarden.com",
): Retrofit
}

View File

@ -1,81 +0,0 @@
package com.bitwarden.authenticator.data.platform.datasource.network.retrofit
import com.bitwarden.network.core.NetworkResultCallAdapterFactory
import com.bitwarden.network.interceptor.BaseUrlInterceptor
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.interceptor.HeadersInterceptor
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
/**
* Primary implementation of [Retrofits].
*/
class RetrofitsImpl(
baseUrlInterceptors: BaseUrlInterceptors,
headersInterceptor: HeadersInterceptor,
json: Json,
) : Retrofits {
//region Unauthenticated Retrofits
override val unauthenticatedApiRetrofit: Retrofit by lazy {
createUnauthenticatedRetrofit(
baseUrlInterceptor = baseUrlInterceptors.apiInterceptor,
)
}
//endregion Unauthenticated Retrofits
//region Static Retrofit
override fun createStaticRetrofit(isAuthenticated: Boolean, baseUrl: String): Retrofit {
return baseRetrofitBuilder
.baseUrl(baseUrl)
.client(
baseOkHttpClient
.newBuilder()
.build(),
)
.build()
}
//endregion Static Retrofit
//region Helper properties and functions
private val baseOkHttpClient: OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(headersInterceptor)
.build()
private val baseRetrofit: Retrofit by lazy {
baseRetrofitBuilder
.baseUrl("https://api.bitwarden.com")
.build()
}
private val baseRetrofitBuilder: Retrofit.Builder by lazy {
Retrofit.Builder()
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.addCallAdapterFactory(NetworkResultCallAdapterFactory())
.client(baseOkHttpClient)
}
private fun createUnauthenticatedRetrofit(
baseUrlInterceptor: BaseUrlInterceptor,
): Retrofit =
baseRetrofit
.newBuilder()
.client(
baseOkHttpClient
.newBuilder()
.addInterceptor(baseUrlInterceptor)
.build(),
)
.build()
//endregion Helper properties and functions
}

View File

@ -1,5 +1,6 @@
package com.bitwarden.authenticator.data.auth.datasource.disk
import androidx.core.content.edit
import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.data.datasource.disk.base.FakeSharedPreferences
import org.junit.jupiter.api.Assertions.assertEquals
@ -37,4 +38,32 @@ class AuthDiskSourceTest {
// Retrieving the key from repository should give same byte array despite String conversion:
assertTrue(authDiskSource.authenticatorBridgeSymmetricSyncKey.contentEquals(symmetricKey))
}
@Test
fun `uniqueAppId should generate a new ID and update SharedPreferences if none exists`() {
val rememberedUniqueAppIdKey = "bwPreferencesStorage:appId"
// Assert that the SharedPreferences are empty
assertNull(fakeSharedPreferences.getString(rememberedUniqueAppIdKey, null))
// Generate a new uniqueAppId and retrieve it
val newId = authDiskSource.uniqueAppId
// Ensure that the SharedPreferences were updated
assertEquals(
newId,
fakeSharedPreferences.getString(rememberedUniqueAppIdKey, null),
)
}
@Test
fun `uniqueAppId should not generate a new ID if one exists`() {
val rememberedUniqueAppIdKey = "bwPreferencesStorage:appId"
val testId = "testId"
// Update preferences to hold test value
fakeSharedPreferences.edit { putString(rememberedUniqueAppIdKey, testId) }
assertEquals(testId, authDiskSource.uniqueAppId)
}
}

View File

@ -1,12 +1,16 @@
package com.bitwarden.authenticator.data.auth.datasource.disk.util
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import java.util.UUID
class FakeAuthDiskSource : AuthDiskSource {
private var lastActiveTimeMillis: Long? = null
private var userBiometricUnlockKey: String? = null
override val uniqueAppId: String
get() = UUID.randomUUID().toString()
override fun getLastActiveTimeMillis(): Long? = lastActiveTimeMillis
override fun storeLastActiveTimeMillis(lastActiveTimeMillis: Long?) {

View File

@ -1,127 +0,0 @@
package com.bitwarden.authenticator.data.platform.datasource.network.retrofit
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.interceptor.HeadersInterceptor
import com.bitwarden.network.model.NetworkResult
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import okhttp3.Interceptor
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import retrofit2.Retrofit
import retrofit2.create
import retrofit2.http.GET
class RetrofitsTest {
private val baseUrlInterceptors = mockk<BaseUrlInterceptors> {
every { apiInterceptor } returns mockk {
mockIntercept { isApiInterceptorCalled = true }
}
}
private val headersInterceptors = mockk<HeadersInterceptor> {
mockIntercept { isheadersInterceptorCalled = true }
}
private val json = Json
private val server = MockWebServer()
private val retrofits = RetrofitsImpl(
baseUrlInterceptors = baseUrlInterceptors,
headersInterceptor = headersInterceptors,
json = json,
)
private var isAuthInterceptorCalled = false
private var isApiInterceptorCalled = false
private var isheadersInterceptorCalled = false
private var isRefreshAuthenticatorCalled = false
@Before
fun setUp() {
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun `unauthenticatedApiRetrofit should not invoke the RefreshAuthenticator`() = runBlocking {
val testApi = retrofits
.unauthenticatedApiRetrofit
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setResponseCode(401).setBody("""{}"""))
testApi.test()
assertFalse(isRefreshAuthenticatorCalled)
}
@Test
fun `unauthenticatedApiRetrofit should invoke the correct interceptors`() = runBlocking {
val testApi = retrofits
.unauthenticatedApiRetrofit
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setBody("""{}"""))
testApi.test()
assertFalse(isAuthInterceptorCalled)
assertTrue(isApiInterceptorCalled)
assertTrue(isheadersInterceptorCalled)
}
@Test
fun `createStaticRetrofit when unauthenticated should invoke the correct interceptors`() =
runBlocking {
val testApi = retrofits
.createStaticRetrofit(isAuthenticated = false)
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setBody("""{}"""))
testApi.test()
assertFalse(isAuthInterceptorCalled)
assertFalse(isApiInterceptorCalled)
assertTrue(isheadersInterceptorCalled)
}
private fun Retrofit.createMockRetrofit(): Retrofit =
this
.newBuilder()
.baseUrl(server.url("/").toString())
.build()
}
interface TestApi {
@GET("/test")
suspend fun test(): NetworkResult<JsonObject>
}
/**
* Mocks the given [Interceptor] such that the [Interceptor.intercept] is a no-op but triggers the
* [isCalledCallback].
*/
private fun Interceptor.mockIntercept(isCalledCallback: () -> Unit) {
val chainSlot = slot<Interceptor.Chain>()
every { intercept(capture(chainSlot)) } answers {
isCalledCallback()
val chain = chainSlot.captured
chain.proceed(chain.request())
}
}

View File

@ -65,6 +65,8 @@ kover {
"androidx.compose.ui.tooling.preview.PreviewScreenSizes",
// Manually excluded classes/files/etc.
"com.bitwarden.core.annotation.OmitFromCoverage",
// Dagger modules
"dagger.Module",
)
classes(
// Navigation helpers

View File

@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
@ -48,8 +47,6 @@ dependencies {
implementation(project(":core"))
implementation(libs.androidx.core.ktx)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.serialization)
implementation(libs.square.okhttp)
implementation(libs.square.okhttp.logging)

View File

@ -0,0 +1,162 @@
@file:OmitFromCoverage
package com.bitwarden.network
import com.bitwarden.core.annotation.OmitFromCoverage
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.provider.RefreshTokenProvider
import com.bitwarden.network.service.AccountsService
import com.bitwarden.network.service.AuthRequestsService
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.DevicesService
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.NewAuthRequestService
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.service.PushService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
/**
* Provides access to Bitwarden services.
*
* New instances of this class should be created using the [bitwardenServiceClient] factory
* function.
*
* Example initialization:
* ```
* val bitwardenServiceClient = bitwardenServiceClient(
* BitwardenServiceClientConfig(
* clock = clock,
* json = json,
* appIdProvider = appIdProvider,
* clientData = BitwardenServiceClientConfig.ClientData(
* userAgent = "my-user-agent-string",
* clientName = "my-application",
* clientVersion = "versionName",
* ),
* authTokenProvider = authTokenProvider,
* baseUrlsProvider = baseUrlsProvider,
* certificateProvider = certificateProvider,
* ),
* )
* ```
*/
interface BitwardenServiceClient {
/**
* Provides access to the Accounts service.
*/
val accountsService: AccountsService
/**
* Provides access to the Authentication Requests service.
*/
val authRequestsService: AuthRequestsService
/**
* Provides access to the Ciphers service.
*/
val ciphersService: CiphersService
/**
* Provides access to the Configuration service.
*/
val configService: ConfigService
/**
* Provides access to the Digital Asset Link service.
*/
val digitalAssetLinkService: DigitalAssetLinkService
/**
* Provides access to the Devices service.
*/
val devicesService: DevicesService
/**
* Provides access to the Download service.
*/
val downloadService: DownloadService
/**
* Provides access to the Event service.
*/
val eventService: EventService
/**
* Provides access to the Folder service.
*/
val folderService: FolderService
/**
* Provides access to the Have I Been Pwned service.
*/
val haveIBeenPwnedService: HaveIBeenPwnedService
/**
* Provides access to the Identity service.
*/
val identityService: IdentityService
/**
* Provides access to the New Authentication Request service.
*/
val newAuthRequestService: NewAuthRequestService
/**
* Provides access to the Organization service.
*/
val organizationService: OrganizationService
/**
* Provides access to the Push service.
*/
val pushService: PushService
/**
* Provides access to the Sync service.
*/
val syncService: SyncService
/**
* Provides access to the Sends service.
*/
val sendsService: SendsService
/**
* Sets the [refreshTokenProvider] to be used for refreshing access tokens.
*/
fun setRefreshTokenProvider(refreshTokenProvider: RefreshTokenProvider?)
}
/**
* Creates a [BitwardenServiceClient] with the given [config].
*
* Example initialization:
* ```
* val bitwardenServiceClient = bitwardenServiceClient(
* BitwardenServiceClientConfig(
* clock = clock,
* json = json,
* appIdProvider = appIdProvider,
* clientData = BitwardenServiceClientConfig.ClientData(
* userAgent = "my-user-agent-string",
* clientName = "my-application",
* clientVersion = "versionName",
* ),
* authTokenProvider = authTokenProvider,
* baseUrlsProvider = baseUrlsProvider,
* certificateProvider = certificateProvider,
* ),
* )
* ```
*/
fun bitwardenServiceClient(
config: BitwardenServiceClientConfig,
): BitwardenServiceClient = BitwardenServiceClientImpl(config)

View File

@ -0,0 +1,207 @@
package com.bitwarden.network
import com.bitwarden.core.annotation.OmitFromCoverage
import com.bitwarden.core.data.serializer.ZonedDateTimeSerializer
import com.bitwarden.network.authenticator.RefreshAuthenticator
import com.bitwarden.network.interceptor.AuthTokenInterceptor
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.interceptor.HeadersInterceptor
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.provider.RefreshTokenProvider
import com.bitwarden.network.retrofit.Retrofits
import com.bitwarden.network.retrofit.RetrofitsImpl
import com.bitwarden.network.service.AccountsServiceImpl
import com.bitwarden.network.service.AuthRequestsService
import com.bitwarden.network.service.AuthRequestsServiceImpl
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.CiphersServiceImpl
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.ConfigServiceImpl
import com.bitwarden.network.service.DevicesService
import com.bitwarden.network.service.DevicesServiceImpl
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.network.service.DigitalAssetLinkServiceImpl
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.DownloadServiceImpl
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.EventServiceImpl
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.FolderServiceImpl
import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.HaveIBeenPwnedServiceImpl
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.IdentityServiceImpl
import com.bitwarden.network.service.NewAuthRequestService
import com.bitwarden.network.service.NewAuthRequestServiceImpl
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.service.OrganizationServiceImpl
import com.bitwarden.network.service.PushService
import com.bitwarden.network.service.PushServiceImpl
import com.bitwarden.network.service.SendsServiceImpl
import com.bitwarden.network.service.SyncServiceImpl
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import retrofit2.create
/**
* Primary implementation of [BitwardenServiceClient].
*/
@OmitFromCoverage
internal class BitwardenServiceClientImpl(
private val bitwardenServiceClientConfig: BitwardenServiceClientConfig,
) : BitwardenServiceClient {
private val refreshAuthenticator: RefreshAuthenticator = RefreshAuthenticator()
private val clientJson = Json {
// If there are keys returned by the server not modeled by a serializable class,
// ignore them.
// This makes additive server changes non-breaking.
ignoreUnknownKeys = true
// We allow for nullable values to have keys missing in the JSON response.
explicitNulls = false
serializersModule = SerializersModule {
contextual(ZonedDateTimeSerializer())
}
// Respect model default property values.
coerceInputValues = true
}
private val retrofits: Retrofits by lazy {
RetrofitsImpl(
authTokenInterceptor = AuthTokenInterceptor(
authTokenProvider = bitwardenServiceClientConfig.authTokenProvider,
),
baseUrlInterceptors = BaseUrlInterceptors(
baseUrlsProvider = bitwardenServiceClientConfig.baseUrlsProvider,
),
headersInterceptor = HeadersInterceptor(
userAgent = bitwardenServiceClientConfig.clientData.userAgent,
clientName = bitwardenServiceClientConfig.clientData.clientName,
clientVersion = bitwardenServiceClientConfig.clientData.clientVersion,
),
refreshAuthenticator = refreshAuthenticator,
logHttpBody = bitwardenServiceClientConfig.enableHttpBodyLogging,
certificateProvider = bitwardenServiceClientConfig.certificateProvider,
json = clientJson,
)
}
override val accountsService by lazy {
AccountsServiceImpl(
unauthenticatedAccountsApi = retrofits.unauthenticatedApiRetrofit.create(),
authenticatedAccountsApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedKeyConnectorApi = retrofits.createStaticRetrofit().create(),
authenticatedKeyConnectorApi = retrofits
.createStaticRetrofit(isAuthenticated = true)
.create(),
json = clientJson,
)
}
override val authRequestsService: AuthRequestsService by lazy {
AuthRequestsServiceImpl(
authenticatedAuthRequestsApi = retrofits.authenticatedApiRetrofit.create(),
)
}
override val ciphersService: CiphersService by lazy {
CiphersServiceImpl(
azureApi = retrofits.createStaticRetrofit().create(),
ciphersApi = retrofits.authenticatedApiRetrofit.create(),
json = clientJson,
clock = bitwardenServiceClientConfig.clock,
)
}
override val configService: ConfigService by lazy {
ConfigServiceImpl(
configApi = retrofits.createStaticRetrofit().create(),
)
}
override val devicesService: DevicesService by lazy {
DevicesServiceImpl(
authenticatedDevicesApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedDevicesApi = retrofits.unauthenticatedApiRetrofit.create(),
)
}
override val digitalAssetLinkService: DigitalAssetLinkService by lazy {
DigitalAssetLinkServiceImpl(
digitalAssetLinkApi = retrofits.createStaticRetrofit().create(),
)
}
override val downloadService: DownloadService by lazy {
DownloadServiceImpl(downloadApi = retrofits.createStaticRetrofit().create())
}
override val eventService: EventService by lazy {
EventServiceImpl(eventApi = retrofits.authenticatedApiRetrofit.create())
}
override val folderService: FolderService by lazy {
FolderServiceImpl(
foldersApi = retrofits.authenticatedApiRetrofit.create(),
json = clientJson,
)
}
override val haveIBeenPwnedService: HaveIBeenPwnedService by lazy {
HaveIBeenPwnedServiceImpl(
api = retrofits
.createStaticRetrofit(baseUrl = "https://api.pwnedpasswords.com")
.create(),
)
}
override val identityService: IdentityService by lazy {
IdentityServiceImpl(
unauthenticatedIdentityApi = retrofits.unauthenticatedIdentityRetrofit.create(),
json = clientJson,
)
}
override val newAuthRequestService: NewAuthRequestService by lazy {
NewAuthRequestServiceImpl(
authenticatedAuthRequestsApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedAuthRequestsApi = retrofits.unauthenticatedApiRetrofit.create(),
)
}
override val organizationService: OrganizationService by lazy {
OrganizationServiceImpl(
authenticatedOrganizationApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedOrganizationApi = retrofits.unauthenticatedApiRetrofit.create(),
)
}
override val pushService: PushService by lazy {
PushServiceImpl(
pushApi = retrofits.authenticatedApiRetrofit.create(),
appId = bitwardenServiceClientConfig.appIdProvider.uniqueAppId,
)
}
override val sendsService by lazy {
SendsServiceImpl(
sendsApi = retrofits.authenticatedApiRetrofit.create(),
azureApi = retrofits.createStaticRetrofit().create(),
json = clientJson,
clock = bitwardenServiceClientConfig.clock,
)
}
override val syncService by lazy {
SyncServiceImpl(
syncApi = retrofits.authenticatedApiRetrofit.create(),
)
}
override fun setRefreshTokenProvider(refreshTokenProvider: RefreshTokenProvider?) {
refreshAuthenticator.refreshTokenProvider = refreshTokenProvider
}
}

View File

@ -0,0 +1,54 @@
package com.bitwarden.network.authenticator
import com.bitwarden.network.provider.RefreshTokenProvider
import com.bitwarden.network.util.HEADER_KEY_AUTHORIZATION
import com.bitwarden.network.util.HEADER_VALUE_BEARER_PREFIX
import com.bitwarden.network.util.parseJwtTokenDataOrNull
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
/**
* An authenticator used to refresh the access token when a 401 is returned from an API. Upon
* successfully getting a new access token, the original request is retried.
*/
internal class RefreshAuthenticator : Authenticator {
var refreshTokenProvider: RefreshTokenProvider? = null
override fun authenticate(
route: Route?,
response: Response,
): Request? {
val accessToken = requireNotNull(
response
.request
.header(name = HEADER_KEY_AUTHORIZATION)
?.substringAfter(delimiter = HEADER_VALUE_BEARER_PREFIX),
)
return when (val userId = parseJwtTokenDataOrNull(accessToken)?.userId) {
null -> {
// We are unable to get the user ID, let's just let the 401 pass through.
null
}
else -> {
refreshTokenProvider
?.refreshAccessTokenSynchronously(userId = userId)
?.fold(
onFailure = { null },
onSuccess = { newAccessToken ->
response.request
.newBuilder()
.header(
name = HEADER_KEY_AUTHORIZATION,
value = HEADER_VALUE_BEARER_PREFIX + newAccessToken.accessToken,
)
.build()
},
)
}
}
}
}

View File

@ -5,12 +5,10 @@ import com.bitwarden.network.util.HEADER_VALUE_BEARER_PREFIX
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import javax.inject.Singleton
/**
* Interceptor responsible for adding the auth token(Bearer) to API requests.
*/
@Singleton
class AuthTokenInterceptor(
private val authTokenProvider: AuthTokenProvider,
) : Interceptor {

View File

@ -1,15 +1,12 @@
package com.bitwarden.network.interceptor
import com.bitwarden.core.annotation.OmitFromCoverage
import javax.inject.Inject
import javax.inject.Singleton
/**
* An overall container for various [BaseUrlInterceptor] implementations for different API groups.
*/
@OmitFromCoverage
@Singleton
class BaseUrlInterceptors @Inject constructor(
class BaseUrlInterceptors(
private val baseUrlsProvider: BaseUrlsProvider,
) {
/**

View File

@ -0,0 +1,30 @@
package com.bitwarden.network.model
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.interceptor.AuthTokenProvider
import com.bitwarden.network.interceptor.BaseUrlsProvider
import com.bitwarden.network.provider.AppIdProvider
import com.bitwarden.network.ssl.CertificateProvider
import java.time.Clock
/**
* Models configuration for [BitwardenServiceClient].
*/
data class BitwardenServiceClientConfig(
val clientData: ClientData,
val appIdProvider: AppIdProvider,
val baseUrlsProvider: BaseUrlsProvider,
val authTokenProvider: AuthTokenProvider,
val certificateProvider: CertificateProvider,
val clock: Clock = Clock.systemDefaultZone(),
val enableHttpBodyLogging: Boolean = false,
) {
/**
* Models data about the client application.
*/
data class ClientData(
val userAgent: String,
val clientName: String,
val clientVersion: String,
)
}

View File

@ -1,4 +1,4 @@
package com.x8bit.bitwarden.data.auth.repository.model
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

View File

@ -0,0 +1,12 @@
package com.bitwarden.network.provider
/**
* A provider for all the functionality needed to uniquely identify the app installation.
*/
interface AppIdProvider {
/**
* The unique app ID.
*/
val uniqueAppId: String
}

View File

@ -0,0 +1,16 @@
package com.bitwarden.network.provider
import com.bitwarden.network.model.RefreshTokenResponseJson
/**
* A provider for all the functionality needed to refresh a user's access token.
*/
interface RefreshTokenProvider {
/**
* Attempt to refresh the user's access token based on the [userId].
*
* This call is both synchronous and performs a network request. Make sure that you are calling
* from an appropriate thread.
*/
fun refreshAccessTokenSynchronously(userId: String): Result<RefreshTokenResponseJson>
}

View File

@ -1,4 +1,4 @@
package com.x8bit.bitwarden.data.platform.datasource.network.retrofit
package com.bitwarden.network.retrofit
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import retrofit2.Retrofit
@ -7,7 +7,7 @@ import retrofit2.http.Url
/**
* A collection of various [Retrofit] instances that serve different purposes.
*/
interface Retrofits {
internal interface Retrofits {
/**
* Allows access to "/api" calls that must be authenticated.
*

View File

@ -1,15 +1,14 @@
package com.x8bit.bitwarden.data.platform.datasource.network.retrofit
package com.bitwarden.network.retrofit
import com.bitwarden.network.authenticator.RefreshAuthenticator
import com.bitwarden.network.core.NetworkResultCallAdapterFactory
import com.bitwarden.network.interceptor.AuthTokenInterceptor
import com.bitwarden.network.interceptor.BaseUrlInterceptor
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.interceptor.HeadersInterceptor
import com.bitwarden.network.ssl.BitwardenX509ExtendedKeyManager
import com.bitwarden.network.ssl.CertificateProvider
import com.bitwarden.network.util.HEADER_KEY_AUTHORIZATION
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
import com.x8bit.bitwarden.data.platform.datasource.network.ssl.BitwardenX509ExtendedKeyManager
import com.x8bit.bitwarden.data.platform.util.isDevBuild
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
@ -21,19 +20,20 @@ import java.security.KeyStore
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
/**
* Primary implementation of [Retrofits].
*/
class RetrofitsImpl(
@Suppress("LongParameterList")
internal class RetrofitsImpl(
authTokenInterceptor: AuthTokenInterceptor,
baseUrlInterceptors: BaseUrlInterceptors,
headersInterceptor: HeadersInterceptor,
refreshAuthenticator: RefreshAuthenticator,
refreshAuthenticator: RefreshAuthenticator?,
json: Json,
private val certificateProvider: CertificateProvider,
private val logHttpBody: Boolean = false,
) : Retrofits {
//region Authenticated Retrofits
@ -91,22 +91,24 @@ class RetrofitsImpl(
redactHeader(name = HEADER_KEY_AUTHORIZATION)
setLevel(
level = HttpLoggingInterceptor.Level.BODY
.takeIf { isDevBuild }
.takeIf { logHttpBody }
?: HttpLoggingInterceptor.Level.BASIC,
)
}
}
private val baseOkHttpClient: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(headersInterceptor)
.setSslSocketFactory()
.build()
.addInterceptor(headersInterceptor)
.configureSsl()
.build()
private val authenticatedOkHttpClient: OkHttpClient by lazy {
baseOkHttpClient
.newBuilder()
.authenticator(refreshAuthenticator)
.addInterceptor(authTokenInterceptor)
.also { builder ->
refreshAuthenticator?.let { builder.authenticator(it) }
}
.build()
}
@ -157,22 +159,20 @@ class RetrofitsImpl(
.apply { init(null as KeyStore?) }
.trustManagers
private fun createX509KeyManager(): X509ExtendedKeyManager =
BitwardenX509ExtendedKeyManager(certificateProvider = certificateProvider)
private fun createSslContext(): SSLContext = SSLContext
.getInstance("TLS")
.apply {
private fun createSslContext(certificateProvider: CertificateProvider): SSLContext = SSLContext
.getInstance("TLS").apply {
init(
arrayOf(createX509KeyManager()),
arrayOf(
BitwardenX509ExtendedKeyManager(certificateProvider = certificateProvider),
),
createSslTrustManagers(),
null,
)
}
private fun OkHttpClient.Builder.setSslSocketFactory(): OkHttpClient.Builder =
private fun OkHttpClient.Builder.configureSsl(): OkHttpClient.Builder =
sslSocketFactory(
createSslContext().socketFactory,
createSslContext(certificateProvider = certificateProvider).socketFactory,
createSslTrustManagers().first() as X509TrustManager,
)

View File

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.network.ssl
package com.bitwarden.network.ssl
import com.bitwarden.core.annotation.OmitFromCoverage
import com.bitwarden.network.ssl.CertificateProvider
import java.net.Socket
import java.security.Principal
import java.security.PrivateKey

View File

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository.util
package com.bitwarden.network.util
import com.bitwarden.network.util.base64UrlDecodeOrNull
import com.x8bit.bitwarden.data.auth.repository.model.JwtTokenDataJson
import com.bitwarden.network.model.JwtTokenDataJson
import kotlinx.serialization.json.Json
import timber.log.Timber

View File

@ -1,16 +1,14 @@
package com.x8bit.bitwarden.data.platform.datasource.network.authenticator
package com.bitwarden.network.authenticator
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.model.JwtTokenDataJson
import com.bitwarden.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.repository.model.JwtTokenDataJson
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.util.parseJwtTokenDataOrNull
import com.bitwarden.network.provider.RefreshTokenProvider
import com.bitwarden.network.util.parseJwtTokenDataOrNull
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import okhttp3.Protocol
@ -24,12 +22,12 @@ import org.junit.jupiter.api.Test
class RefreshAuthenticatorTests {
private lateinit var authenticator: RefreshAuthenticator
private val authenticatorProvider: AuthenticatorProvider = mockk()
private val refreshTokenProvider: RefreshTokenProvider = mockk()
@BeforeEach
fun setup() {
authenticator = RefreshAuthenticator()
authenticator.authenticatorProvider = authenticatorProvider
authenticator.refreshTokenProvider = refreshTokenProvider
mockkStatic(::parseJwtTokenDataOrNull)
}
@ -39,18 +37,6 @@ class RefreshAuthenticatorTests {
unmockkStatic(::parseJwtTokenDataOrNull)
}
@Test
fun `RefreshAuthenticator returns null if the request is for a different user`() {
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
every { authenticatorProvider.activeUserId } returns "different_user_id"
assertNull(authenticator.authenticate(null, RESPONSE_401))
verify(exactly = 1) {
authenticatorProvider.activeUserId
}
}
@Test
fun `RefreshAuthenticator returns null if API has no authorization user ID`() {
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns null
@ -58,39 +44,37 @@ class RefreshAuthenticatorTests {
assertNull(authenticator.authenticate(null, RESPONSE_401))
verify(exactly = 0) {
authenticatorProvider.activeUserId
authenticatorProvider.refreshAccessTokenSynchronously(any())
authenticatorProvider.logout(userId = any(), reason = LogoutReason.TokenRefreshFail)
refreshTokenProvider.refreshAccessTokenSynchronously(any())
}
}
@Suppress("MaxLineLength")
@Test
fun `RefreshAuthenticator returns null and logs out when request is for active user and refresh is failure`() {
fun `RefreshAuthenticator returns null when refresh is failure`() {
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
every { authenticatorProvider.activeUserId } returns USER_ID
every {
authenticatorProvider.refreshAccessTokenSynchronously(USER_ID)
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
} returns Throwable("Fail").asFailure()
every {
authenticatorProvider.logout(
userId = USER_ID,
reason = LogoutReason.TokenRefreshFail,
)
} just runs
assertNull(authenticator.authenticate(null, RESPONSE_401))
verify(exactly = 1) {
authenticatorProvider.activeUserId
authenticatorProvider.refreshAccessTokenSynchronously(USER_ID)
authenticatorProvider.logout(userId = USER_ID, reason = LogoutReason.TokenRefreshFail)
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
}
}
@Test
fun `RefreshAuthenticator returns null when refreshTokenProvider is null`() {
authenticator.refreshTokenProvider = null
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
assertNull(authenticator.authenticate(null, RESPONSE_401))
verify(exactly = 0) {
refreshTokenProvider.refreshAccessTokenSynchronously(any())
}
}
@Suppress("MaxLineLength")
@Test
fun `RefreshAuthenticator returns updated request when request is for active user and refresh is success`() {
fun `RefreshAuthenticator returns updated request when refresh is success`() {
val newAccessToken = "newAccessToken"
val refreshResponse = RefreshTokenResponseJson(
accessToken = newAccessToken,
@ -99,9 +83,8 @@ class RefreshAuthenticatorTests {
tokenType = "Bearer",
)
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
every { authenticatorProvider.activeUserId } returns USER_ID
every {
authenticatorProvider.refreshAccessTokenSynchronously(USER_ID)
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
} returns refreshResponse.asSuccess()
val authenticatedRequest = authenticator.authenticate(null, RESPONSE_401)
@ -113,8 +96,7 @@ class RefreshAuthenticatorTests {
authenticatedRequest!!.header("Authorization"),
)
verify(exactly = 1) {
authenticatorProvider.activeUserId
authenticatorProvider.refreshAccessTokenSynchronously(USER_ID)
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
}
}
}

View File

@ -7,9 +7,7 @@ import okhttp3.Request
import org.junit.Assert.assertThrows
import org.junit.Test
import java.io.IOException
import javax.inject.Singleton
@Singleton
class AuthTokenInterceptorTest {
private val mockAuthTokenProvider = mockk<AuthTokenProvider> {
every { getActiveAccessTokenOrNull() } returns null

View File

@ -1,16 +1,20 @@
package com.x8bit.bitwarden.data.platform.datasource.network.retrofit
package com.bitwarden.network.retrofit
import com.bitwarden.network.authenticator.RefreshAuthenticator
import com.bitwarden.network.interceptor.AuthTokenInterceptor
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.interceptor.HeadersInterceptor
import com.bitwarden.network.model.NetworkResult
import com.bitwarden.network.ssl.CertificateProvider
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
import io.mockk.MockKMatcherScope
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.slot
import io.mockk.unmockkConstructor
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import okhttp3.Authenticator
@ -266,6 +270,32 @@ class RetrofitsTest {
assertFalse(isEventsInterceptorCalled)
}
@Test
fun `createStaticRetrofit should set sslSocketFactory when certificateProvider is not null`() =
runTest {
mockkConstructor(OkHttpClient.Builder::class)
mockBuilder<OkHttpClient.Builder> {
it.addInterceptor(baseUrlInterceptors.apiInterceptor)
}
every {
anyConstructed<OkHttpClient.Builder>().sslSocketFactory(any(), any())
} returns mockk(relaxed = true)
val retrofits = RetrofitsImpl(
authTokenInterceptor = authTokenInterceptor,
baseUrlInterceptors = baseUrlInterceptors,
headersInterceptor = headersInterceptors,
refreshAuthenticator = refreshAuthenticator,
certificateProvider = certificateProvider,
json = json,
)
retrofits.createStaticRetrofit()
verify(exactly = 1) {
anyConstructed<OkHttpClient.Builder>().sslSocketFactory(any(), any())
}
}
private fun Retrofit.createMockRetrofit(): Retrofit =
this
.newBuilder()
@ -301,3 +331,31 @@ private fun Interceptor.mockIntercept(isCalledCallback: () -> Unit) {
chain.proceed(chain.request())
}
}
/**
* Helper method for mocking pipeline operations within the builder pattern. This saves a lot of
* boiler plate. In order to use this, the builder's constructor must be mockked.
*
* Example:
* ```
* // Setup
* mockkConstructor(FillResponse.Builder::class)
* mockBuilder<FillResponse.Builder> { it.setIgnoredIds() }
* every { anyConstructed<FillResponse.Builder>().build() } returns mockk()
*
* // Test
* ...
*
* // Verify
* verify(exactly = 1) {
* anyConstructed<FillResponse.Builder>().setIgnoredIds()
* anyConstructed<FillResponse.Builder>().build()
* }
* unmockkConstructor(FillResponse.Builder::class)
* ```
*/
inline fun <reified T : Any> mockBuilder(crossinline block: MockKMatcherScope.(T) -> T) {
every { block(anyConstructed<T>()) } answers {
this.self as T
}
}