JWT Authentication in Android: Using Retrofit and Authenticator

Ratko Kostov
5 min readOct 3, 2023

--

If your app utilizes JWT for authentication, you’ve landed in the right spot. Today, I’ll unfold my solution for embedding authentication within API requests, adeptly managing scenarios of expired access tokens, and seamlessly refreshing tokens ‘under the hood’ to ensure uninterrupted service for your users. Let’s dive in.

1. Define the JwtTokenManager Contract

This interface helps in storing and retrieving tokens

interface JwtTokenManager {
suspend fun saveAccessJwt(token: String)
suspend fun saveRefreshJwt(token: String)
suspend fun getAccessJwt(): String?
suspend fun getRefreshJwt(): String?
suspend fun clearAllTokens()
}

2. Implementing JwtTokenManager

Leverage DataStore or your preferred storage for this implementation:

class JwtTokenDataStore @Inject constructor(private val dataStore: DataStore<Preferences>) :
JwtTokenManager {

companion object {
val ACCESS_JWT_KEY = stringPreferencesKey("access_jwt")
val REFRESH_JWT_KEY = stringPreferencesKey("refresh_jwt")
}

override suspend fun saveAccessJwt(token: String) {
dataStore.edit { preferences ->
preferences[ACCESS_JWT_KEY] = token
}
}

override suspend fun saveRefreshJwt(token: String) {
dataStore.edit { preferences ->
preferences[REFRESH_JWT_KEY] = token
}
}

override suspend fun getAccessJwt(): String? {
return dataStore.data.map { preferences ->
preferences[ACCESS_JWT_KEY]
}.first()
}

override suspend fun getRefreshJwt(): String? {
return dataStore.data.map { preferences ->
preferences[REFRESH_JWT_KEY]
}.first()
}

override suspend fun clearAllTokens() {
dataStore.edit { preferences ->
preferences.remove(ACCESS_JWT_KEY)
preferences.remove(REFRESH_JWT_KEY)
}
}
}

3. Dependency Injection

Inject DataStore and the JwtTokenManager implementation:

@[Provides Singleton]
fun provideDataStore(@ApplicationContext appContext: Context): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(
produceNewData = { emptyPreferences() }
),
produceFile = { appContext.preferencesDataStoreFile(AUTH_PREFERENCES) }
)
}

@[Provides Singleton]
fun provideJwtTokenManager(dataStore: DataStore<Preferences>): JwtTokenManager {
return JwtTokenDataStore(dataStore = dataStore)
}

All right, we’ve now implemented the code to manage tokens. Next, let’s discuss how to structure API calls using these tokens and how to handle token refreshes in the event of an unauthorized (401) response.

4. Interceptor implementation

Create AccessTokenInterceptor this interceptor will be used to provide the access token in the request.

class AccessTokenInterceptor @Inject constructor(
private val tokenManager: JwtTokenManager,
) : Interceptor {
companion object {
const val HEADER_AUTHORIZATION = "Authorization"
const val TOKEN_TYPE = "Bearer"
}
override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking {
tokenManager.getAccessJwt()
}
val request = chain.request().newBuilder()
request.addHeader(HEADER_AUTHORIZATION, "$TOKEN_TYPE $token")
return chain.proceed(request.build())
}
}

Create RefreshTokenInterceptor.This interceptor will be used to provide the refresh token in the request.

class RefreshTokenInterceptor @Inject constructor(
private val tokenManager: JwtTokenManager,
) : Interceptor {
companion object {
const val HEADER_AUTHORIZATION = "Authorization"
const val TOKEN_TYPE = "Bearer"
}
override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking {
tokenManager.getRefreshJwt()
}
val request = chain.request().newBuilder()
request.addHeader(HEADER_AUTHORIZATION, "$TOKEN_TYPE $token")
return chain.proceed(request.build())
}
}

5. Authenticator Implementation

Next, create an AuthAuthenticator class where the critical logic is implemented. This class should extend Authenticator and override the authenticate method, which will contain the logic to handle unauthorized responses. For this purpose, we will utilize TokenManager to manage the tokens and RefreshTokenService to execute API calls for new tokens.

