Skip to content

Conversation

@AndrewCheung360
Copy link
Member

@AndrewCheung360 AndrewCheung360 commented Nov 13, 2025

Overview

Added logic to handle access and refresh tokens from backend

Changes Made

  • Created UserPreferences datastore with serializer that uses Android Keystore system to encrypt sensitive data like the tokens
    • Created Crypto object to encapsulate encryption and decryption logic
  • Created Auth api Service for the token endpoints (verify, refresh)
  • Created TokenManager class to handle getting, storing, updating, and clearing tokens from user preferences datastore
  • Added logic to call verify token on google sign in and store the tokens in the AuthRepository
  • Created SessionManager class to notify session expired to relevant parts of UI (related to potential edge case when refresh token and firebase auth session is expired)
  • Created and applied Interceptor to authenticated OkHttpClient that handles adding the access token automatically to api calls
  • Created and applied TokenAuthenticator to authenticated OkHttpClient that handles authentication failures (eg. different token expiration cases) and their retry flow
  • Separated Authenticated and Unauthenticated okhttpclient and retrofit to avoid circular dependencies and only add interceptor and authenticator to the routes that require authentication.
  • Added minor navigation logic to navigate to onboarding when session expires
  • Gradle/Dependencies
    • Added datastore preferences for storing token data
    • Added kotlin serialization converter for retrofit (replaces moshi)
    • Removed moshi dependencies since using kotlin serialization
    • Updated min sdk to 26 for java base64 usage in serializer

Test Coverage

  • Manually tested on emulator with okhttp logger

Next Steps (delete if not applicable)

  • Test the edge cases (especially for token expiration handling) with mock web server

Related PRs or Issues (delete if not applicable)

Screenshots (delete if not applicable)

Screen.Recording.2025-11-13.at.12.41.31.AM.mov

Misc

  • This video was my source for the encryption logic for the tokens

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR integrates backend token authentication endpoints into the Android app, implementing secure token storage with encryption, automatic token refresh, and comprehensive session management.

Key Changes

  • Implemented encrypted token storage using Android Keystore and DataStore with custom serialization
  • Created token refresh and re-authentication flow with OkHttp Interceptor and Authenticator
  • Migrated from Moshi to Kotlin Serialization for JSON processing
  • Separated authenticated and unauthenticated network clients to avoid circular dependencies

Reviewed Changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
gradle/libs.versions.toml Removed Moshi dependencies, added DataStore Preferences and Kotlin Serialization converter
app/build.gradle.kts Updated minSdk to 26 for Base64 support, replaced Moshi with Kotlin Serialization, added DataStore dependency
app/src/main/AndroidManifest.xml Enabled cleartext traffic for HTTP API calls (development only)
app/src/main/java/com/cornellappdev/hustle/di/NetworkModule.kt Created separate authenticated/unauthenticated OkHttp clients and Retrofit instances, integrated interceptor and authenticator
app/src/main/java/com/cornellappdev/hustle/di/DataStoreModule.kt Configured encrypted DataStore for user preferences
app/src/main/java/com/cornellappdev/hustle/di/AppModule.kt Updated import path for moved AuthRepository
app/src/main/java/com/cornellappdev/hustle/data/security/Crypto.kt Implemented encryption/decryption using Android Keystore with AES-CBC
app/src/main/java/com/cornellappdev/hustle/data/local/auth/UserPreferencesSerializer.kt Custom serializer for encrypting/decrypting DataStore data
app/src/main/java/com/cornellappdev/hustle/data/local/auth/TokenManager.kt Manages secure storage and retrieval of access and refresh tokens
app/src/main/java/com/cornellappdev/hustle/data/repository/auth/SessionManager.kt Notifies UI components when session expires
app/src/main/java/com/cornellappdev/hustle/data/repository/auth/AuthRepository.kt Added token verification and storage on Google sign-in
app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthApiService.kt Defined verify-token and refresh-token API endpoints
app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthInterceptor.kt Adds Authorization header with access token to requests
app/src/main/java/com/cornellappdev/hustle/data/remote/auth/TokenAuthenticator.kt Handles 401 responses with token refresh, Firebase re-auth fallback, and logout
app/src/main/java/com/cornellappdev/hustle/data/model/user/UserPreferences.kt Data class for storing encrypted token data
app/src/main/java/com/cornellappdev/hustle/data/model/user/User.kt Added UserResponse model for backend API
app/src/main/java/com/cornellappdev/hustle/data/model/user/AuthTokens.kt Request/response models for token verification and refresh
app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/RootViewModel.kt Integrated SessionManager to handle session expiration
app/src/main/java/com/cornellappdev/hustle/ui/navigation/HustleNavigation.kt Added navigation to onboarding screen when user is signed out
app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/onboarding/SignInScreenViewModel.kt Updated import path for moved AuthRepository
app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/profile/ProfileScreenViewModel.kt Updated import path for moved AuthRepository
Comments suppressed due to low confidence (2)

