feat: Add logic for automatic token refreshing
This commit is contained in:
parent
afa253420a
commit
ec86f48954
|
@ -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"
|
||||
|
|
13
app/src/main/java/com/p_vacho/neat_calendar/MyApplication.kt
Normal file
13
app/src/main/java/com/p_vacho/neat_calendar/MyApplication.kt
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue