diff --git a/template-xml/app/src/main/java/co/nimblehq/template/xml/di/Authenticate.kt b/template-xml/app/src/main/java/co/nimblehq/template/xml/di/Authenticate.kt new file mode 100644 index 000000000..ea127def2 --- /dev/null +++ b/template-xml/app/src/main/java/co/nimblehq/template/xml/di/Authenticate.kt @@ -0,0 +1,7 @@ +package co.nimblehq.template.xml.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Authenticate diff --git a/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/AuthModule.kt b/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/AuthModule.kt new file mode 100644 index 000000000..ca53dfacc --- /dev/null +++ b/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/AuthModule.kt @@ -0,0 +1,22 @@ +package co.nimblehq.template.xml.di.modules + +import co.nimblehq.template.xml.data.repository.SessionManagerImpl +import co.nimblehq.template.xml.data.repository.TokenRefresherImpl +import co.nimblehq.template.xml.data.service.AuthService +import co.nimblehq.template.xml.data.service.SessionManager +import co.nimblehq.template.xml.data.service.authenticator.TokenRefresher +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class AuthModule { + + @Provides + fun provideAuthService(authService: AuthService): TokenRefresher = TokenRefresherImpl(authService) + + @Provides + fun provideSessionManager(): SessionManager = SessionManagerImpl() +} diff --git a/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/OkHttpClientModule.kt b/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/OkHttpClientModule.kt index ee2c8df8a..879c6cb75 100644 --- a/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/OkHttpClientModule.kt +++ b/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/OkHttpClientModule.kt @@ -2,6 +2,10 @@ package co.nimblehq.template.xml.di.modules import android.content.Context import co.nimblehq.template.xml.BuildConfig +import co.nimblehq.template.xml.data.service.SessionManager +import co.nimblehq.template.xml.data.service.authenticator.ApplicationRequestAuthenticator +import co.nimblehq.template.xml.data.service.authenticator.TokenRefresher +import co.nimblehq.template.xml.di.Authenticate import com.chuckerteam.chucker.api.* import dagger.Module import dagger.Provides @@ -20,16 +24,42 @@ class OkHttpClientModule { @Provides fun provideOkHttpClient( - chuckerInterceptor: ChuckerInterceptor - ) = OkHttpClient.Builder().apply { - if (BuildConfig.DEBUG) { - addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - }) - addInterceptor(chuckerInterceptor) - readTimeout(READ_TIME_OUT, TimeUnit.SECONDS) - } - }.build() + chuckerInterceptor: ChuckerInterceptor, + sessionManager: SessionManager, + tokenRefresher: TokenRefresher? + ): OkHttpClient { + val authenticator = + tokenRefresher?.let { ApplicationRequestAuthenticator(it, sessionManager) } + return OkHttpClient.Builder() + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + addInterceptor(chuckerInterceptor) + readTimeout(READ_TIME_OUT, TimeUnit.SECONDS) + } + } + .build() + .apply { authenticator?.okHttpClient = this } + + } + + @Authenticate + @Provides + fun provideAuthOkHttpClient(chuckerInterceptor: ChuckerInterceptor): OkHttpClient { + return OkHttpClient.Builder() + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + addInterceptor(chuckerInterceptor) + readTimeout(READ_TIME_OUT, TimeUnit.SECONDS) + } + } + .build() + } @Provides fun provideChuckerInterceptor( diff --git a/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/RetrofitModule.kt b/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/RetrofitModule.kt index de696c4c0..f8bab3a10 100644 --- a/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/RetrofitModule.kt +++ b/template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/RetrofitModule.kt @@ -2,9 +2,11 @@ package co.nimblehq.template.xml.di.modules import co.nimblehq.template.xml.BuildConfig import co.nimblehq.template.xml.data.service.ApiService +import co.nimblehq.template.xml.data.service.AuthService import co.nimblehq.template.xml.data.service.providers.ApiServiceProvider import co.nimblehq.template.xml.data.service.providers.ConverterFactoryProvider import co.nimblehq.template.xml.data.service.providers.RetrofitProvider +import co.nimblehq.template.xml.di.Authenticate import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides @@ -37,4 +39,21 @@ class RetrofitModule { @Provides fun provideApiService(retrofit: Retrofit): ApiService = ApiServiceProvider.getApiService(retrofit) + + + @Authenticate + @Provides + fun provideAuthRetrofit( + baseUrl: String, + @Authenticate okHttpClient: OkHttpClient, + converterFactory: Converter.Factory, + ): Retrofit = RetrofitProvider + .getRetrofitBuilder(baseUrl, okHttpClient, converterFactory) + .build() + + @Provides + fun provideAuthService( + @Authenticate retrofit: Retrofit + ): AuthService = + ApiServiceProvider.getAuthService(retrofit) } diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/extensions/ResponseMapping.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/extensions/ResponseMapping.kt index 926db5a59..f6205bed3 100644 --- a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/extensions/ResponseMapping.kt +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/extensions/ResponseMapping.kt @@ -42,7 +42,7 @@ private fun Throwable.mapError(): Throwable { } } -private fun parseErrorResponse(response: Response<*>?): ErrorResponse? { +fun parseErrorResponse(response: Response<*>?): ErrorResponse? { val jsonString = response?.errorBody()?.string() return try { val moshi = MoshiBuilderProvider.moshiBuilder.build() @@ -54,3 +54,15 @@ private fun parseErrorResponse(response: Response<*>?): ErrorResponse? { null } } + +fun parseErrorResponse(jsonString: String?): ErrorResponse? { + return try { + val moshi = MoshiBuilderProvider.moshiBuilder.build() + val adapter = moshi.adapter(ErrorResponse::class.java) + adapter.fromJson(jsonString.orEmpty()) + } catch (exception: IOException) { + null + } catch (exception: JsonDataException) { + null + } +} diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/repository/SessionManagerImpl.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/repository/SessionManagerImpl.kt new file mode 100644 index 000000000..652e2b051 --- /dev/null +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/repository/SessionManagerImpl.kt @@ -0,0 +1,27 @@ +package co.nimblehq.template.xml.data.repository + +import co.nimblehq.template.xml.data.response.AuthenticateResponse +import co.nimblehq.template.xml.data.service.SessionManager + +class SessionManagerImpl: SessionManager { + + override suspend fun getAccessToken(): String { + TODO("Not yet implemented") + } + + override suspend fun getRefreshToken(): String { + TODO("Not yet implemented") + } + + override suspend fun getRegistrationToken(): String { + TODO("Not yet implemented") + } + + override suspend fun getTokenType(): String { + TODO("Not yet implemented") + } + + override suspend fun refresh(authenticateResponse: AuthenticateResponse) { + TODO("Not yet implemented") + } +} diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/repository/TokenRefresherImpl.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/repository/TokenRefresherImpl.kt new file mode 100644 index 000000000..16ccdfa67 --- /dev/null +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/repository/TokenRefresherImpl.kt @@ -0,0 +1,16 @@ +package co.nimblehq.template.xml.data.repository + +import co.nimblehq.template.xml.data.extensions.flowTransform +import co.nimblehq.template.xml.data.response.AuthenticateResponse +import co.nimblehq.template.xml.data.service.AuthService +import co.nimblehq.template.xml.data.service.authenticator.TokenRefresher +import kotlinx.coroutines.flow.Flow + +class TokenRefresherImpl constructor( + private val authService: AuthService +) : TokenRefresher { + + override suspend fun refreshToken(): Flow = flowTransform { + authService.refreshToken() + } +} diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/response/AuthenticateResponse.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/response/AuthenticateResponse.kt new file mode 100644 index 000000000..aed97197b --- /dev/null +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/response/AuthenticateResponse.kt @@ -0,0 +1,22 @@ +package co.nimblehq.template.xml.data.response + +import co.nimblehq.template.xml.domain.model.AuthStatus +import com.squareup.moshi.Json + +data class AuthenticateResponse( + @Json(name = "access_token") + val accessToken: String, + @Json(name = "refresh_token") + val refreshToken: String, + @Json(name = "status") + val status: String, + @Json(name = "token_type") + val tokenType: String? +) + +fun AuthenticateResponse.toModel() = AuthStatus.Authenticated( + accessToken = accessToken, + refreshToken = refreshToken, + status = status, + tokenType = tokenType +) diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/response/ErrorResponse.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/response/ErrorResponse.kt index ac90be0c4..a6338aae0 100644 --- a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/response/ErrorResponse.kt +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/response/ErrorResponse.kt @@ -5,7 +5,9 @@ import com.squareup.moshi.Json data class ErrorResponse( @Json(name = "message") - val message: String + val message: String, + @Json(name = "type") + val type: String? ) -internal fun ErrorResponse.toModel() = Error(message = message) +internal fun ErrorResponse.toModel() = Error(message = message, type = type) diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/AuthService.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/AuthService.kt new file mode 100644 index 000000000..85f581696 --- /dev/null +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/AuthService.kt @@ -0,0 +1,10 @@ +package co.nimblehq.template.xml.data.service + +import co.nimblehq.template.xml.data.response.AuthenticateResponse +import retrofit2.http.POST + +interface AuthService { + + @POST("refreshToken") + suspend fun refreshToken(): AuthenticateResponse +} diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/SessionManager.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/SessionManager.kt new file mode 100644 index 000000000..1e21a0075 --- /dev/null +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/SessionManager.kt @@ -0,0 +1,12 @@ +package co.nimblehq.template.xml.data.service + +import co.nimblehq.template.xml.data.response.AuthenticateResponse + +interface SessionManager { + + suspend fun getAccessToken(): String + + suspend fun getRefreshToken(): String + + suspend fun refresh(authenticateResponse: AuthenticateResponse) +} diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/authenticator/ApplicationRequestAuthenticator.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/authenticator/ApplicationRequestAuthenticator.kt new file mode 100644 index 000000000..c2ee59bb7 --- /dev/null +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/authenticator/ApplicationRequestAuthenticator.kt @@ -0,0 +1,93 @@ +package co.nimblehq.template.xml.data.service.authenticator + +import android.annotation.SuppressLint +import android.util.Log +import co.nimblehq.template.xml.data.extensions.parseErrorResponse +import co.nimblehq.template.xml.data.service.SessionManager +import co.nimblehq.template.xml.domain.exceptions.NoConnectivityException +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.last +import okhttp3.* + +const val REQUEST_HEADER_AUTHORIZATION = "Authorization" + +class ApplicationRequestAuthenticator( + private val tokenRefresher: TokenRefresher, + private val sessionManager: SessionManager +) : Authenticator { + + lateinit var okHttpClient: OkHttpClient + + private var retryCount = 0 + + @SuppressLint("CheckResult", "LongMethod", "TooGenericExceptionCaught") + override fun authenticate(route: Route?, response: Response): Request? = + runBlocking { + if (shouldSkipAuthenticationByErrorType(response)) { + return@runBlocking null + } + + // Due to unable to check the last retry succeeded + // So reset the retry count on the request first triggered by an automatic retry + if (response.priorResponse == null && retryCount != 0) { + retryCount = 0 + } + + if (retryCount >= MAX_ATTEMPTS) { + // Reset retry count once reached max attempts + retryCount = 0 + return@runBlocking null + } else { + retryCount++ + + val failedAccessToken = sessionManager.getAccessToken() + + try { + val refreshTokenResponse = tokenRefresher.refreshToken().last() + val newAccessToken = refreshTokenResponse.accessToken + + if (newAccessToken.isEmpty() || newAccessToken == failedAccessToken) { + // Avoid infinite loop if the new Token == old (failed) token + return@runBlocking null + } + + // Update the Interceptor (for future requests) + sessionManager.refresh(refreshTokenResponse) + + // Retry this failed request (401) with the new token + return@runBlocking response.request + .newBuilder() + .header(REQUEST_HEADER_AUTHORIZATION, newAccessToken) + .build() + } catch (e: Exception) { + Log.w("AUTHENTICATOR", "Failed to refresh token: $e") + return@runBlocking if (e !is NoConnectivityException) { + // cancel all pending requests + okHttpClient.dispatcher.cancelAll() + response.request + } else { + // do nothing + null + } + } + } + } + + private fun shouldSkipAuthenticationByErrorType(response: Response): Boolean { + val headers = response.request.headers + val skippingError = headers[HEADER_AUTHENTICATION_SKIPPING_ERROR_TYPE] + + if (!skippingError.isNullOrEmpty()) { + // Clone response body + // https://github.com/square/okhttp/issues/1240#issuecomment-330813274 + val responseBody = response.peekBody(Long.MAX_VALUE).toString() + val error = parseErrorResponse(responseBody) + + return error != null && skippingError == error.type + } + return false + } +} + +const val HEADER_AUTHENTICATION_SKIPPING_ERROR_TYPE = "Authentication-Skipping-ErrorType" +private const val MAX_ATTEMPTS = 3 diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/authenticator/TokenRefresher.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/authenticator/TokenRefresher.kt new file mode 100644 index 000000000..c0a940a17 --- /dev/null +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/authenticator/TokenRefresher.kt @@ -0,0 +1,9 @@ +package co.nimblehq.template.xml.data.service.authenticator + +import co.nimblehq.template.xml.data.response.AuthenticateResponse +import kotlinx.coroutines.flow.Flow + +interface TokenRefresher { + + suspend fun refreshToken(): Flow +} diff --git a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/providers/ApiServiceProvider.kt b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/providers/ApiServiceProvider.kt index 2c50d5a24..d525f8375 100644 --- a/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/providers/ApiServiceProvider.kt +++ b/template-xml/data/src/main/java/co/nimblehq/template/xml/data/service/providers/ApiServiceProvider.kt @@ -1,6 +1,7 @@ package co.nimblehq.template.xml.data.service.providers import co.nimblehq.template.xml.data.service.ApiService +import co.nimblehq.template.xml.data.service.AuthService import retrofit2.Retrofit object ApiServiceProvider { @@ -8,4 +9,8 @@ object ApiServiceProvider { fun getApiService(retrofit: Retrofit): ApiService { return retrofit.create(ApiService::class.java) } + + fun getAuthService(retrofit: Retrofit): AuthService { + return retrofit.create(AuthService::class.java) + } } diff --git a/template-xml/data/src/test/java/co/nimblehq/template/xml/data/test/MockUtil.kt b/template-xml/data/src/test/java/co/nimblehq/template/xml/data/test/MockUtil.kt index 98d11b780..8191b01e7 100644 --- a/template-xml/data/src/test/java/co/nimblehq/template/xml/data/test/MockUtil.kt +++ b/template-xml/data/src/test/java/co/nimblehq/template/xml/data/test/MockUtil.kt @@ -27,6 +27,7 @@ object MockUtil { } val errorResponse = ErrorResponse( - message = "message" + message = "message", + type = null ) } diff --git a/template-xml/domain/src/main/java/co/nimblehq/template/xml/domain/model/AuthStatus.kt b/template-xml/domain/src/main/java/co/nimblehq/template/xml/domain/model/AuthStatus.kt new file mode 100644 index 000000000..57d776354 --- /dev/null +++ b/template-xml/domain/src/main/java/co/nimblehq/template/xml/domain/model/AuthStatus.kt @@ -0,0 +1,11 @@ +package co.nimblehq.template.xml.domain.model + +sealed class AuthStatus { + + data class Authenticated( + val accessToken: String, + val refreshToken: String, + val status: String, + val tokenType: String? + ) +} diff --git a/template-xml/domain/src/main/java/co/nimblehq/template/xml/domain/model/Error.kt b/template-xml/domain/src/main/java/co/nimblehq/template/xml/domain/model/Error.kt index 84f140969..4fdf8bc16 100644 --- a/template-xml/domain/src/main/java/co/nimblehq/template/xml/domain/model/Error.kt +++ b/template-xml/domain/src/main/java/co/nimblehq/template/xml/domain/model/Error.kt @@ -1,5 +1,6 @@ package co.nimblehq.template.xml.domain.model data class Error( - val message: String + val message: String, + val type: String? )