Skip to content

Commit 4e81463

Browse files
Merge pull request #106 from cuappdev/preston/tokens
Token and Session Management
2 parents 9f77d68 + ea9602c commit 4e81463

18 files changed

Lines changed: 331 additions & 104 deletions

app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ android {
5858
"GOOGLE_AUTH_CLIENT_ID", secretsProperties["GOOGLE_AUTH_CLIENT_ID"]
5959
)
6060
signingConfig signingConfigs.debug
61-
buildConfigField("boolean", "ONBOARDING_FLAG", "true")
62-
buildConfigField("boolean", "CHECK_IN_FLAG", "true")
61+
buildConfigField("boolean", "ONBOARDING_FLAG", "false")
62+
buildConfigField("boolean", "CHECK_IN_FLAG", "false")
6363
}
6464
}
6565
compileOptions {

app/src/main/graphql/User.graphql

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,15 @@ mutation LoginUser($netId: String!) {
7070
refreshToken
7171
}
7272
}
73+
74+
mutation LogoutUser {
75+
logoutUser {
76+
success
77+
}
78+
}
79+
80+
mutation RefreshAccessToken {
81+
refreshAccessToken {
82+
newAccessToken
83+
}
84+
}

app/src/main/java/com/cornellappdev/uplift/data/repositories/AuthInterceptor.kt renamed to app/src/main/java/com/cornellappdev/uplift/data/auth/AuthInterceptor.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
package com.cornellappdev.uplift.data.repositories
1+
package com.cornellappdev.uplift.data.auth
22

