-
Notifications
You must be signed in to change notification settings - Fork 1
Auth - Integrate Backend Token Endpoints #7
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
8824bd7
d6ba600
51fa3cb
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,50 @@ | ||
| package com.cornellappdev.hustle.data.local.auth | ||
|
|
||
| import androidx.datastore.core.DataStore | ||
| import com.cornellappdev.hustle.data.model.user.UserPreferences | ||
| import kotlinx.coroutines.flow.first | ||
| import kotlinx.coroutines.flow.map | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| /** | ||
| * Manages the secure storage and retrieval of authentication tokens (access and refresh tokens) | ||
| * using an encrypted DataStore. | ||
| */ | ||
| @Singleton | ||
| class TokenManager @Inject constructor( | ||
| private val userPreferencesDataStore: DataStore<UserPreferences> | ||
| ) { | ||
| suspend fun getAccessToken(): String? { | ||
| return userPreferencesDataStore.data.map { | ||
| it.accessToken | ||
| }.first() | ||
| } | ||
|
|
||
| suspend fun getRefreshToken(): String? { | ||
| return userPreferencesDataStore.data.map { | ||
| it.refreshToken | ||
| }.first() | ||
| } | ||
|
|
||
| suspend fun saveTokens( | ||
| accessToken: String, | ||
| refreshToken: String | ||
| ) { | ||
| userPreferencesDataStore.updateData { preferences -> | ||
| preferences.copy( | ||
| accessToken = accessToken, | ||
| refreshToken = refreshToken | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| suspend fun clearTokens() { | ||
| userPreferencesDataStore.updateData { preferences -> | ||
| preferences.copy( | ||
| accessToken = null, | ||
| refreshToken = null | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package com.cornellappdev.hustle.data.local.auth | ||
|
|
||
| import androidx.datastore.core.Serializer | ||
| import com.cornellappdev.hustle.data.model.user.UserPreferences | ||
| import com.cornellappdev.hustle.data.security.Crypto | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.withContext | ||
| import kotlinx.serialization.json.Json | ||
| import java.io.InputStream | ||
| import java.io.OutputStream | ||
| import java.util.Base64 | ||
|
|
||
|
|
||
| /** | ||
| * Serializer object responsible for reading and writing [UserPreferences] to DataStore with encryption. | ||
| */ | ||
| object UserPreferencesSerializer : Serializer<UserPreferences> { | ||
| override val defaultValue: UserPreferences | ||
| get() = UserPreferences() | ||
|
|
||
| override suspend fun readFrom(input: InputStream): UserPreferences { | ||
| val encryptedBytes = withContext(Dispatchers.IO) { | ||
| input.use { it.readBytes() } | ||
| } | ||
| if (encryptedBytes.isEmpty()) { | ||
| return defaultValue | ||
| } | ||
| val encryptedBytesDecoded = Base64.getDecoder().decode(encryptedBytes) | ||
| val decryptedBytes = Crypto.decrypt(encryptedBytesDecoded) | ||
| val decodedJsonString = decryptedBytes.decodeToString() | ||
| return Json.decodeFromString<UserPreferences>(decodedJsonString) | ||
| } | ||
|
|
||
| override suspend fun writeTo(t: UserPreferences, output: OutputStream) { | ||
| val json = Json.encodeToString<UserPreferences>(t) | ||
| val bytes = json.toByteArray() | ||
| val encryptedBytes = Crypto.encrypt(bytes) | ||
| val encryptedBytesBase64 = Base64.getEncoder().encode(encryptedBytes) | ||
| withContext(Dispatchers.IO) { | ||
| output.use { | ||
| it.write(encryptedBytesBase64) | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.cornellappdev.hustle.data.model.user | ||
|
|
||
| import kotlinx.serialization.SerialName | ||
| import kotlinx.serialization.Serializable | ||
|
|
||
|
|
||
| @Serializable | ||
| data class VerifyTokenRequest( | ||
| val token: String | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class VerifyTokenResponse( | ||
| @SerialName("access_token") val accessToken: String, | ||
| @SerialName("refresh_token") val refreshToken: String, | ||
| val user: UserResponse | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class RefreshTokenRequest( | ||
| @SerialName("refresh_token") val refreshToken: String | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class RefreshTokenResponse( | ||
| @SerialName("access_token") val accessToken: String, | ||
| @SerialName("refresh_token") val refreshToken: String | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,23 @@ | ||
| package com.cornellappdev.hustle.data.model.user | ||
|
|
||
| import kotlinx.serialization.SerialName | ||
| import kotlinx.serialization.Serializable | ||
|
|
||
| //TODO: Add other fields from backend UserResponse as necessary and remove unused fields | ||
| data class User( | ||
| val firebaseUid: String, | ||
| val email: String?, | ||
| val displayName: String?, | ||
| val photoUrl: String?, | ||
| val photoUrl: String? | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class UserResponse( | ||
| val id: String, | ||
| @SerialName("firebase_uid") val firebaseUid: String, | ||
| val email: String, | ||
| @SerialName("firstname") val firstName: String, | ||
| @SerialName("lastname") val lastName: String | ||
| ) | ||
|
|
||
| class InvalidEmailDomainException(message: String) : Exception(message) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.cornellappdev.hustle.data.model.user | ||
|
|
||
| import kotlinx.serialization.Serializable | ||
|
|
||
| @Serializable | ||
| data class UserPreferences( | ||
| val accessToken: String? = null, | ||
| val refreshToken: String? = null | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.cornellappdev.hustle.data.remote.auth | ||
|
|
||
| import com.cornellappdev.hustle.data.model.user.RefreshTokenRequest | ||
| import com.cornellappdev.hustle.data.model.user.RefreshTokenResponse | ||
| import com.cornellappdev.hustle.data.model.user.VerifyTokenRequest | ||
| import com.cornellappdev.hustle.data.model.user.VerifyTokenResponse | ||
| import retrofit2.Response | ||
| import retrofit2.http.Body | ||
| import retrofit2.http.POST | ||
|
|
||
| interface AuthApiService { | ||
| @POST("api/verify-token") | ||
| suspend fun verifyToken( | ||
| @Body request: VerifyTokenRequest | ||
| ): Response<VerifyTokenResponse> | ||
|
|
||
| @POST("api/refresh-token") | ||
| suspend fun refreshToken( | ||
| @Body request: RefreshTokenRequest | ||
| ): Response<RefreshTokenResponse> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.cornellappdev.hustle.data.remote.auth | ||
|
|
||
| import com.cornellappdev.hustle.data.local.auth.TokenManager | ||
| import kotlinx.coroutines.runBlocking | ||
| import okhttp3.Interceptor | ||
| import okhttp3.Response | ||
| import javax.inject.Inject | ||
|
|
||
AndrewCheung360 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /** | ||
| * [Interceptor] that adds the Authorization header with the access token to outgoing API requests. | ||
| */ | ||
| class AuthInterceptor @Inject constructor( | ||
| private val tokenManager: TokenManager | ||
| ) : Interceptor { | ||
| override fun intercept(chain: Interceptor.Chain): Response { | ||
| val originalRequest = chain.request() | ||
|
|
||
| val accessToken = runBlocking { | ||
| tokenManager.getAccessToken() | ||
| } ?: return chain.proceed(originalRequest) | ||
|
Comment on lines
+18
to
+20
|
||
|
|
||
| return chain.proceed( | ||
| originalRequest.newBuilder() | ||
| .addHeader("Authorization", "Bearer $accessToken") | ||
| .build() | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| package com.cornellappdev.hustle.data.remote.auth | ||
|
|
||
| import android.util.Log | ||
| import com.cornellappdev.hustle.data.local.auth.TokenManager | ||
| import com.cornellappdev.hustle.data.model.user.RefreshTokenRequest | ||
| import com.cornellappdev.hustle.data.model.user.VerifyTokenRequest | ||
| import com.cornellappdev.hustle.data.repository.auth.SessionManager | ||
| import com.google.firebase.auth.FirebaseAuth | ||
| import kotlinx.coroutines.runBlocking | ||
| import kotlinx.coroutines.sync.Mutex | ||
| import kotlinx.coroutines.sync.withLock | ||
| import kotlinx.coroutines.tasks.await | ||
| import okhttp3.Authenticator | ||
| import okhttp3.Request | ||
| import okhttp3.Response | ||
| import okhttp3.Route | ||
| import javax.inject.Inject | ||
|
|
||
| /** | ||
| * An [Authenticator] that handles token expiration and refresh logic for HTTP requests. | ||
| */ | ||
| class TokenAuthenticator @Inject constructor( | ||
| private val tokenManager: TokenManager, | ||
| private val authApiService: AuthApiService, | ||
| private val firebaseAuth: FirebaseAuth, | ||
| private val sessionManager: SessionManager | ||
| ) : Authenticator { | ||
AndrewCheung360 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // Mutex to ensure only one token refresh at a time | ||
| private val mutex = Mutex() | ||
|
|
||
| override fun authenticate(route: Route?, response: Response): Request? { | ||
| // Prevent infinite loops by limiting the number of retries | ||
| if (getResponseCount(response) >= 3) { | ||
| return null | ||
| } | ||
|
|
||
| return runBlocking { | ||
| mutex.withLock { | ||
| val currentToken = tokenManager.getAccessToken() | ||
| val failedToken = response.request.header("Authorization")?.removePrefix("Bearer ") | ||
|
|
||
| // If the token has been updated since the request was made, use the new token and skip refresh flow | ||
| if (currentToken != null && currentToken != failedToken) { | ||
| return@runBlocking buildAuthRequest(response, currentToken) | ||
| } | ||
|
|
||
| // 1. Try to refresh the access token using the refresh token | ||
| // 2. If that fails, try to re-authenticate with the Firebase token | ||
| // 3. If that also fails, sign the user out and notify session expiration | ||
| tryRefreshToken(response) | ||
| ?: tryFirebaseReAuthentication(response) | ||
| ?: handleAuthFailure() | ||
| } | ||
| } | ||
|
Comment on lines
+37
to
+54
|
||
| } | ||
|
|
||
| private suspend fun tryRefreshToken(response: Response): Request? { | ||
| val refreshToken = tokenManager.getRefreshToken() ?: return null | ||
|
|
||
| return runCatching { | ||
| authApiService.refreshToken(RefreshTokenRequest(refreshToken)) | ||
| .takeIf { it.isSuccessful } | ||
| ?.body() | ||
| ?.let { tokenData -> | ||
| tokenManager.saveTokens( | ||
| accessToken = tokenData.accessToken, | ||
| refreshToken = tokenData.refreshToken | ||
| ) | ||
| buildAuthRequest(response, tokenData.accessToken) | ||
| } | ||
| }.onFailure { | ||
| Log.e(TAG, "Token refresh failed: ${it.message}") | ||
| }.getOrNull() | ||
| } | ||
|
|
||
| private suspend fun tryFirebaseReAuthentication(response: Response): Request? { | ||
| val firebaseUser = firebaseAuth.currentUser ?: return null | ||
|
|
||
| return runCatching { | ||
| val firebaseToken = firebaseUser.getIdToken(true).await().token ?: return null | ||
|
|
||
| authApiService.verifyToken(VerifyTokenRequest(firebaseToken)) | ||
| .takeIf { it.isSuccessful } | ||
| ?.body() | ||
| ?.let { tokenData -> | ||
| tokenManager.saveTokens( | ||
| accessToken = tokenData.accessToken, | ||
| refreshToken = tokenData.refreshToken | ||
| ) | ||
| buildAuthRequest(response, tokenData.accessToken) | ||
| } | ||
| }.onFailure { | ||
| Log.e(TAG, "Firebase re-authentication failed: ${it.message}") | ||
| }.getOrNull() | ||
| } | ||
|
|
||
| private suspend fun handleAuthFailure(): Request? { | ||
| tokenManager.clearTokens() | ||
| firebaseAuth.signOut() | ||
| sessionManager.notifySessionExpired() | ||
| return null | ||
| } | ||
|
|
||
| private fun buildAuthRequest(response: Response, token: String): Request = | ||
| response.request.newBuilder() | ||
| .header("Authorization", "Bearer $token") | ||
| .build() | ||
|
|
||
| private fun getResponseCount(response: Response): Int = | ||
| generateSequence(response) { it.priorResponse }.count() | ||
|
|
||
| companion object { | ||
| private const val TAG = "TokenAuthenticator" | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.