class AuthAuthenticator @Inject constructor(
private val tokenManager: JwtTokenManager,
private val refreshTokenService: RefreshTokenService
) : Authenticator {
companion object {
const val HEADER_AUTHORIZATION = "Authorization"
const val TOKEN_TYPE = "Bearer"
}
override fun authenticate(route: Route?, response: Response): Request? {
val currentToken = runBlocking {
tokenManager.getAccessJwt()
}
synchronized(this) {
val updatedToken = runBlocking {
tokenManager.getAccessJwt()
}
val token = if (currentToken != updatedToken) updatedToken else {
val newSessionResponse = runBlocking { refreshTokenService.refreshToken() }
if (newSessionResponse.isSuccessful && newSessionResponse.body() != null) {
newSessionResponse.body()?.let { body ->
runBlocking {
tokenManager.saveAccessJwt(body.accessToken)
tokenManager.saveRefreshJwt(body.refreshToken)
}
body.accessToken
}
} else null
}
return if (token != null) response.request.newBuilder()
.header(HEADER_AUTHORIZATION, "$TOKEN_TYPE $token")
.build() else null
}
}
}
  • Retrieve Current Token:
  • currentToken is retrieved using tokenManager.getAccessJwt(), executed within a runBlocking coroutine.
  • Synchronization Block:
  • A synchronized block begins, ensuring thread-safe operations for the enclosed logic.
  • Retrieve Updated Token:
  • Within the synchronized block, updatedToken is retrieved in a similar fashion as currentToken.
  • Token Validation and Potential Refresh:
  • Check if another thread has updated the access token.
  • If currentToken and updatedToken differ, updatedToken is used.
  • If they are identical, a new token is attempted to be fetched using the getNewToken() method.
  • Upon successfully fetching a new token and ensuring the response body is non-null, new access and refresh tokens are saved using tokenManager.
  • The new access token is assigned to token.
  • If the new token fetch fails or returns a null body, token is assigned null.
  • Modify and Return API Request:
  • If token is non-null, the original API request (response.request) is modified, appending an Authorization header with the valid token, and returned.
  • If token is null, null is returned, not modifying the API request (this happens when refresh token is also expired and in most cases you will force user to login again).

We need to add the interceptors and authenticator in the OkHttpClient.

6. Configuring OkHttpClient

Configure different OkHttpClient instances for different purposes:

Authenticated Client: For requests requiring the access token.


@[Provides Singleton AuthenticatedClient]
fun provideAccessOkHttpClient(
accessTokenInterceptor: AccessTokenInterceptor,
authAuthenticator: AuthAuthenticator
): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
return OkHttpClient.Builder()
.authenticator(authAuthenticator)
.addInterceptor(accessTokenInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}

TokenRefreshClient Client: For the refresh token request.

@[Provides Singleton TokenRefreshClient]
fun provideRefreshOkHttpClient(
refreshTokenInterceptor: RefreshTokenInterceptor
): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(refreshTokenInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}

Public Client: For requests that don’t require authentication.

@[Provides Singleton PublicClient]
fun provideUnauthenticatedOkHttpClient(): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}

7. Using Qualifiers

Make use of qualifiers to instruct the compiler on the correct OkHttpClient instance to initialize:

@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class AuthenticatedClient

@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class TokenRefreshClient

@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class PublicClient

8. Building Retrofit Instances

For authenticatedAPI calls:

@[Provides Singleton]
fun provideAuthenticationApi(@AuthenticatedClient okHttpClient: OkHttpClient): UserService {
return Retrofit.Builder()
.baseUrl(Constants.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClient)
.build()
.create(UserService::class.java)
interface UserService {
@GET("office/{officeId}/users/{userId}")
suspend fun fetchUser(
@Path("officeId") officeId: Long,
@Path("userId") userId: Long
): Response<UserNetworkResponse>
}

For refresh token:

@[Provides Singleton]
fun provideRetrofit(@TokenRefreshClient okHttpClient: OkHttpClient): RefreshTokenService {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClient)
.build()
.create(RefreshTokenService::class.java)
}
interface RefreshTokenService {
@POST("v3/auth/refresh-token")
suspend fun refreshToken(): Response<AuthNetworkResponse>
}

For API calls without authentication:

@[Provides Singleton] 
fun provideAuthenticationApi(@PublicClient okHttpClient: OkHttpClient): AuthService {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClient)
.build()
.create(AuthService::class.java)
}
interface AuthService {
@POST("v3/auth/authenticate")
suspend fun login(
@Body body: LoginRequest
): Response<AuthNetworkResponse>
}

Lastly, don’t forget to store the tokens when you are successfully authenticated.

override suspend fun login(emailLoginRequest: EmailLoginRequest): ResultOf<Unit> {
return fetchDataFromApi(call = { authService.login(emailLoginRequest) },
transform = { response ->
jwtTokenManager.saveAccessJwt(response.accessToken)
jwtTokenManager.saveRefreshJwt(response.refreshToken)
})
}

This comprehensive guide offers a structured way to manage JWT tokens in applications, particularly for embedding authentication in API requests 🛡️. Through designated interceptors, authenticators, and OkHttpClient configurations, the solution ensures uninterrupted service by adeptly handling and refreshing expired access tokens ⏳🔄. Integrating these steps in your app will ensure seamless token management and an enhanced user experience.
Thank you! Happy coding! ✨🚀

--

--