-
Notifications
You must be signed in to change notification settings - Fork 0
Token and Session Management #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
52e3833
c5d378c
b55c2f9
a49fda6
8e5acaf
93819e6
85f38b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package com.cornellappdev.uplift.data.auth | ||
|
|
||
| import kotlinx.coroutines.flow.MutableStateFlow | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.flow.asStateFlow | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| @Singleton | ||
| class SessionManager @Inject constructor( | ||
| private val tokenManager: TokenManager | ||
| ) { | ||
| // A reactive flow that the UI can collect | ||
| private val _isLoggedIn = MutableStateFlow(tokenManager.getAccessToken() != null) | ||
isiahpwilliams marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() | ||
|
|
||
| // Call this after LoginUser or CreateUser mutations succeed | ||
| fun startSession(userId: Int, name: String, email: String, access: String, refresh: String) { | ||
| tokenManager.saveTokens(access, refresh) | ||
| tokenManager.saveUserSession(userId, name, email) | ||
| _isLoggedIn.value = true | ||
| } | ||
|
|
||
| // Call this for manual logout or when refresh fails | ||
| fun logout() { | ||
| tokenManager.clearTokens() | ||
| _isLoggedIn.value = false | ||
| } | ||
|
|
||
| val userId: Int? get() = tokenManager.getUserId() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| package com.cornellappdev.uplift.data.auth | ||
|
|
||
| import android.util.Log | ||
| import com.apollographql.apollo.ApolloClient | ||
| import com.cornellappdev.uplift.RefreshAccessTokenMutation | ||
| import com.cornellappdev.uplift.data.auth.SessionManager | ||
| import com.cornellappdev.uplift.data.auth.TokenManager | ||
| import kotlinx.coroutines.runBlocking | ||
| import kotlinx.coroutines.withTimeout | ||
| import okhttp3.Authenticator | ||
| import okhttp3.Request | ||
| import okhttp3.Response | ||
| import okhttp3.Route | ||
| import javax.inject.Inject | ||
| import javax.inject.Named | ||
|
|
||
| class TokenAuthenticator @Inject constructor( | ||
isiahpwilliams marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| private val tokenManager: TokenManager, | ||
| private val sessionManager: SessionManager, | ||
| @Named("refresh") private val apolloClient: ApolloClient | ||
| ) : Authenticator { | ||
|
|
||
| override fun authenticate(route: Route?, response: Response): Request? { | ||
| val refreshToken = tokenManager.getRefreshToken() ?: return null | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is probably out of scope of this PR, but for refresh tokens, if we know when refresh tokens expire from the backend and if backend has some endpoint that allows us to get a new refresh token aside from forcing the user to log in, it would probably be better UX to get the new refresh token before it expires and keep the user logged in for longer and then fall back to logging the user out for cases where maybe the user hasn't opened the app in a long time. |
||
|
|
||
| synchronized(this) { | ||
| // Check if the token was already refreshed by another thread | ||
| // while this request was waiting for the lock. | ||
| val currentToken = tokenManager.getAccessToken() | ||
| val requestToken = response.request.header("Authorization")?.substringAfter("Bearer ") | ||
|
|
||
| if (currentToken != requestToken && currentToken != null) { | ||
| return response.request.newBuilder() | ||
| .header("Authorization", "Bearer $currentToken") | ||
| .build() | ||
| } | ||
|
|
||
| // 3. Since OkHttp's Authenticator is synchronous but Apollo is suspend-based, | ||
| // we use runBlocking to wait for the refresh mutation result. | ||
| return runBlocking { | ||
| try { | ||
| val mutationResponse = withTimeout(10000L) { | ||
| apolloClient.mutation(RefreshAccessTokenMutation()) | ||
| // We manually add the Refresh Token to this specific call | ||
| // because the "refresh" ApolloClient has no interceptor. | ||
| .addHttpHeader("Authorization", "Bearer $refreshToken") | ||
| .execute() | ||
| } | ||
|
|
||
| val newAccessToken = mutationResponse.data?.refreshAccessToken?.newAccessToken | ||
|
|
||
| if (newAccessToken != null) { | ||
| tokenManager.saveTokens(newAccessToken, refreshToken) | ||
|
|
||
| // Retry the original request with the new Access Token | ||
| response.request.newBuilder() | ||
| .header("Authorization", "Bearer $newAccessToken") | ||
| .build() | ||
| } else { | ||
| // Refresh failed (e.g., refresh token expired on backend) | ||
| sessionManager.logout() | ||
| null | ||
| } | ||
| } catch (e: Exception) { | ||
| // Network error or server down during refresh | ||
| Log.e("TokenAuthenticator", "Refresh timed out or failed", e) | ||
| sessionManager.logout() | ||
| null | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,11 @@ | ||
| package com.cornellappdev.uplift.data.repositories | ||
| package com.cornellappdev.uplift.data.auth | ||
|
|
||
| import android.content.Context | ||
| import android.content.SharedPreferences | ||
| import android.util.Log | ||
| import androidx.core.content.edit | ||
| import androidx.security.crypto.EncryptedSharedPreferences | ||
| import androidx.security.crypto.MasterKey | ||
| import androidx.core.content.edit | ||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
@@ -62,4 +62,15 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: | |
| fun clearTokens() { | ||
| sharedPreferences?.edit { clear() } | ||
| } | ||
| } | ||
|
|
||
| fun saveUserSession(userId: Int, username: String, userEmail: String) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: It's probably fine to have this here, but the naming of this class implies mainly handling tokens so having other user data stuff here might be a little confusing
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, the above clearTokens function will now clear these data fields as well, which could be the intended behavior anyways but same "issue" as above
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think clearing the data fields makes sense since if you don't have tokens, you would get logged out and hence, you wouldn't use the user data anyway |
||
| sharedPreferences?.edit { | ||
| putInt("user_id", userId) | ||
| putString("username", username) | ||
| putString("user_email", userEmail) | ||
| } | ||
| } | ||
|
|
||
| fun getUserId(): Int? = sharedPreferences?.takeIf { it.contains("user_id") }?.getInt("user_id", -1) | ||
|
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.