app/src/main/java/com/cornellappdev/hustle/data/repository/auth/AuthRepository.kt:76

  • Error handling issue: If authApiService.verifyToken() fails, the user is signed into Firebase but tokens are not stored, leaving the app in an inconsistent state. Consider rolling back the Firebase sign-in (calling firebaseAuth.signOut()) when token verification fails to maintain consistency between Firebase auth state and backend token state.
    app/src/main/java/com/cornellappdev/hustle/data/repository/auth/AuthRepository.kt:69
  • Unclear error message: "Empty response body" doesn't provide enough context about what went wrong. Consider a more specific message like "Token verification succeeded but response body is empty" to help with debugging.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +15 to +17
val accessToken = runBlocking {
tokenManager.getAccessToken()
} ?: return chain.proceed(originalRequest)
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

Comment on lines +34 to +51
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()
}
}
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

…boarding route before navigation; reworked to have individual retrofit builderinstances
Copy link

@zachseidner1 zachseidner1 left a comment

Choose a reason for hiding this comment

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

Impressive stuff as always. Have some questions about the set up but ultimately this looks great. Also, make sure to post the secret.properties file in the hustle-android Slack channel!

Comment on lines +57 to +58
val iv = cipher.iv
val encrypted = cipher.doFinal(bytes)

Choose a reason for hiding this comment

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

what is iv?

Choose a reason for hiding this comment

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

KeyProperties.PURPOSE_ENCRYPT or
KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(BLOCK_MODE)

Choose a reason for hiding this comment

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

why is this line here (maybe add comment explaining)?

Copy link
Member Author

@AndrewCheung360 AndrewCheung360 Nov 15, 2025

Choose a reason for hiding this comment

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

I think I might add a comment to this file with a link to Phillip Lackner's video for more information since I pretty much took it from there

Copy link
Member Author

Choose a reason for hiding this comment

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

From what I know, I think block mode is used since the AES algorithm we are using makes use of block ciphers, and we are telling it to only allow the use of the Cipher Block Chaining strategy for generating the key to solve the problem where the input block will always output the same output block if encrypting each block with the key, so malicious actors can recognize the pattern potentially. (source: Gemini)

Comment on lines +30 to +32
val existingKey = keyStore
.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: createKey()

Choose a reason for hiding this comment

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

I'm wondering how this works, does this use a new key every time we run the app, or is the key somehow saved locally and persisted across sessions? If we are using a new key for every run of the app, how will that work for decrypting stuff we encrypted in the past using a different key?

Copy link
Member Author

@AndrewCheung360 AndrewCheung360 Nov 15, 2025

Choose a reason for hiding this comment

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

It should be the same key that is saved locally I believe. I think it checks if there is an existing key in the keystore, and if not, it creates a key and stores it into the keystore

Choose a reason for hiding this comment

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

Lol following Philipp Lackner tutorial, I approve

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, he is the goat

Comment on lines +15 to +17
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST

Choose a reason for hiding this comment

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

curious why these settings exactly?

Copy link
Member Author

Choose a reason for hiding this comment

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

replay = 0 is to make sure we don't cache any old events for new subscribers because we only want the session expiration to occur once.

extraBufferCapacity = 1: tryEmit is not suspending, so this adds a buffer for tryEmit to drop the event in in case the collector is busy at the moment.

onBufferOverflow = DROP_OLDEST is to make sure we always get the newest event if for instance 2 network calls fail at the same time.

@AndrewCheung360 AndrewCheung360 merged commit d7255ab into main Nov 16, 2025
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants