JWT Authentication in Android: Using Retrofit and Authenticator
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 usingtokenManager.getAccessJwt()
, executed within arunBlocking
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 ascurrentToken
. - Token Validation and Potential Refresh:
- Check if another thread has updated the access token.
- If
currentToken
andupdatedToken
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 assignednull
. - 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 authenticated
API 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! ✨🚀