From e0b98030345b4b60fc7f40f7b5a7736d10ebeb12 Mon Sep 17 00:00:00 2001 From: Andrew Cheung Date: Thu, 30 Apr 2026 03:02:07 -0400 Subject: [PATCH 1/3] Handle expired signature GraphQL errors with ApolloAuthInterceptor --- .../uplift/data/auth/ApolloAuthInterceptor.kt | 76 +++++++++++++++++++ .../com/cornellappdev/uplift/di/AppModule.kt | 7 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt diff --git a/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt new file mode 100644 index 0000000..f567b81 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt @@ -0,0 +1,76 @@ +package com.cornellappdev.uplift.data.auth + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloRequest +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.ExecutionContext +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.interceptor.ApolloInterceptor +import com.apollographql.apollo.interceptor.ApolloInterceptorChain +import com.cornellappdev.uplift.RefreshAccessTokenMutation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Execution context to track retries. + */ +internal class RetryContext(val retryCount: Int) : ExecutionContext.Element { + override val key: ExecutionContext.Key<*> = Key + + companion object Key : ExecutionContext.Key +} + +/** + * An Apollo Interceptor that handles token expiration errors that return as 200 OK with + * GraphQL errors (specifically "Signature has expired"). + */ +@Singleton +class ApolloAuthInterceptor @Inject constructor( + private val tokenManager: TokenManager, + private val sessionManager: SessionManager, + @Named("refresh") private val refreshClient: ApolloClient +) : ApolloInterceptor { + override fun intercept( + request: ApolloRequest, + chain: ApolloInterceptorChain + ): Flow> = flow { + val response = chain.proceed(request).first() + + val retryCount = request.executionContext[RetryContext]?.retryCount ?: 0 + // TODO: replace string check with explicit error codes if backend implements + if (response.errors?.any { it.message.contains("Signature has expired") } == true && retryCount < 1) { + val refreshToken = tokenManager.getRefreshToken() + if (refreshToken != null) { + try { + val mutationResponse = refreshClient.mutation(RefreshAccessTokenMutation()) + .addHttpHeader("Authorization", "Bearer $refreshToken") + .execute() + + val newAccessToken = mutationResponse.data?.refreshAccessToken?.newAccessToken + if (newAccessToken != null) { + tokenManager.saveTokens(newAccessToken, refreshToken) + // Retry the request with the new token + val newRequest = request.newBuilder() + .addExecutionContext(RetryContext(retryCount + 1)) + .build() + emitAll(chain.proceed(newRequest)) + return@flow + } else { + sessionManager.logout() + } + } catch (e: Exception) { + sessionManager.logout() + } + } else { + sessionManager.logout() + } + } + + emit(response) + } +} diff --git a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt index 5170980..0ab9c61 100644 --- a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt +++ b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt @@ -3,6 +3,7 @@ package com.cornellappdev.uplift.di import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.network.okHttpClient import com.cornellappdev.uplift.BuildConfig +import com.cornellappdev.uplift.data.auth.ApolloAuthInterceptor import com.cornellappdev.uplift.data.auth.AuthInterceptor import com.cornellappdev.uplift.data.auth.TokenAuthenticator import dagger.Module @@ -67,10 +68,14 @@ object AppModule { @Provides @Singleton @Named("main") - fun provideApolloClient(@Named("main") okHttpClient: OkHttpClient): ApolloClient { + fun provideApolloClient( + @Named("main") okHttpClient: OkHttpClient, + apolloAuthInterceptor: ApolloAuthInterceptor + ): ApolloClient { return ApolloClient.Builder() .serverUrl(BuildConfig.BACKEND_URL) .okHttpClient(okHttpClient) + .addInterceptor(apolloAuthInterceptor) .build() } From 1588a9520008c9339557c8cca3b7bf264b1ab079 Mon Sep 17 00:00:00 2001 From: Andrew Cheung Date: Thu, 30 Apr 2026 03:10:31 -0400 Subject: [PATCH 2/3] Address PR feedback: add Mutex for thread-safety and improve logging --- .../uplift/data/auth/ApolloAuthInterceptor.kt | 57 +++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt index f567b81..be3bac6 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt @@ -1,5 +1,6 @@ package com.cornellappdev.uplift.data.auth +import android.util.Log import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.api.ApolloRequest import com.apollographql.apollo.api.ApolloResponse @@ -12,6 +13,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @@ -35,6 +38,8 @@ class ApolloAuthInterceptor @Inject constructor( private val sessionManager: SessionManager, @Named("refresh") private val refreshClient: ApolloClient ) : ApolloInterceptor { + private val mutex = Mutex() + override fun intercept( request: ApolloRequest, chain: ApolloInterceptorChain @@ -42,31 +47,51 @@ class ApolloAuthInterceptor @Inject constructor( val response = chain.proceed(request).first() val retryCount = request.executionContext[RetryContext]?.retryCount ?: 0 + // TODO: replace string check with explicit error codes if backend implements if (response.errors?.any { it.message.contains("Signature has expired") } == true && retryCount < 1) { val refreshToken = tokenManager.getRefreshToken() if (refreshToken != null) { - try { - val mutationResponse = refreshClient.mutation(RefreshAccessTokenMutation()) - .addHttpHeader("Authorization", "Bearer $refreshToken") - .execute() + val newAccessToken = mutex.withLock { + // Check if another request already refreshed the token while we were waiting for the lock + val currentAccessToken = tokenManager.getAccessToken() + val requestToken = request.httpHeaders?.find { it.name == "Authorization" }?.value?.substringAfter("Bearer ") - val newAccessToken = mutationResponse.data?.refreshAccessToken?.newAccessToken - if (newAccessToken != null) { - tokenManager.saveTokens(newAccessToken, refreshToken) - // Retry the request with the new token - val newRequest = request.newBuilder() - .addExecutionContext(RetryContext(retryCount + 1)) - .build() - emitAll(chain.proceed(newRequest)) - return@flow + if (currentAccessToken != null && currentAccessToken != requestToken) { + currentAccessToken } else { - sessionManager.logout() + try { + val mutationResponse = refreshClient.mutation(RefreshAccessTokenMutation()) + .addHttpHeader("Authorization", "Bearer $refreshToken") + .execute() + + val refreshedToken = mutationResponse.data?.refreshAccessToken?.newAccessToken + if (refreshedToken != null) { + tokenManager.saveTokens(refreshedToken, refreshToken) + refreshedToken + } else { + Log.e("ApolloAuthInterceptor", "Refresh token mutation returned null access token") + sessionManager.logout() + null + } + } catch (e: Exception) { + Log.e("ApolloAuthInterceptor", "Token refresh failed with exception", e) + sessionManager.logout() + null + } } - } catch (e: Exception) { - sessionManager.logout() + } + + if (newAccessToken != null) { + // Retry the request with the new token + val newRequest = request.newBuilder() + .addExecutionContext(RetryContext(retryCount + 1)) + .build() + emitAll(chain.proceed(newRequest)) + return@flow } } else { + Log.d("ApolloAuthInterceptor", "No refresh token available, logging out") sessionManager.logout() } } From db8e2fc0ff1b54b30db98dc944d8c296bed16ab5 Mon Sep 17 00:00:00 2001 From: Andrew Cheung Date: Fri, 1 May 2026 02:16:03 -0400 Subject: [PATCH 3/3] Fix ApolloAuthInterceptor to correctly inject token and handle retries --- .../uplift/data/auth/ApolloAuthInterceptor.kt | 93 +++++++++++-------- 1 file changed, 53 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt index be3bac6..b942ad1 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/ApolloAuthInterceptor.kt @@ -11,7 +11,6 @@ import com.apollographql.apollo.interceptor.ApolloInterceptorChain import com.cornellappdev.uplift.RefreshAccessTokenMutation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -44,58 +43,72 @@ class ApolloAuthInterceptor @Inject constructor( request: ApolloRequest, chain: ApolloInterceptorChain ): Flow> = flow { - val response = chain.proceed(request).first() + val accessToken = tokenManager.getAccessToken() + val requestWithToken = if (accessToken != null) { + request.newBuilder() + .addHttpHeader("Authorization", "Bearer $accessToken") + .build() + } else { + request + } val retryCount = request.executionContext[RetryContext]?.retryCount ?: 0 + var isRetrying = false - // TODO: replace string check with explicit error codes if backend implements - if (response.errors?.any { it.message.contains("Signature has expired") } == true && retryCount < 1) { - val refreshToken = tokenManager.getRefreshToken() - if (refreshToken != null) { - val newAccessToken = mutex.withLock { - // Check if another request already refreshed the token while we were waiting for the lock - val currentAccessToken = tokenManager.getAccessToken() - val requestToken = request.httpHeaders?.find { it.name == "Authorization" }?.value?.substringAfter("Bearer ") + chain.proceed(requestWithToken).collect { response -> + // Check for "Signature has expired" GraphQL error + if (!isRetrying && response.errors?.any { it.message.contains("Signature has expired") } == true && retryCount < 1) { + isRetrying = true + val refreshToken = tokenManager.getRefreshToken() + if (refreshToken != null) { + val newAccessToken = mutex.withLock { + // Check if another request already refreshed the token while we were waiting for the lock + val currentAccessToken = tokenManager.getAccessToken() + val requestToken = requestWithToken.httpHeaders?.find { it.name == "Authorization" }?.value?.substringAfter("Bearer ") - if (currentAccessToken != null && currentAccessToken != requestToken) { - currentAccessToken - } else { - try { - val mutationResponse = refreshClient.mutation(RefreshAccessTokenMutation()) - .addHttpHeader("Authorization", "Bearer $refreshToken") - .execute() + if (currentAccessToken != null && requestToken != null && currentAccessToken != requestToken) { + currentAccessToken + } else { + try { + val mutationResponse = refreshClient.mutation(RefreshAccessTokenMutation()) + .addHttpHeader("Authorization", "Bearer $refreshToken") + .execute() - val refreshedToken = mutationResponse.data?.refreshAccessToken?.newAccessToken - if (refreshedToken != null) { - tokenManager.saveTokens(refreshedToken, refreshToken) - refreshedToken - } else { - Log.e("ApolloAuthInterceptor", "Refresh token mutation returned null access token") + val refreshedToken = mutationResponse.data?.refreshAccessToken?.newAccessToken + if (refreshedToken != null) { + tokenManager.saveTokens(refreshedToken, refreshToken) + refreshedToken + } else { + Log.e("ApolloAuthInterceptor", "Refresh token mutation returned null access token") + sessionManager.logout() + null + } + } catch (e: Exception) { + Log.e("ApolloAuthInterceptor", "Token refresh failed with exception", e) sessionManager.logout() null } - } catch (e: Exception) { - Log.e("ApolloAuthInterceptor", "Token refresh failed with exception", e) - sessionManager.logout() - null } } - } - if (newAccessToken != null) { - // Retry the request with the new token - val newRequest = request.newBuilder() - .addExecutionContext(RetryContext(retryCount + 1)) - .build() - emitAll(chain.proceed(newRequest)) - return@flow + if (newAccessToken != null) { + // Retry the request with the new token + val newRequest = request.newBuilder() + .addExecutionContext(RetryContext(retryCount + 1)) + .build() + emitAll(chain.proceed(newRequest)) + } else { + // If refresh failed, emit the original error response + emit(response) + } + } else { + Log.d("ApolloAuthInterceptor", "No refresh token available, logging out") + sessionManager.logout() + emit(response) } - } else { - Log.d("ApolloAuthInterceptor", "No refresh token available, logging out") - sessionManager.logout() + } else if (!isRetrying) { + emit(response) } } - - emit(response) } }