3+
import android.util.Log
34
import okhttp3.Interceptor
45
import okhttp3.Response
56
import javax.inject.Inject
@@ -11,12 +12,12 @@ class AuthInterceptor @Inject constructor(
1112
) : Interceptor {
1213
override fun intercept(chain: Interceptor.Chain): Response {
1314
val token = tokenManager.getAccessToken()
14-
android.util.Log.d("AuthInterceptor", "token present = ${token != null}")
15+
Log.d("AuthInterceptor", "token present = ${token != null}")
1516
val request = chain.request().newBuilder().apply {
1617
if (token != null) {
17-
addHeader("Authorization", "Bearer $token")
18+
header("Authorization", "Bearer $token")
1819
}
1920
}.build()
2021
return chain.proceed(request)
2122
}
22-
}
23+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.cornellappdev.uplift.data.auth
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.flow.SharingStarted
5+
import kotlinx.coroutines.flow.StateFlow
6+
import kotlinx.coroutines.flow.map
7+
import kotlinx.coroutines.flow.stateIn
8+
import javax.inject.Inject
9+
import javax.inject.Singleton
10+
import com.cornellappdev.uplift.di.AppModule.ApplicationScope
11+
12+
@Singleton
13+
class SessionManager @Inject constructor(
14+
private val tokenManager: TokenManager,
15+
@ApplicationScope private val upliftScope: CoroutineScope
16+
) {
17+
// A reactive flow that the UI can collect
18+
val isLoggedIn: StateFlow<Boolean> = tokenManager.tokenFlow
19+
.map { token -> token != null }
20+
.stateIn(
21+
scope = upliftScope,
22+
started = SharingStarted.Eagerly,
23+
initialValue = tokenManager.getAccessToken() != null
24+
)
25+
26+
// Call this after LoginUser or CreateUser mutations succeed
27+
fun startSession(userId: Int, name: String, email: String, access: String, refresh: String) {
28+
tokenManager.saveTokens(access, refresh)
29+
tokenManager.saveUserSession(userId, name, email)
30+
}
31+
32+
// Call this for manual logout or when refresh fails
33+
fun logout() {
34+
tokenManager.clearTokensAndUserInfo()
35+
}
36+
37+
val userId: Int? get() = tokenManager.getUserId()
38+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.cornellappdev.uplift.data.auth
2+
3+
import android.util.Log
4+
import com.apollographql.apollo.ApolloClient
5+
import com.cornellappdev.uplift.RefreshAccessTokenMutation
6+
import kotlinx.coroutines.runBlocking
7+
import kotlinx.coroutines.withTimeout
8+
import okhttp3.Authenticator
9+
import okhttp3.Request
10+
import okhttp3.Response
11+
import okhttp3.Route
12+
import javax.inject.Inject
13+
import javax.inject.Named
14+
import javax.inject.Singleton
15+
16+
@Singleton
17+
class TokenAuthenticator @Inject constructor(
18+
private val tokenManager: TokenManager,
19+
private val sessionManager: SessionManager,
20+
@Named("refresh") private val apolloClient: ApolloClient
21+
) : Authenticator {
22+
23+
override fun authenticate(route: Route?, response: Response): Request? {
24+
if (responseCount(response) >= 2) {
25+
return null
26+
}
27+
28+
val refreshToken = tokenManager.getRefreshToken() ?: return null
29+
30+
synchronized(this) {
31+
// Check if the token was already refreshed by another thread
32+
// while this request was waiting for the lock.
33+
val currentToken = tokenManager.getAccessToken()
34+
val requestToken = response.request.header("Authorization")?.substringAfter("Bearer ")
35+
36+
if (currentToken != null && currentToken != requestToken ) {
37+
return response.request.newBuilder()
38+
.header("Authorization", "Bearer $currentToken")
39+
.build()
40+
}
41+
42+
// 3. Since OkHttp's Authenticator is synchronous but Apollo is suspend-based,
43+
// we use runBlocking to wait for the refresh mutation result.
44+
return runBlocking {
45+
try {
46+
val mutationResponse = withTimeout(10000L) {
47+
apolloClient.mutation(RefreshAccessTokenMutation())
48+
// We manually add the Refresh Token to this specific call
49+
// because the "refresh" ApolloClient has no interceptor.
50+
.addHttpHeader("Authorization", "Bearer $refreshToken")
51+
.execute()
52+
}
53+
54+
val newAccessToken = mutationResponse.data?.refreshAccessToken?.newAccessToken
55+
56+
if (newAccessToken != null && newAccessToken != requestToken) {
57+
tokenManager.saveTokens(newAccessToken, refreshToken)
58+
59+
// Retry the original request with the new Access Token
60+
response.request.newBuilder()
61+
.header("Authorization", "Bearer $newAccessToken")
62+
.build()
63+
} else {
64+
// Refresh failed (e.g., refresh token expired on backend)
65+
sessionManager.logout()
66+
null
67+
}
68+
} catch (e: Exception) {
69+
// Network error or server down during refresh
70+
Log.e("TokenAuthenticator", "Refresh timed out or failed", e)
71+
sessionManager.logout()
72+
null
73+
}
74+
}
75+
}
76+
}
77+
78+
private fun responseCount(response: Response?): Int {
79+
var result = 1
80+
var current = response
81+
// Traverse the chain of prior responses
82+
while (current?.priorResponse != null) {
83+
result++
84+
current = current.priorResponse
85+
}
86+
return result
87+
}
88+
}

app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt renamed to app/src/main/java/com/cornellappdev/uplift/data/auth/TokenManager.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
package com.cornellappdev.uplift.data.repositories
1+
package com.cornellappdev.uplift.data.auth
22

33
import android.content.Context
44
import android.content.SharedPreferences
55
import android.util.Log
6+
import androidx.core.content.edit
67
import androidx.security.crypto.EncryptedSharedPreferences
78
import androidx.security.crypto.MasterKey
8-
import androidx.core.content.edit
99
import dagger.hilt.android.qualifiers.ApplicationContext
10+
import kotlinx.coroutines.flow.MutableStateFlow
11+
import kotlinx.coroutines.flow.asStateFlow
1012
import javax.inject.Inject
1113
import javax.inject.Singleton
1214

@@ -34,6 +36,9 @@ class TokenManager @Inject constructor(@ApplicationContext private val context:
3436
}
3537
}
3638

39+
private val _tokenFlow = MutableStateFlow(getAccessToken())
40+
val tokenFlow = _tokenFlow.asStateFlow()
41+
3742
private fun createEncryptedPrefs(): SharedPreferences {
3843
val masterKey = MasterKey.Builder(context)
3944
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@@ -53,13 +58,26 @@ class TokenManager @Inject constructor(@ApplicationContext private val context:
5358
putString("access_token", accessToken)
5459
putString("refresh_token", refreshToken)
5560
}
61+
_tokenFlow.value = accessToken
5662
}
5763

