Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ android {

defaultConfig {
applicationId = "com.cornellappdev.hustle"
minSdk = 24
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
Expand Down Expand Up @@ -99,8 +99,7 @@ dependencies {
kapt(libs.hilt.android.compiler)
// Retrofit and OkHttp Dependencies
implementation(libs.retrofit)
implementation(libs.converter.moshi)
implementation(libs.moshi.kotlin)
implementation(libs.converter.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// Lint Checks
Expand All @@ -123,6 +122,8 @@ dependencies {
implementation(libs.coil.network.okhttp)
// Splash Screen API
implementation(libs.androidx.splashscreen)
// DataStore Preferences
implementation(libs.androidx.datastore.preferences)
}

// Allow references to generated code
Expand Down
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

/**
* [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
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runBlocking used in interceptor chain. Using runBlocking in AuthInterceptor.intercept() can block the OkHttp thread pool, potentially causing performance issues or deadlocks under load. Consider using a coroutine-aware HTTP client or restructuring to avoid blocking calls in the interceptor.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Little difficult to work around this because token manager is suspend and intercept is a synchronous function we are overriding for okhttp


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 {
// 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
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runBlocking used in authenticator. Using runBlocking in TokenAuthenticator.authenticate() can block the OkHttp thread pool during token refresh attempts. While the mutex helps prevent concurrent refreshes, this could still cause performance degradation. Consider the performance implications, especially when multiple requests fail simultaneously.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing here for the intercept with synchronous function and suspend functions used inside

}

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"
}
}
Loading
Loading