feat: Add logic for automatic token refreshing

This commit is contained in:
Peter Vacho 2024-12-23 17:21:16 +01:00
parent afa253420a
commit ec86f48954
Signed by: school
GPG key ID: 8CFC3837052871B4
5 changed files with 163 additions and 12 deletions

View file

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View file

@ -0,0 +1,13 @@
package com.p_vacho.neat_calendar
import android.app.Application
import com.p_vacho.neat_calendar.api.RetrofitClient
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize RetrofitClient
RetrofitClient.initialize(this)
}
}

View file

@ -0,0 +1,110 @@
package com.p_vacho.neat_calendar.api
import android.content.Context
import android.content.Intent
import android.util.Log
import com.p_vacho.neat_calendar.LoginActivity
import com.p_vacho.neat_calendar.auth.AuthRepository
import com.p_vacho.neat_calendar.auth.TokenManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import kotlin.coroutines.cancellation.CancellationException
class AuthInterceptor(
private val context: Context,
) : Interceptor {
private val tokenManager: TokenManager = TokenManager(context)
private val authRepository: AuthRepository = AuthRepository(tokenManager)
companion object {
// List of URLs to bypass in the interceptor
private val bypassedUrls = listOf(
"/ping",
"/auth/login",
"/auth/refresh",
"/auth/logout"
)
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// Check if the request URL is in the bypass list
if (bypassedUrls.any { originalRequest.url().encodedPath() == it }) {
return chain.proceed(originalRequest)
}
// No point in continuing if our refresh token expired, make the user to re-login
if (tokenManager.refreshToken == null || tokenManager.isRefreshTokenExpired()) {
handleFail()
}
refreshAccessTokenIfNeeded()
// Override the original request and add the access token, which should be
// available & non-expired after the above checks (though it's still possible
// that it was invalidated through other means, so we can still fail here).
val requestBuilder = originalRequest.newBuilder()
requestBuilder.addHeader("Authorization", "Bearer $tokenManager.accessToken")
val response = chain.proceed(requestBuilder.build())
// Handle authentication failure, if it occurs
if (response.code() == 401) {
// Clear the access token, as it didn't work anyways, we got 401 with it
tokenManager.clearAccessToken()
// This will definitely trigger a refresh now, as we cleared the access token
refreshAccessTokenIfNeeded()
// Retry the original request with the refreshed access token
val newBuilder = originalRequest.newBuilder()
newBuilder.addHeader("Authorization", "Bearer ${tokenManager.accessToken}")
val newResponse = chain.proceed(newBuilder.build())
// If this request also lead to a 401, something is very wrong, as the access token
// was in fact refreshed by now, which means our refresh token does work, but the
// access token it gave us wasn't valid.
if (newResponse.code() == 401) {
Log.e("API", "Got 403 from a freshly refreshed access token: $newResponse")
throw IllegalStateException("Got 403 from a freshly refreshed access token")
}
newResponse
}
return response
}
@Synchronized // Avoid simultaneous refresh attempts
private fun refreshAccessTokenIfNeeded() {
// If the access token is close to expiring (<10 seconds remaining),
// get a new access token to use for the request.
if (tokenManager.accessToken == null || tokenManager.willAccessTokenExpireIn(10)) {
val refreshed = runBlocking {
authRepository.refreshAccessToken()
}
if (!refreshed) {
handleFail()
}
}
}
private fun handleFail(): Nothing {
Log.e("API", "Session expired or refresh token is invalid. Redirecting to login.")
tokenManager.clearTokens()
navigateToLoginActivity()
// End the current request chain gracefully
throw CancellationException("Session expired. User redirected to login.") }
private fun navigateToLoginActivity() {
val intent = Intent(context, LoginActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
ContextCompat.startActivity(context, intent, null)
}
}

View file

@ -1,7 +1,10 @@
package com.p_vacho.neat_calendar.api
import android.content.Context
import com.p_vacho.neat_calendar.api.services.AuthService
import com.p_vacho.neat_calendar.api.services.GeneralService
import com.p_vacho.neat_calendar.auth.TokenManager
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import kotlinx.coroutines.Dispatchers
@ -10,15 +13,10 @@ import android.util.Log
import okhttp3.ResponseBody
object RetrofitClient {
// Points to localhost on the machine running the emulator
private const val BASE_URL = "http://10.0.2.2:8000"
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
private lateinit var retrofit: Retrofit
val authService: AuthService by lazy {
retrofit.create(AuthService::class.java)
@ -28,11 +26,28 @@ object RetrofitClient {
retrofit.create(GeneralService::class.java)
}
/**
* Initializes the RetrofitClient with context and token manager
*/
fun initialize(context: Context) {
val authInterceptor = AuthInterceptor(context)
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor) // Adds the AuthInterceptor
.build()
retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient) // Set custom OkHttpClient
.addConverterFactory(GsonConverterFactory.create())
.build()
}
suspend fun isApiReachable(): Boolean {
return withContext(Dispatchers.IO) {
try {
val responseBody: ResponseBody = RetrofitClient.generalService.ping()
val responseText = responseBody.string() // Extract the string content from ResponseBody
val responseText = responseBody.string()
if (responseText != "pong") {
Log.w("API", "Unexpected response from ping: $responseText")
}

View file

@ -23,12 +23,20 @@ class TokenManager(context: Context) {
get() = prefs.getLong("refresh_token_expires_at", 0L)
private set(value) = prefs.edit().putLong("refresh_token_expires_at", value).apply()
fun willAccessTokenExpireIn(expireSeconds: Int): Boolean {
return (accessTokenExpiresAt * 1000 - expireSeconds) <= System.currentTimeMillis()
}
fun willRefreshTokenExpireIn(expireSeconds: Int): Boolean {
return (refreshTokenExpiresAt * 1000 - expireSeconds) <= System.currentTimeMillis()
}
fun isAccessTokenExpired(): Boolean {
return accessTokenExpiresAt * 1000 <= System.currentTimeMillis()
return willAccessTokenExpireIn(0)
}
fun isRefreshTokenExpired(): Boolean {
return refreshTokenExpiresAt * 1000 <= System.currentTimeMillis()
return willRefreshTokenExpireIn(0)
}
fun storeAccessToken(accessToken: String, expiresIn: Int) {
@ -41,10 +49,14 @@ class TokenManager(context: Context) {
this.refreshTokenExpiresAt = System.currentTimeMillis() / 1000 + expiresIn
}
fun clearTokens() {
fun clearAccessToken() {
accessToken = null
refreshToken = null
accessTokenExpiresAt = 0L
}
fun clearTokens() {
clearAccessToken()
refreshToken = null
refreshTokenExpiresAt = 0L
}
}