Skip to content

Commit d7255ab

Browse files
Merge pull request #7 from cuappdev/andrew/auth-integration
Auth - Integrate Backend Token Endpoints
2 parents 7f3bb2d + 51fa3cb commit d7255ab

File tree

20 files changed

+574
-33
lines changed

20 files changed

+574
-33
lines changed

app/build.gradle.kts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ android {
2525

2626
defaultConfig {
2727
applicationId = "com.cornellappdev.hustle"
28-
minSdk = 24
28+
minSdk = 26
2929
targetSdk = 36
3030
versionCode = 1
3131
versionName = "1.0"
@@ -99,8 +99,7 @@ dependencies {
9999
kapt(libs.hilt.android.compiler)
100100
// Retrofit and OkHttp Dependencies
101101
implementation(libs.retrofit)
102-
implementation(libs.converter.moshi)
103-
implementation(libs.moshi.kotlin)
102+
implementation(libs.converter.kotlinx.serialization)
104103
implementation(libs.okhttp)
105104
implementation(libs.okhttp.logging)
106105
// Lint Checks
@@ -123,6 +122,8 @@ dependencies {
123122
implementation(libs.coil.network.okhttp)
124123
// Splash Screen API
125124
implementation(libs.androidx.splashscreen)
125+
// DataStore Preferences
126+
implementation(libs.androidx.datastore.preferences)
126127
}
127128

128129
// Allow references to generated code
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.cornellappdev.hustle.data.local.auth
2+
3+
import androidx.datastore.core.DataStore
4+
import com.cornellappdev.hustle.data.model.user.UserPreferences
5+
import kotlinx.coroutines.flow.first
6+
import kotlinx.coroutines.flow.map
7+
import javax.inject.Inject
8+
import javax.inject.Singleton
9+
10+
/**
11+
* Manages the secure storage and retrieval of authentication tokens (access and refresh tokens)
12+
* using an encrypted DataStore.
13+
*/
14+
@Singleton
15+
class TokenManager @Inject constructor(
16+
private val userPreferencesDataStore: DataStore<UserPreferences>
17+
) {
18+
suspend fun getAccessToken(): String? {
19+
return userPreferencesDataStore.data.map {
20+
it.accessToken
21+
}.first()
22+
}
23+
24+
suspend fun getRefreshToken(): String? {
25+
return userPreferencesDataStore.data.map {
26+
it.refreshToken
27+
}.first()
28+
}
29+
30+
suspend fun saveTokens(
31+
accessToken: String,
32+
refreshToken: String
33+
) {
34+
userPreferencesDataStore.updateData { preferences ->
35+
preferences.copy(
36+
accessToken = accessToken,
37+
refreshToken = refreshToken
38+
)
39+
}
40+
}
41+
42+
suspend fun clearTokens() {
43+
userPreferencesDataStore.updateData { preferences ->
44+
preferences.copy(
45+
accessToken = null,
46+
refreshToken = null
47+
)
48+
}
49+
}
50+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.cornellappdev.hustle.data.local.auth
2+
3+
import androidx.datastore.core.Serializer
4+
import com.cornellappdev.hustle.data.model.user.UserPreferences
5+
import com.cornellappdev.hustle.data.security.Crypto
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.withContext
8+
import kotlinx.serialization.json.Json
9+
import java.io.InputStream
10+
import java.io.OutputStream
11+
import java.util.Base64
12+
13+
14+
/**
15+
* Serializer object responsible for reading and writing [UserPreferences] to DataStore with encryption.
16+
*/
17+
object UserPreferencesSerializer : Serializer<UserPreferences> {
18+
override val defaultValue: UserPreferences
19+
get() = UserPreferences()
20+
21+
override suspend fun readFrom(input: InputStream): UserPreferences {
22+
val encryptedBytes = withContext(Dispatchers.IO) {
23+
input.use { it.readBytes() }
24+
}
25+
if (encryptedBytes.isEmpty()) {
26+
return defaultValue
27+
}
28+
val encryptedBytesDecoded = Base64.getDecoder().decode(encryptedBytes)
29+
val decryptedBytes = Crypto.decrypt(encryptedBytesDecoded)
30+
val decodedJsonString = decryptedBytes.decodeToString()
31+
return Json.decodeFromString<UserPreferences>(decodedJsonString)
32+
}
33+
34+
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
35+
val json = Json.encodeToString<UserPreferences>(t)
36+
val bytes = json.toByteArray()
37+
val encryptedBytes = Crypto.encrypt(bytes)
38+
val encryptedBytesBase64 = Base64.getEncoder().encode(encryptedBytes)
39+
withContext(Dispatchers.IO) {
40+
output.use {
41+
it.write(encryptedBytesBase64)
42+
}
43+
}
44+
}
45+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.cornellappdev.hustle.data.model.user
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
7+
@Serializable
8+
data class VerifyTokenRequest(
9+
val token: String
10+
)
11+
12+
@Serializable
13+
data class VerifyTokenResponse(
14+
@SerialName("access_token") val accessToken: String,
15+
@SerialName("refresh_token") val refreshToken: String,
16+
val user: UserResponse
17+
)
18+
19+
@Serializable
20+
data class RefreshTokenRequest(
21+
@SerialName("refresh_token") val refreshToken: String
22+
)
23+
24+
@Serializable
25+
data class RefreshTokenResponse(
26+
@SerialName("access_token") val accessToken: String,
27+
@SerialName("refresh_token") val refreshToken: String
28+
)
Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
package com.cornellappdev.hustle.data.model.user
22

3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
//TODO: Add other fields from backend UserResponse as necessary and remove unused fields
37
data class User(
48
val firebaseUid: String,
59
val email: String?,
610
val displayName: String?,
7-
val photoUrl: String?,
11+
val photoUrl: String?
12+
)
13+
14+
@Serializable
15+
data class UserResponse(
16+
val id: String,
17+
@SerialName("firebase_uid") val firebaseUid: String,
18+
val email: String,
19+
@SerialName("firstname") val firstName: String,
20+
@SerialName("lastname") val lastName: String
821
)
922

1023
class InvalidEmailDomainException(message: String) : Exception(message)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.cornellappdev.hustle.data.model.user
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class UserPreferences(
7+
val accessToken: String? = null,
8+
val refreshToken: String? = null
9+
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.cornellappdev.hustle.data.remote.auth
2+
3+
import com.cornellappdev.hustle.data.model.user.RefreshTokenRequest
4+
import com.cornellappdev.hustle.data.model.user.RefreshTokenResponse
5+
import com.cornellappdev.hustle.data.model.user.VerifyTokenRequest
6+
import com.cornellappdev.hustle.data.model.user.VerifyTokenResponse
7+
import retrofit2.Response
8+
import retrofit2.http.Body
9+
import retrofit2.http.POST
10+
11+
interface AuthApiService {
12+
@POST("api/verify-token")
13+
suspend fun verifyToken(
14+
@Body request: VerifyTokenRequest
15+
): Response<VerifyTokenResponse>
16+
17+
@POST("api/refresh-token")
18+
suspend fun refreshToken(
19+
@Body request: RefreshTokenRequest
20+
): Response<RefreshTokenResponse>
21+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.cornellappdev.hustle.data.remote.auth
2+
3+
import com.cornellappdev.hustle.data.local.auth.TokenManager
4+
import kotlinx.coroutines.runBlocking
5+
import okhttp3.Interceptor
6+
import okhttp3.Response
7+
import javax.inject.Inject
8+
9+
/**
10+
* [Interceptor] that adds the Authorization header with the access token to outgoing API requests.
11+
*/
12+
class AuthInterceptor @Inject constructor(
13+
private val tokenManager: TokenManager
14+
) : Interceptor {
15+
override fun intercept(chain: Interceptor.Chain): Response {
16+
val originalRequest = chain.request()
17+
18+
val accessToken = runBlocking {
19+
tokenManager.getAccessToken()
20+
} ?: return chain.proceed(originalRequest)
21+
22+
return chain.proceed(
23+
originalRequest.newBuilder()
24+
.addHeader("Authorization", "Bearer $accessToken")
25+
.build()
26+
)
27+
}
28+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.cornellappdev.hustle.data.remote.auth
2+
3+
import android.util.Log
4+
import com.cornellappdev.hustle.data.local.auth.TokenManager
5+
import com.cornellappdev.hustle.data.model.user.RefreshTokenRequest
6+
import com.cornellappdev.hustle.data.model.user.VerifyTokenRequest
7+
import com.cornellappdev.hustle.data.repository.auth.SessionManager
8+
import com.google.firebase.auth.FirebaseAuth
9+
import kotlinx.coroutines.runBlocking
10+
import kotlinx.coroutines.sync.Mutex
11+
import kotlinx.coroutines.sync.withLock
12+
import kotlinx.coroutines.tasks.await
13+
import okhttp3.Authenticator
14+
import okhttp3.Request
15+
import okhttp3.Response
16+
import okhttp3.Route
17+
import javax.inject.Inject
18+
19+
/**
20+
* An [Authenticator] that handles token expiration and refresh logic for HTTP requests.
21+
*/
22+
class TokenAuthenticator @Inject constructor(
23+
private val tokenManager: TokenManager,
24+
private val authApiService: AuthApiService,
25+
private val firebaseAuth: FirebaseAuth,
26+
private val sessionManager: SessionManager
27+
) : Authenticator {
28+
// Mutex to ensure only one token refresh at a time
29+
private val mutex = Mutex()
30+
31+
override fun authenticate(route: Route?, response: Response): Request? {
32+
// Prevent infinite loops by limiting the number of retries
33+
if (getResponseCount(response) >= 3) {
34+
return null
35+
}
36+
37+
return runBlocking {
38+
mutex.withLock {
39+
val currentToken = tokenManager.getAccessToken()
40+
val failedToken = response.request.header("Authorization")?.removePrefix("Bearer ")
41+
42+
// If the token has been updated since the request was made, use the new token and skip refresh flow
43+
if (currentToken != null && currentToken != failedToken) {
44+
return@runBlocking buildAuthRequest(response, currentToken)
45+
}
46+
47+
// 1. Try to refresh the access token using the refresh token
48+
// 2. If that fails, try to re-authenticate with the Firebase token
49+
// 3. If that also fails, sign the user out and notify session expiration
50+
tryRefreshToken(response)
51+
?: tryFirebaseReAuthentication(response)
52+
?: handleAuthFailure()
53+
}
54+
}
55+
}
56+
57+
private suspend fun tryRefreshToken(response: Response): Request? {
58+
val refreshToken = tokenManager.getRefreshToken() ?: return null
59+
60+
return runCatching {
61+
authApiService.refreshToken(RefreshTokenRequest(refreshToken))
62+
.takeIf { it.isSuccessful }
63+
?.body()
64+
?.let { tokenData ->
65+
tokenManager.saveTokens(
66+
accessToken = tokenData.accessToken,
67+
refreshToken = tokenData.refreshToken
68+
)
69+
buildAuthRequest(response, tokenData.accessToken)
70+
}
71+
}.onFailure {
72+
Log.e(TAG, "Token refresh failed: ${it.message}")
73+
}.getOrNull()
74+
}
75+
76+
private suspend fun tryFirebaseReAuthentication(response: Response): Request? {
77+
val firebaseUser = firebaseAuth.currentUser ?: return null
78+
79+
return runCatching {
80+
val firebaseToken = firebaseUser.getIdToken(true).await().token ?: return null
81+
82+
authApiService.verifyToken(VerifyTokenRequest(firebaseToken))
83+
.takeIf { it.isSuccessful }
84+
?.body()
85+
?.let { tokenData ->
86+
tokenManager.saveTokens(
87+
accessToken = tokenData.accessToken,
88+
refreshToken = tokenData.refreshToken
89+
)
90+
buildAuthRequest(response, tokenData.accessToken)
91+
}
92+
}.onFailure {
93+
Log.e(TAG, "Firebase re-authentication failed: ${it.message}")
94+
}.getOrNull()
95+
}
96+
97+
private suspend fun handleAuthFailure(): Request? {
98+
tokenManager.clearTokens()
99+
firebaseAuth.signOut()
100+
sessionManager.notifySessionExpired()
101+
return null
102+
}
103+
104+
private fun buildAuthRequest(response: Response, token: String): Request =
105+
response.request.newBuilder()
106+
.header("Authorization", "Bearer $token")
107+
.build()
108+
109+
private fun getResponseCount(response: Response): Int =
110+
generateSequence(response) { it.priorResponse }.count()
111+
112+
companion object {
113+
private const val TAG = "TokenAuthenticator"
114+
}
115+
}

0 commit comments

Comments
 (0)