Skip to content

Commit c79ef76

Browse files
committed
[#228] Add TokenAuthenticator to template-xml
1 parent 66b5f5d commit c79ef76

File tree

17 files changed

+305
-14
lines changed

17 files changed

+305
-14
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package co.nimblehq.template.xml.di
2+
3+
import javax.inject.Qualifier
4+
5+
@Qualifier
6+
@Retention(AnnotationRetention.BINARY)
7+
annotation class Authenticate
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package co.nimblehq.template.xml.di.modules
2+
3+
import co.nimblehq.template.xml.data.repository.SessionManagerImpl
4+
import co.nimblehq.template.xml.data.repository.TokenRefresherImpl
5+
import co.nimblehq.template.xml.data.service.AuthService
6+
import co.nimblehq.template.xml.data.service.SessionManager
7+
import co.nimblehq.template.xml.data.service.authenticator.TokenRefresher
8+
import dagger.Module
9+
import dagger.Provides
10+
import dagger.hilt.InstallIn
11+
import dagger.hilt.components.SingletonComponent
12+
13+
@Module
14+
@InstallIn(SingletonComponent::class)
15+
class AuthModule {
16+
17+
@Provides
18+
fun provideAuthService(authService: AuthService): TokenRefresher = TokenRefresherImpl(authService)
19+
20+
@Provides
21+
fun provideSessionManager(): SessionManager = SessionManagerImpl()
22+
}

template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/OkHttpClientModule.kt

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package co.nimblehq.template.xml.di.modules
22

33
import android.content.Context
44
import co.nimblehq.template.xml.BuildConfig
5+
import co.nimblehq.template.xml.data.service.SessionManager
6+
import co.nimblehq.template.xml.data.service.authenticator.ApplicationRequestAuthenticator
7+
import co.nimblehq.template.xml.data.service.authenticator.TokenRefresher
8+
import co.nimblehq.template.xml.di.Authenticate
59
import com.chuckerteam.chucker.api.*
610
import dagger.Module
711
import dagger.Provides
@@ -20,16 +24,42 @@ class OkHttpClientModule {
2024

2125
@Provides
2226
fun provideOkHttpClient(
23-
chuckerInterceptor: ChuckerInterceptor
24-
) = OkHttpClient.Builder().apply {
25-
if (BuildConfig.DEBUG) {
26-
addInterceptor(HttpLoggingInterceptor().apply {
27-
level = HttpLoggingInterceptor.Level.BODY
28-
})
29-
addInterceptor(chuckerInterceptor)
30-
readTimeout(READ_TIME_OUT, TimeUnit.SECONDS)
31-
}
32-
}.build()
27+
chuckerInterceptor: ChuckerInterceptor,
28+
sessionManager: SessionManager,
29+
tokenRefresher: TokenRefresher?
30+
): OkHttpClient {
31+
val authenticator =
32+
tokenRefresher?.let { ApplicationRequestAuthenticator(it, sessionManager) }
33+
return OkHttpClient.Builder()
34+
.apply {
35+
if (BuildConfig.DEBUG) {
36+
addInterceptor(HttpLoggingInterceptor().apply {
37+
level = HttpLoggingInterceptor.Level.BODY
38+
})
39+
addInterceptor(chuckerInterceptor)
40+
readTimeout(READ_TIME_OUT, TimeUnit.SECONDS)
41+
}
42+
}
43+
.build()
44+
.apply { authenticator?.okHttpClient = this }
45+
46+
}
47+
48+
@Authenticate
49+
@Provides
50+
fun provideAuthOkHttpClient(chuckerInterceptor: ChuckerInterceptor): OkHttpClient {
51+
return OkHttpClient.Builder()
52+
.apply {
53+
if (BuildConfig.DEBUG) {
54+
addInterceptor(HttpLoggingInterceptor().apply {
55+
level = HttpLoggingInterceptor.Level.BODY
56+
})
57+
addInterceptor(chuckerInterceptor)
58+
readTimeout(READ_TIME_OUT, TimeUnit.SECONDS)
59+
}
60+
}
61+
.build()
62+
}
3363

3464
@Provides
3565
fun provideChuckerInterceptor(

template-xml/app/src/main/java/co/nimblehq/template/xml/di/modules/RetrofitModule.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package co.nimblehq.template.xml.di.modules
22

33
import co.nimblehq.template.xml.BuildConfig
44
import co.nimblehq.template.xml.data.service.ApiService
5+
import co.nimblehq.template.xml.data.service.AuthService
56
import co.nimblehq.template.xml.data.service.providers.ApiServiceProvider
67
import co.nimblehq.template.xml.data.service.providers.ConverterFactoryProvider
78
import co.nimblehq.template.xml.data.service.providers.RetrofitProvider
9+
import co.nimblehq.template.xml.di.Authenticate
810
import com.squareup.moshi.Moshi
911
import dagger.Module
1012
import dagger.Provides
@@ -37,4 +39,21 @@ class RetrofitModule {
3739
@Provides
3840
fun provideApiService(retrofit: Retrofit): ApiService =
3941
ApiServiceProvider.getApiService(retrofit)
42+
43+
44+
@Authenticate
45+
@Provides
46+
fun provideAuthRetrofit(
47+
baseUrl: String,
48+
@Authenticate okHttpClient: OkHttpClient,
49+
converterFactory: Converter.Factory,
50+
): Retrofit = RetrofitProvider
51+
.getRetrofitBuilder(baseUrl, okHttpClient, converterFactory)
52+
.build()
53+
54+
@Provides
55+
fun provideAuthService(
56+
@Authenticate retrofit: Retrofit
57+
): AuthService =
58+
ApiServiceProvider.getAuthService(retrofit)
4059
}

template-xml/data/src/main/java/co/nimblehq/template/xml/data/extensions/ResponseMapping.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,15 @@ private fun parseErrorResponse(response: Response<*>?): ErrorResponse? {
5454
null
5555
}
5656
}
57+
58+
fun parseErrorResponse(jsonString: String?): ErrorResponse? {
59+
return try {
60+
val moshi = MoshiBuilderProvider.moshiBuilder.build()
61+
val adapter = moshi.adapter(ErrorResponse::class.java)
62+
adapter.fromJson(jsonString.orEmpty())
63+
} catch (exception: IOException) {
64+
null
65+
} catch (exception: JsonDataException) {
66+
null
67+
}
68+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package co.nimblehq.template.xml.data.repository
2+
3+
import co.nimblehq.template.xml.data.response.AuthenticateResponse
4+
import co.nimblehq.template.xml.data.service.SessionManager
5+
6+
class SessionManagerImpl: SessionManager {
7+
8+
override suspend fun getAccessToken(): String {
9+
TODO("Not yet implemented")
10+
}
11+
12+
override suspend fun getRefreshToken(): String {
13+
TODO("Not yet implemented")
14+
}
15+
16+
override suspend fun refresh(authenticateResponse: AuthenticateResponse) {
17+
TODO("Not yet implemented")
18+
}
19+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package co.nimblehq.template.xml.data.repository
2+
3+
import co.nimblehq.template.xml.data.extensions.flowTransform
4+
import co.nimblehq.template.xml.data.response.AuthenticateResponse
5+
import co.nimblehq.template.xml.data.service.AuthService
6+
import co.nimblehq.template.xml.data.service.authenticator.TokenRefresher
7+
import kotlinx.coroutines.flow.Flow
8+
9+
class TokenRefresherImpl constructor(
10+
private val authService: AuthService
11+
) : TokenRefresher {
12+
13+
override suspend fun refreshToken(): Flow<AuthenticateResponse> = flowTransform {
14+
authService.refreshToken()
15+
}
16+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package co.nimblehq.template.xml.data.response
2+
3+
import co.nimblehq.template.xml.domain.model.AuthStatus
4+
import com.squareup.moshi.Json
5+
6+
data class AuthenticateResponse(
7+
@Json(name = "access_token")
8+
val accessToken: String,
9+
@Json(name = "refresh_token")
10+
val refreshToken: String,
11+
@Json(name = "status")
12+
val status: String,
13+
@Json(name = "token_type")
14+
val tokenType: String?
15+
)
16+
17+
fun AuthenticateResponse.toAuthenticated() = AuthStatus.Authenticated(
18+
accessToken = accessToken,
19+
refreshToken = refreshToken,
20+
status = status,
21+
tokenType = tokenType
22+
)

template-xml/data/src/main/java/co/nimblehq/template/xml/data/response/ErrorResponse.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import com.squareup.moshi.Json
55

66
data class ErrorResponse(
77
@Json(name = "message")
8-
val message: String
8+
val message: String,
9+
@Json(name = "type")
10+
val type: String?
911
)
1012

11-
internal fun ErrorResponse.toModel() = Error(message = message)
13+
internal fun ErrorResponse.toModel() = Error(message = message, type = type)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package co.nimblehq.template.xml.data.service
2+
3+
import co.nimblehq.template.xml.data.response.AuthenticateResponse
4+
import retrofit2.http.POST
5+
6+
interface AuthService {
7+
8+
@POST("refreshToken")
9+
suspend fun refreshToken(): AuthenticateResponse
10+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package co.nimblehq.template.xml.data.service
2+
3+
import co.nimblehq.template.xml.data.response.AuthenticateResponse
4+
5+
interface SessionManager {
6+
7+
suspend fun getAccessToken(): String
8+
9+
suspend fun getRefreshToken(): String
10+
11+
suspend fun refresh(authenticateResponse: AuthenticateResponse)
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package co.nimblehq.template.xml.data.service.authenticator
2+
3+
import android.annotation.SuppressLint
4+
import android.util.Log
5+
import co.nimblehq.template.xml.data.extensions.parseErrorResponse
6+
import co.nimblehq.template.xml.data.service.SessionManager
7+
import co.nimblehq.template.xml.domain.exceptions.NoConnectivityException
8+
import kotlinx.coroutines.*
9+
import kotlinx.coroutines.flow.last
10+
import okhttp3.*
11+
12+
const val REQUEST_HEADER_AUTHORIZATION = "Authorization"
13+
14+
class ApplicationRequestAuthenticator(
15+
private val tokenRefresher: TokenRefresher,
16+
private val sessionManager: SessionManager
17+
) : Authenticator {
18+
19+
lateinit var okHttpClient: OkHttpClient
20+
21+
private var retryCount = 0
22+
23+
@SuppressLint("CheckResult", "LongMethod", "TooGenericExceptionCaught")
24+
override fun authenticate(route: Route?, response: Response): Request? =
25+
runBlocking {
26+
if (shouldSkipAuthenticationByErrorType(response)) {
27+
return@runBlocking null
28+
}
29+
30+
// Due to unable to check the last retry succeeded
31+
// So reset the retry count on the request first triggered by an automatic retry
32+
if (response.priorResponse == null && retryCount != 0) {
33+
retryCount = 0
34+
}
35+
36+
if (retryCount >= MAX_ATTEMPTS) {
37+
// Reset retry count once reached max attempts
38+
retryCount = 0
39+
return@runBlocking null
40+
} else {
41+
retryCount++
42+
43+
val failedAccessToken = sessionManager.getAccessToken()
44+
45+
try {
46+
val refreshTokenResponse = tokenRefresher.refreshToken().last()
47+
val newAccessToken = refreshTokenResponse.accessToken
48+
49+
if (newAccessToken.isEmpty() || newAccessToken == failedAccessToken) {
50+
// Avoid infinite loop if the new Token == old (failed) token
51+
return@runBlocking null
52+
}
53+
54+
// Update the Interceptor (for future requests)
55+
sessionManager.refresh(refreshTokenResponse)
56+
57+
// Retry this failed request (401) with the new token
58+
return@runBlocking response.request
59+
.newBuilder()
60+
.header(REQUEST_HEADER_AUTHORIZATION, newAccessToken)
61+
.build()
62+
} catch (e: Exception) {
63+
Log.w("AUTHENTICATOR", "Failed to refresh token: $e")
64+
return@runBlocking if (e !is NoConnectivityException) {
65+
// cancel all pending requests
66+
okHttpClient.dispatcher.cancelAll()
67+
response.request
68+
} else {
69+
// do nothing
70+
null
71+
}
72+
}
73+
}
74+
}
75+
76+
private fun shouldSkipAuthenticationByErrorType(response: Response): Boolean {
77+
val headers = response.request.headers
78+
val skippingError = headers[HEADER_AUTHENTICATION_SKIPPING_ERROR_TYPE]
79+
80+
if (!skippingError.isNullOrEmpty()) {
81+
// Clone response body
82+
// https://github.com/square/okhttp/issues/1240#issuecomment-330813274
83+
val responseBody = response.peekBody(Long.MAX_VALUE).toString()
84+
val error = parseErrorResponse(responseBody)
85+
86+
return error != null && skippingError == error.type
87+
}
88+
return false
89+
}
90+
}
91+
92+
const val HEADER_AUTHENTICATION_SKIPPING_ERROR_TYPE = "Authentication-Skipping-ErrorType"
93+
private const val MAX_ATTEMPTS = 3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package co.nimblehq.template.xml.data.service.authenticator
2+
3+
import co.nimblehq.template.xml.data.response.AuthenticateResponse
4+
import kotlinx.coroutines.flow.Flow
5+
6+
interface TokenRefresher {
7+
8+
suspend fun refreshToken(): Flow<AuthenticateResponse>
9+
}
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package co.nimblehq.template.xml.data.service.providers
22

33
import co.nimblehq.template.xml.data.service.ApiService
4+
import co.nimblehq.template.xml.data.service.AuthService
45
import retrofit2.Retrofit
56

67
object ApiServiceProvider {
78

89
fun getApiService(retrofit: Retrofit): ApiService {
910
return retrofit.create(ApiService::class.java)
1011
}
12+
13+
fun getAuthService(retrofit: Retrofit): AuthService {
14+
return retrofit.create(AuthService::class.java)
15+
}
1116
}

template-xml/data/src/test/java/co/nimblehq/template/xml/data/test/MockUtil.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ object MockUtil {
2727
}
2828

2929
val errorResponse = ErrorResponse(
30-
message = "message"
30+
message = "message",
31+
type = null
3132
)
3233
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package co.nimblehq.template.xml.domain.model
2+
3+
sealed class AuthStatus {
4+
5+
data class Authenticated(
6+
val accessToken: String,
7+
val refreshToken: String,
8+
val status: String,
9+
val tokenType: String?
10+
)
11+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package co.nimblehq.template.xml.domain.model
22

33
data class Error(
4-
val message: String
4+
val message: String,
5+
val type: String?
56
)

0 commit comments

Comments
 (0)