-
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
Changes from all commits
52e3833
c5d378c
b55c2f9
a49fda6
8e5acaf
93819e6
85f38b6
598ebda
e13dbc5
1dc3d71
ea9602c
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,38 @@ | ||
| package com.cornellappdev.uplift.data.auth | ||
|
|
||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.flow.SharingStarted | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.flow.map | ||
| import kotlinx.coroutines.flow.stateIn | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
| import com.cornellappdev.uplift.di.AppModule.ApplicationScope | ||
|
|
||
| @Singleton | ||
| class SessionManager @Inject constructor( | ||
| private val tokenManager: TokenManager, | ||
| @ApplicationScope private val upliftScope: CoroutineScope | ||
| ) { | ||
| // A reactive flow that the UI can collect | ||
| val isLoggedIn: StateFlow<Boolean> = tokenManager.tokenFlow | ||
| .map { token -> token != null } | ||
| .stateIn( | ||
| scope = upliftScope, | ||
| started = SharingStarted.Eagerly, | ||
| initialValue = tokenManager.getAccessToken() != null | ||
| ) | ||
|
|
||
| // 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) | ||
| } | ||
|
|
||
| // Call this for manual logout or when refresh fails | ||
| fun logout() { | ||
| tokenManager.clearTokensAndUserInfo() | ||
| } | ||
|
|
||
| val userId: Int? get() = tokenManager.getUserId() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| package com.cornellappdev.uplift.data.auth | ||
|
|
||
| import android.util.Log | ||
| import com.apollographql.apollo.ApolloClient | ||
| import com.cornellappdev.uplift.RefreshAccessTokenMutation | ||
| 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 | ||
| import javax.inject.Singleton | ||
|
|
||
| @Singleton | ||
| class TokenAuthenticator @Inject constructor( | ||
|
isiahpwilliams marked this conversation as resolved.
|
||
| private val tokenManager: TokenManager, | ||
| private val sessionManager: SessionManager, | ||
| @Named("refresh") private val apolloClient: ApolloClient | ||
| ) : Authenticator { | ||
|
isiahpwilliams marked this conversation as resolved.
|
||
|
|
||
| override fun authenticate(route: Route?, response: Response): Request? { | ||
| if (responseCount(response) >= 2) { | ||
| return null | ||
| } | ||
|
|
||
| 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 != null && currentToken != requestToken ) { | ||
| 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 && newAccessToken != requestToken) { | ||
| 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.
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun responseCount(response: Response?): Int { | ||
| var result = 1 | ||
| var current = response | ||
| // Traverse the chain of prior responses | ||
| while (current?.priorResponse != null) { | ||
| result++ | ||
| current = current.priorResponse | ||
| } | ||
| return result | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,14 @@ | ||
| 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 kotlinx.coroutines.flow.MutableStateFlow | ||
| import kotlinx.coroutines.flow.asStateFlow | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
|
|
@@ -34,6 +36,9 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: | |
| } | ||
| } | ||
|
|
||
| private val _tokenFlow = MutableStateFlow(getAccessToken()) | ||
| val tokenFlow = _tokenFlow.asStateFlow() | ||
|
|
||
| private fun createEncryptedPrefs(): SharedPreferences { | ||
| val masterKey = MasterKey.Builder(context) | ||
| .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) | ||
|
|
@@ -53,13 +58,26 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: | |
| putString("access_token", accessToken) | ||
| putString("refresh_token", refreshToken) | ||
| } | ||
| _tokenFlow.value = accessToken | ||
| } | ||
|
|
||
| fun getAccessToken(): String? = sharedPreferences?.getString("access_token", null) | ||
|
|
||
| fun getRefreshToken(): String? = sharedPreferences?.getString("refresh_token", null) | ||
|
|
||
| fun clearTokens() { | ||
| fun clearTokensAndUserInfo() { | ||
| sharedPreferences?.edit { clear() } | ||
| _tokenFlow.value = null | ||
| } | ||
|
|
||
| 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
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. I think it makes sense to clear data fields, mainly just a minor naming mismatch with clearTokens. |
||
| 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.