Blog Infos
Author
Published
Topics
Author
Published
Topics

 

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
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()
    }
}

 

Job Offers

Job Offers


    Android Test Automation Engineer

    Komoot
    Remote
    • Full Time
    apply now

    Android Software Engineer (f/m/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now

    Senior Android Software Engineer (f/m/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

, ,

From Scoped Storage to Photo Picker: Everything to know about Storage

Persistence is a core element of every mobile app. Android provides different APIs to access or expose files with different tradeoffs.
Watch Video

From Scoped Storage to Photo Picker: Everything to know about Storage

Yacine Rezgui
Android developer advocate
Google

From Scoped Storage to Photo Picker: Everything to know about Storage

Yacine Rezgui
Android developer ad ...
Google

From Scoped Storage to Photo Picker: Everything to know about Storage

Yacine Rezgui
Android developer advocat ...
Google

Jobs

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.

 

Interceptors — OkHttp (square.github.io)

 

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.

Authenticator Flow

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()
}
}
}
view raw MainFragment.kt hosted with ❤ by GitHub

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:

You can contact me on,

This article was originally published on proandroiddev.com on December 28, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu