In this article, we’ll be implementing JWT (JSON Web Token) authentication and silent refresh with Retrofit Interceptor and Authenticator. Silent token refresh is necessary when token expires and response from the server is 401 Unauthorized. Instead of logging out the user, we’ll refresh token and continue the request.
Table of Contents
Getting Started
API Endpoint & Models
Retrofit Setup
Token DataStore
Helpers
Repository & View Model
Interceptor & Authenticator
Hilt Module
UI
Prerequisites
We’ll use LiveData, Hilt, Retrofit, OkHttp and DataStore in this article, so it’s better you know how they work.
I’ll skip some parts of the Hilt setup, so if you want to see the source code, you can find the link at the bottom of this article.
Getting Started
App level build.gradle file,
//DataStore
implementation "androidx.datastore:datastore-preferences:1.0.0"
//Retrofit
def retrofit_version = "2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
//Hilt
def hilt_version = "2.44"
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
//OkHttp
def okhttp_version = "4.10.0"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
Don’t forget to add Internet permission in AndroidManifest.xml,
<uses-permission android:name="android.permission.INTERNET" />
API Endpoint & Models
I’ve created my own backend to test token authentication. You can check it from this link. MrNtlu/JWT-Test-API (github.com)
Also, if you want to run it locally, you can follow these instructions. It’s very simple Rest API. Token will be refreshed in every 30 seconds and we’ll have to refresh the old token with the new one.
Note: You can get timeout error while testing. It’s free tier on render.com so it has limitations. Please keep trying until you get the result successfully.
We’ll need to create couple of request/response models for Rest API,
POST auth/login request,
data class Auth(
@SerializedName("email_address")
val email: String,
val password: String
)
POST auth/login and GET auth/refresh response,
data class LoginResponse(
@SerializedName("access_token")
val token: String
)
GET user/info response,
Please put them into different files. I’ve put them into one code block to make it easier to read.
data class UserInfoResponse(
@SerializedName("data")
val userInfo: UserInfo,
val message: String
)
data class UserInfo(
val _id: String,
val email_address: String
)
Finally ErrorResponse ,
data class ErrorResponse(
val code: Int,
val message: String
)
Retrofit Setup
We’ll have two separate API Service files. First one is AuthApiService ,
interface AuthApiService {
@POST("auth/login")
suspend fun login(
@Body auth: Auth,
): Response<LoginResponse>
@GET("auth/refresh")
suspend fun refreshToken(
@Header("Authorization") token: String,
): Response<LoginResponse>
}
Second one is MainApiService,
interface MainApiService {
@GET("user/info")
suspend fun getUserInfo(): Response<UserInfoResponse>
}
That’s it for now, we’ll create the Retrofit instance later in this article.
Token DataStore
When a user logged in and reopened the application, we’ll need a way to save that token and use it. DataStore will help us for this problem. We’ll save the token locally and use it when needed.
class TokenManager(private val context: Context) {
companion object {
private val TOKEN_KEY = stringPreferencesKey("jwt_token")
}
fun getToken(): Flow<String?> {
return context.dataStore.data.map { preferences ->
preferences[TOKEN_KEY]
}
}
suspend fun saveToken(token: String) {
context.dataStore.edit { preferences ->
preferences[TOKEN_KEY] = token
}
}
suspend fun deleteToken() {
context.dataStore.edit { preferences ->
preferences.remove(TOKEN_KEY)
}
}
}
That’s it. There is nothing special. Just a small note, context.dataStore won’t work and you’ll see error. Don’t worry. We’ll add it later on Hilt Module part.
Helpers
Before we start implementing Repository and View Model, we’ll create helper classes.
First ApiResponse , this will help us manage the API requests and UI state management. We’ll be able to show the necessary UI to the user and extract data easily.
sealed class ApiResponse<out T> {
object Loading: ApiResponse<Nothing>()
data class Success<out T>(
val data: T
): ApiResponse<T>()
data class Failure(
val errorMessage: String,
val code: Int,
): ApiResponse<Nothing>()
}
Second is apiRequestFlow , this class will help us to make API calls on the IO thread and emit the state. Currently the timeout is set to 20 seconds, you can change it depending on your needs.
fun<T> apiRequestFlow(call: suspend () -> Response<T>): Flow<ApiResponse<T>> = flow {
emit(ApiResponse.Loading)
withTimeoutOrNull(20000L) {
val response = call()
try {
if (response.isSuccessful) {
response.body()?.let { data ->
emit(ApiResponse.Success(data))
}
} else {
response.errorBody()?.let { error ->
error.close()
val parsedError: ErrorResponse = Gson().fromJson(error.charStream(), ErrorResponse::class.java)
emit(ApiResponse.Failure(parsedError.message, parsedError.code))
}
}
} catch (e: Exception) {
emit(ApiResponse.Failure(e.message ?: e.toString(), 400))
}
} ?: emit(ApiResponse.Failure("Timeout! Please try again.", 408))
}.flowOn(Dispatchers.IO)
Finally BaseViewModel, this class will help us catch and present errors on correct thread and cancel the job when necessary.
open class BaseViewModel : ViewModel() {
private var mJob: Job? = null
protected fun <T> baseRequest(liveData: MutableLiveData<T>, errorHandler: CoroutinesErrorHandler, request: () -> Flow<T>) {
mJob = viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, error ->
viewModelScope.launch(Dispatchers.Main) {
errorHandler.onError(error.localizedMessage ?: "Error occured! Please try again.")
}
}){
request().collect {
withContext(Dispatchers.Main) {
liveData.value = it
}
}
}
}
override fun onCleared() {
super.onCleared()
mJob?.let {
if (it.isActive) {
it.cancel()
}
}
}
}
interface CoroutinesErrorHandler {
fun onError(message:String)
}
That’s it. Now we can start implementing Repository & ViewModel.
Repository & View Model
Since we’ve already implemented apiRequestFlow , repository classes will be very short.
class AuthRepository @Inject constructor(
private val authApiService: AuthApiService,
) {
fun login(auth: Auth) = apiRequestFlow {
authApiService.login(auth)
}
}
class MainRepository @Inject constructor(
private val mainApiService: MainApiService,
) {
fun getUserInfo() = apiRequestFlow {
mainApiService.getUserInfo()
}
}
Same goes for view models, only thing that is extra is LiveData objects. Both view models extend BaseViewModel which we’ve created earlier.
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository,
): BaseViewModel() {
private val _loginResponse = MutableLiveData<ApiResponse<LoginResponse>>()
val loginResponse = _loginResponse
fun login(auth: Auth, coroutinesErrorHandler: CoroutinesErrorHandler) = baseRequest(
_loginResponse,
coroutinesErrorHandler
) {
authRepository.login(auth)
}
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val mainRepository: MainRepository,
): BaseViewModel() {
private val _userInfoResponse = MutableLiveData<ApiResponse<UserInfoResponse>>()
val userInfoResponse = _userInfoResponse
fun getUserInfo(coroutinesErrorHandler: CoroutinesErrorHandler) = baseRequest(
_userInfoResponse,
coroutinesErrorHandler,
) {
mainRepository.getUserInfo()
}
}
Finally, we are going to create one more view model for Token.
@HiltViewModel
class TokenViewModel @Inject constructor(
private val tokenManager: TokenManager,
): ViewModel() {
val token = MutableLiveData<String?>()
init {
viewModelScope.launch(Dispatchers.IO) {
tokenManager.getToken().collect {
withContext(Dispatchers.Main) {
token.value = it
}
}
}
}
fun saveToken(token: String) {
viewModelScope.launch(Dispatchers.IO) {
tokenManager.saveToken(token)
}
}
fun deleteToken() {
viewModelScope.launch(Dispatchers.IO) {
tokenManager.deleteToken()
}
}
}
In this view model, we have only 1 variable which is token live data object. When we initialize the view model, we are setting token value to tokenManager.token and listen for changes.
Whenever we call saveToken or deleteToken , token value will be updated on tokenManager.getToken().collect so we don’t have to worry about anything else.
That’s it! Now we can implement Interceptor and Authenticator.
Interceptor & Authenticator
Interceptor is very simple,
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager,
): Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking {
tokenManager.getToken().first()
}
val request = chain.request().newBuilder()
request.addHeader("Authorization", "Bearer $token")
return chain.proceed(request.build())
}
}
We are getting the token from tokenManager by blocking the current thread until completion with runBlocking. After that, we add Authorization header to the ongoing request.
There is a small problem, what if token is empty or outdated? The solution is simple, Authenticator.
Performs either preemptive authentication before connecting to a proxy server, or reactive authentication after receiving a challenge from either an origin web server or proxy server.
When reactive authentication is requested by an origin web server, the response code is 401 and the implementation should respond with a new request that sets the “Authorization” header.
For more info, you can click the link.
class AuthAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
): Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val token = runBlocking {
tokenManager.getToken().first()
}
return runBlocking {
val newToken = getNewToken(token)
if (!newToken.isSuccessful || newToken.body() == null) { //Couldn't refresh the token, so restart the login process
tokenManager.deleteToken()
}
newToken.body()?.let {
tokenManager.saveToken(it.token)
response.request.newBuilder()
.header("Authorization", "Bearer ${it.token}")
.build()
}
}
}
private suspend fun getNewToken(refreshToken: String?): retrofit2.Response<LoginResponse> {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
val okHttpClient = OkHttpClient.Builder().addInterceptor(loggingInterceptor).build()
val retrofit = Retrofit.Builder()
.baseUrl("https://jwt-test-api.onrender.com/api/")
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClient)
.build()
val service = retrofit.create(AuthApiService::class.java)
return service.refreshToken("Bearer $refreshToken")
}
}
Authenticator will not intercept every time, it’s going to be reactive to response and react accordingly. So, if token is valid, authenticator won’t interfere but if token is not valid, it’ll intercept and refresh the token.
Again, before we do anything we get the token with runBlocking. After it, we use getNewToken function to request new token with the old token then we are checking if we’ve retrieved the new token successfully.
If we couldn’t get new token, we use deleteToken function which will trigger TokenViewModel token live data and on UI part we’ll force user to logout.
If we’ve retrieved the new refresh token successfully, we’ll update the old token value with new token and make the request.
As you can see from the image, we make the request (1) but token is expired, so we make another request to auth/refresh for a new token and successfully retrieve it (2). After that we make the request to user/info and successfully fetch the data (3).
Hilt Module
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "data_store")
@Module
@InstallIn(SingletonComponent::class)
class SingletonModule {
@Singleton
@Provides
fun provideTokenManager(@ApplicationContext context: Context): TokenManager = TokenManager(context)
@Singleton
@Provides
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
authAuthenticator: AuthAuthenticator,
): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.authenticator(authAuthenticator)
.build()
}
@Singleton
@Provides
fun provideAuthInterceptor(tokenManager: TokenManager): AuthInterceptor =
AuthInterceptor(tokenManager)
@Singleton
@Provides
fun provideAuthAuthenticator(tokenManager: TokenManager): AuthAuthenticator =
AuthAuthenticator(tokenManager)
@Singleton
@Provides
fun provideRetrofitBuilder(): Retrofit.Builder =
Retrofit.Builder()
.baseUrl("https://jwt-test-api.onrender.com/api/")
.addConverterFactory(GsonConverterFactory.create())
@Singleton
@Provides
fun provideAuthAPIService(retrofit: Retrofit.Builder): AuthApiService =
retrofit
.build()
.create(AuthApiService::class.java)
@Singleton
@Provides
fun provideMainAPIService(okHttpClient: OkHttpClient, retrofit: Retrofit.Builder): MainApiService =
retrofit
.client(okHttpClient)
.build()
.create(MainApiService::class.java)
}
This class doesn’t need much of an explanation but some small notes,
Like I’ve mentioned earlier, we set the Context.dataStore extension at the top of this class.
HttpLogginInterceptor which logs request and response information.
Instead of providing Retrofit instance, I’ve providedRetrofit.Builder because AuthApiService won’t use OkHttpClient but MainApiService will. To make the separation I am passing only the Retrofit.Builder instance.
That’s it. Now we can implement UI.
UI
We’ll have Login and Main fragments.
@AndroidEntryPoint
class LoginFragment : Fragment() {
private val viewModel: AuthViewModel by viewModels()
private val tokenViewModel: TokenViewModel by activityViewModels()
private lateinit var navController: NavController
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_login, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
navController = Navigation.findNavController(view)
val loginTV = view.findViewById<TextView>(R.id.loginTV)
tokenViewModel.token.observe(viewLifecycleOwner) { token ->
if (token != null)
navController.navigate(R.id.action_loginFragment_to_main_nav_graph)
}
viewModel.loginResponse.observe(viewLifecycleOwner) {
when(it) {
is ApiResponse.Failure -> loginTV.text = it.errorMessage
ApiResponse.Loading -> loginTV.text = "Loading"
is ApiResponse.Success -> {
tokenViewModel.saveToken(it.data.token)
}
}
}
view.findViewById<Button>(R.id.loginButton).setOnClickListener {
viewModel.login(
Auth("test@gmail.com", "123Test"),
object: CoroutinesErrorHandler {
override fun onError(message: String) {
loginTV.text = "Error! $message"
}
}
)
}
}
}
Views are poorly set, please ignore them.
In LoginFragment, we have two view models, AuthViewModel and TokenViewModel. We’ve used activityViewModels because TokenViewModel should be scoped to Activity, not fragment.
activityViewModels() is pretty self-explanatory. It is used to scoped to its activity and when its activity get destroy, activityViewModels() will be destroyed as well. Source Link.
We are observing two live data objects, token and loginResponse. When user clicks the loginButton and makes the viewModel.login request, we’ll retrieve the data.token from loginResponse and save it. When saved, token will observe and when it’s set and not null we’ll navigate to MainFragment.
@AndroidEntryPoint
class MainFragment : Fragment() {
private val viewModel: MainViewModel by viewModels()
private val tokenViewModel: TokenViewModel by activityViewModels()
private lateinit var navController: NavController
private var token: String? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_main, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
navController = Navigation.findNavController(view)
tokenViewModel.token.observe(viewLifecycleOwner) { token ->
this.token = token
if (token == null)
navController.navigate(R.id.action_global_loginFragment)
}
val mainTV = view.findViewById<TextView>(R.id.infoTV)
viewModel.userInfoResponse.observe(viewLifecycleOwner) {
mainTV.text = when(it) {
is ApiResponse.Failure -> "Code: ${it.code}, ${it.errorMessage}"
ApiResponse.Loading -> "Loading"
is ApiResponse.Success -> "ID: ${it.data.userInfo._id}\nMail: ${it.data.userInfo.email_address}\n\nToken: $token"
}
}
view.findViewById<Button>(R.id.infoButton).setOnClickListener {
viewModel.getUserInfo(object: CoroutinesErrorHandler {
override fun onError(message: String) {
mainTV.text = "Error! $message"
}
})
}
view.findViewById<Button>(R.id.logoutButton).setOnClickListener {
tokenViewModel.deleteToken()
}
}
}
MainFragment is very similar to LoginFragment. On infoButton click, we make the request and observe it. When we receive the userInfoResponse we present it to infoTV text view.
Also, we are observing the tokenViewModel token live data, when token is set to null, that means we should log out and move back to login screen.
Let’s see the results:
That’s it! I hope it was useful. 👋👋
Full Code
MrNtlu/Token-Authentication (github.com)
Sources:
https://github.com/Giga99/MediumRepos/tree/main/interceptors
App Architecture: Data Layer — DataStore — Android Developers
You can contact me on,
Top comments (0)