From 046a94939e48eeea8dd3623f1b5c65a6e1414fe0 Mon Sep 17 00:00:00 2001 From: Ramsey Smith <142836716+ramsey-livefront@users.noreply.github.com> Date: Thu, 21 Sep 2023 11:35:32 -0600 Subject: [PATCH] BIT-405: AuthTokenInterceptor (#57) --- .../auth/repository/AuthRepositoryImpl.kt | 4 + .../datasource/network/di/NetworkModule.kt | 75 +++++++++++++------ .../interceptor/AuthTokenInterceptor.kt | 29 +++++++ .../interceptor/AuthTokenInterceptorTest.kt | 43 +++++++++++ .../interceptor/FakeInterceptorChain.kt | 69 +++++++++++++++++ 5 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/AuthTokenInterceptor.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/AuthTokenInterceptorTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/FakeInterceptorChain.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 60067846b..a41f1c273 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson +import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor import com.x8bit.bitwarden.data.platform.util.flatMap import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,6 +23,7 @@ class AuthRepositoryImpl @Inject constructor( private val accountsApi: AccountsApi, private val identityApi: IdentityApi, private val bitwardenSdkClient: Client, + private val authTokenInterceptor: AuthTokenInterceptor, ) : AuthRepository { private val mutableAuthStateFlow = MutableStateFlow(AuthState.Unauthenticated) @@ -55,6 +57,8 @@ class AuthRepositoryImpl @Inject constructor( LoginResult.Error }, onSuccess = { + // TODO: Create intermediate class for providing auth token to interceptor (BIT-411) + authTokenInterceptor.authToken = it.accessToken mutableAuthStateFlow.value = AuthState.Authenticated(it.accessToken) LoginResult.Success }, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/NetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/NetworkModule.kt index 3ab242f24..736ddc84f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/NetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/NetworkModule.kt @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi import com.x8bit.bitwarden.data.platform.datasource.network.api.ConfigApi import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory +import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -15,6 +16,7 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.create +import javax.inject.Named import javax.inject.Singleton /** @@ -25,46 +27,75 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object NetworkModule { - @Provides - @Singleton - fun providesAccountsApiService(retrofit: Retrofit): AccountsApi = retrofit.create() + private const val AUTHORIZED = "authorized" + private const val UNAUTHORIZED = "unauthorized" @Provides @Singleton - fun providesConfigApiService(retrofit: Retrofit): ConfigApi = retrofit.create() + fun providesAccountsApiService(@Named(UNAUTHORIZED) retrofit: Retrofit): AccountsApi = + retrofit.create() @Provides @Singleton - fun providesIdentityApiService(retrofit: Retrofit): IdentityApi = retrofit.create() + fun providesConfigApiService(@Named(UNAUTHORIZED) retrofit: Retrofit): ConfigApi = + retrofit.create() @Provides @Singleton - fun provideOkHttpClient(): OkHttpClient { - return OkHttpClient.Builder() - .addInterceptor( - HttpLoggingInterceptor().apply { - setLevel(HttpLoggingInterceptor.Level.BODY) - }, - ) - .build() - } + fun providesIdentityApiService(@Named(UNAUTHORIZED) retrofit: Retrofit): IdentityApi = + retrofit.create() @Provides @Singleton - fun provideRetrofit( - okHttpClient: OkHttpClient, + fun providesAuthTokenInterceptor(): AuthTokenInterceptor = AuthTokenInterceptor() + + @Provides + @Singleton + fun providesOkHttpClientBuilder(): OkHttpClient.Builder = + OkHttpClient.Builder().addInterceptor( + HttpLoggingInterceptor().apply { + setLevel(HttpLoggingInterceptor.Level.BODY) + }, + ) + + @Provides + @Singleton + fun providesRetrofitBuilder( json: Json, - ): Retrofit { + ): Retrofit.Builder { val contentType = "application/json".toMediaType() - - return Retrofit.Builder() - .baseUrl("https://api.bitwarden.com") - .client(okHttpClient) + return Retrofit.Builder().baseUrl("https://api.bitwarden.com") .addConverterFactory(json.asConverterFactory(contentType)) .addCallAdapterFactory(ResultCallAdapterFactory()) - .build() } + @Provides + @Singleton + @Named(UNAUTHORIZED) + fun providesUnauthorizedRetrofit( + okHttpClientBuilder: OkHttpClient.Builder, + retrofitBuilder: Retrofit.Builder, + ): Retrofit = + retrofitBuilder + .client( + okHttpClientBuilder.build(), + ) + .build() + + @Provides + @Singleton + @Named(AUTHORIZED) + fun providesAuthorizedRetrofit( + okHttpClientBuilder: OkHttpClient.Builder, + retrofitBuilder: Retrofit.Builder, + authTokenInterceptor: AuthTokenInterceptor, + ): Retrofit = + retrofitBuilder + .client( + okHttpClientBuilder.addInterceptor(authTokenInterceptor).build(), + ) + .build() + @Provides @Singleton fun providesJson(): Json = Json { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/AuthTokenInterceptor.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/AuthTokenInterceptor.kt new file mode 100644 index 000000000..154037453 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/AuthTokenInterceptor.kt @@ -0,0 +1,29 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.interceptor + +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 : Interceptor { + /** + * The auth token to be added to API requests. + */ + var authToken: String? = null + + private val missingTokenMessage = "Auth token is missing!" + + override fun intercept(chain: Interceptor.Chain): Response { + val token = authToken ?: throw IOException(IllegalStateException(missingTokenMessage)) + val request = chain + .request() + .newBuilder() + .addHeader("Authorization", "Bearer $token") + .build() + return chain + .proceed(request) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/AuthTokenInterceptorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/AuthTokenInterceptorTest.kt new file mode 100644 index 000000000..24f97faed --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/AuthTokenInterceptorTest.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.interceptor + +import junit.framework.TestCase.assertEquals +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 interceptor: AuthTokenInterceptor = AuthTokenInterceptor() + private val mockAuthToken = "yourAuthToken" + private val request: Request = Request + .Builder() + .url("http://localhost") + .build() + + @Test + fun `intercept should add the auth token when set`() { + interceptor.authToken = mockAuthToken + val response = interceptor.intercept( + chain = FakeInterceptorChain(request = request), + ) + assertEquals( + "Bearer $mockAuthToken", + response.request.header("Authorization"), + ) + } + + @Test + fun `intercept should throw an exception when an auth token is missing`() { + val throwable = assertThrows(IOException::class.java) { + interceptor.intercept( + chain = FakeInterceptorChain(request = request), + ) + } + assertEquals( + "Auth token is missing!", + throwable.cause?.message, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/FakeInterceptorChain.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/FakeInterceptorChain.kt new file mode 100644 index 000000000..67b570bb9 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/FakeInterceptorChain.kt @@ -0,0 +1,69 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.interceptor + +import okhttp3.Call +import okhttp3.Connection +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import java.util.concurrent.TimeUnit + +/** + * Helper class for implementing a [Interceptor.Chain] in a way that a [Request] passed in to + * [proceed] will be returned in a valid [Response] object that can be queried. This wrapping is + * performed by the [responseProvider]. + */ +class FakeInterceptorChain( + private val request: Request, + private val responseProvider: (Request) -> Response = DEFAULT_RESPONSE_PROVIDER, +) : Interceptor.Chain { + override fun request(): Request = request + + override fun proceed(request: Request): Response = responseProvider(request) + + override fun connection(): Connection = notImplemented() + + override fun call(): Call = notImplemented() + + override fun connectTimeoutMillis(): Int = notImplemented() + + override fun withConnectTimeout( + timeout: Int, + unit: TimeUnit, + ): Interceptor.Chain = notImplemented() + + override fun readTimeoutMillis(): Int = notImplemented() + + override fun withReadTimeout( + timeout: Int, + unit: TimeUnit, + ): Interceptor.Chain = notImplemented() + + override fun writeTimeoutMillis(): Int = notImplemented() + + override fun withWriteTimeout( + timeout: Int, + unit: TimeUnit, + ): Interceptor.Chain = notImplemented() + + private fun notImplemented(): Nothing { + throw NotImplementedError("This is not yet required by tests") + } + + companion object { + /** + * A default response provider that provides a basic successful response. This is useful + * when the details of the response are not as important as retrieving the [Request] that + * was used to build it. + */ + val DEFAULT_RESPONSE_PROVIDER: (Request) -> Response = { request -> + Response + .Builder() + .code(200) + .message("OK") + .protocol(Protocol.HTTP_1_1) + .request(request) + .build() + } + } +}