5864
fun getAccessToken(): String? = sharedPreferences?.getString("access_token", null)
5965

6066
fun getRefreshToken(): String? = sharedPreferences?.getString("refresh_token", null)
6167

62-
fun clearTokens() {
68+
fun clearTokensAndUserInfo() {
6369
sharedPreferences?.edit { clear() }
70+
_tokenFlow.value = null
6471
}
72+
73+
fun saveUserSession(userId: Int, username: String, userEmail: String) {
74+
sharedPreferences?.edit {
75+
putInt("user_id", userId)
76+
putString("username", username)
77+
putString("user_email", userEmail)
78+
}
79+
}
80+
81+
fun getUserId(): Int? = sharedPreferences?.takeIf { it.contains("user_id") }?.getInt("user_id", -1)
82+
6583
}

app/src/main/java/com/cornellappdev/uplift/data/repositories/CapacityRemindersRepository.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import com.cornellappdev.uplift.DeleteCapacityReminderMutation
77
import com.cornellappdev.uplift.EditCapacityReminderMutation
88
import com.cornellappdev.uplift.data.mappers.toResult
99
import javax.inject.Inject
10+
import javax.inject.Named
1011
import javax.inject.Singleton
1112

1213

1314
@Singleton
1415
class CapacityRemindersRepository @Inject constructor(
15-
private val apolloClient: ApolloClient,
16+
@Named("main") private val apolloClient: ApolloClient,
1617
private val dataStoreRepository: DatastoreRepository,
1718
) {
1819
/**

app/src/main/java/com/cornellappdev/uplift/data/repositories/CheckInRepository.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ import com.apollographql.apollo.ApolloClient
3030
import com.cornellappdev.uplift.LogWorkoutMutation
3131
import java.time.Instant
3232
import javax.inject.Inject
33+
import javax.inject.Named
3334
import javax.inject.Singleton
3435

3536
@Singleton
3637
class CheckInRepository @Inject constructor(
3738
val upliftApiRepository: UpliftApiRepository,
3839
private val dataStore: DataStore<Preferences>,
39-
private val apolloClient: ApolloClient,
40+
@Named("main") private val apolloClient: ApolloClient,
4041
private val userInfoRepository: UserInfoRepository
4142
){
4243
private val _nearestGymFlow = MutableStateFlow<UpliftGym?>(null)

app/src/main/java/com/cornellappdev/uplift/data/repositories/PopularTimesRepository.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import com.apollographql.apollo.ApolloClient
44
import com.cornellappdev.uplift.PopularTimesQuery
55
import com.cornellappdev.uplift.data.mappers.toResult
66
import javax.inject.Inject
7+
import javax.inject.Named
78
import javax.inject.Singleton
89

910
@Singleton
1011
class PopularTimesRepository @Inject constructor(
11-
private val apolloClient: ApolloClient
12+
@Named("main") private val apolloClient: ApolloClient
1213
) {
1314
suspend fun getPopularTimes(facilityId: Int): Result<PopularTimesQuery.Data> {
1415
return apolloClient.query(

app/src/main/java/com/cornellappdev/uplift/data/repositories/ProfileRepository.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import com.cornellappdev.uplift.data.models.ProfileData
99
import com.cornellappdev.uplift.data.models.WorkoutDomain
1010
import java.time.Instant
1111
import javax.inject.Inject
12+
import javax.inject.Named
1213
import javax.inject.Singleton
1314

1415

1516
@Singleton
1617
class ProfileRepository @Inject constructor(
1718
private val userInfoRepository: UserInfoRepository,
18-
private val apolloClient: ApolloClient
19+
@Named("main") private val apolloClient: ApolloClient
1920
) {
2021
suspend fun getProfile(): Result<ProfileData> = runCatching {
2122
val netId = userInfoRepository.getNetIdFromDataStore()

0 commit comments

Comments
 (0)