Skip to content

Commit

Permalink
[#228] Add TokenAuthenticator to template-xml
Browse files Browse the repository at this point in the history
  • Loading branch information
kaungkhantsoe committed Jan 6, 2023
1 parent 66b5f5d commit a0a9a43
Show file tree
Hide file tree
Showing 17 changed files with 314 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package co.nimblehq.template.xml.di

import javax.inject.Qualifier

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Authenticate
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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<AuthenticateResponse> = flowTransform {
authService.refreshToken()
}
}
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<AuthenticateResponse>
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
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 {

fun getApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}

fun getAuthService(retrofit: Retrofit): AuthService {
return retrofit.create(AuthService::class.java)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ object MockUtil {
}

val errorResponse = ErrorResponse(
message = "message"
message = "message",
type = null
)
}
Original file line number Diff line number Diff line change
@@ -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?
)
}
Loading

0 comments on commit a0a9a43

Please sign in to comment.