From a5642c6e9e7a922bea752cf4ace21c8d0fffa2d1 Mon Sep 17 00:00:00 2001 From: Emil Jiang Date: Thu, 5 Nov 2026 14:47:30 -0500 Subject: [PATCH] Add Google sign-up and sign-in --- .gitignore | 3 +- README.md | 14 +++ app/build.gradle.kts | 53 ++++----- app/src/main/graphql/Auth.graphql | 25 +++++ app/src/main/graphql/schema.graphqls | 8 +- .../com/cornellappdev/score/MainActivity.kt | 91 ++-------------- .../java/com/cornellappdev/score/ScoreApp.kt | 65 +++++++++++ .../com/cornellappdev/score/auth/AuthApi.kt | 99 +++++++++++++++++ .../cornellappdev/score/auth/AuthModule.kt | 27 +++++ .../score/auth/AuthRepository.kt | 101 ++++++++++++++++++ .../com/cornellappdev/score/auth/AuthState.kt | 39 +++++++ .../score/auth/AuthStatusOverlay.kt | 54 ++++++++++ .../cornellappdev/score/auth/AuthViewModel.kt | 61 +++++++++++ .../score/auth/GoogleSignInClient.kt | 61 +++++++++++ .../score/auth/SessionStorage.kt | 90 ++++++++++++++++ app/src/main/res/values/strings.xml | 6 +- 16 files changed, 680 insertions(+), 117 deletions(-) create mode 100644 app/src/main/graphql/Auth.graphql create mode 100644 app/src/main/java/com/cornellappdev/score/ScoreApp.kt create mode 100644 app/src/main/java/com/cornellappdev/score/auth/AuthApi.kt create mode 100644 app/src/main/java/com/cornellappdev/score/auth/AuthModule.kt create mode 100644 app/src/main/java/com/cornellappdev/score/auth/AuthRepository.kt create mode 100644 app/src/main/java/com/cornellappdev/score/auth/AuthState.kt create mode 100644 app/src/main/java/com/cornellappdev/score/auth/AuthStatusOverlay.kt create mode 100644 app/src/main/java/com/cornellappdev/score/auth/AuthViewModel.kt create mode 100644 app/src/main/java/com/cornellappdev/score/auth/GoogleSignInClient.kt create mode 100644 app/src/main/java/com/cornellappdev/score/auth/SessionStorage.kt diff --git a/.gitignore b/.gitignore index c4b45f3c..1b777d2f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ app/google-services.json secrets.properties # Mac -.DS_store \ No newline at end of file +.DS_store +.kotlin/ diff --git a/README.md b/README.md index aecc01f1..e5d13a0e 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,17 @@ Score is Cornell’s all-in-one sports discovery hub that makes Cornell sports e - `secrets.properties` 4. Add `secrets.properties` to the project root and `google-services.json` to the app level in Android Studio 5. Build and run! + +## Authentication + +Google sign-in uses Credential Manager and Firebase Authentication, then exchanges the Firebase ID token for Score backend tokens. Enable Google as a sign-in provider in Firebase, register the Android signing SHA-1 and SHA-256 fingerprints, and install the updated `app/google-services.json`. The file must include the web OAuth client so the Google Services plugin generates `default_web_client_id`. The backend Firebase Admin configuration must use the same Firebase project. + +The backend must support `loginUser(idToken:)`, `signupUser(idToken:, name:)`, `refreshAccessToken`, and `logoutUser`. The Android schema matches the local backend's token-only responses. It does not require the user and favorite-team response fields from the iOS PR. Cornell email filtering is also required on the backend to enforce the restriction outside this client. + +The welcome screen's Sign Up and Log In buttons both start Google sign-in. The app first calls `loginUser`; only a missing-user response triggers `signupUser`. Successful authentication enters the existing app, and Sign Up Later keeps guest browsing available. + +`AuthRepository.state` records the backend session. A Firebase session alone does not sign the user into Score. Backend sessions are encrypted using Android Keystore and stored outside backups. Session restoration refreshes expired access tokens; network failures preserve the saved session, while rejected refresh tokens clear it. + +Auth is currently limited to the welcome flow. Profile screens, profile and sports onboarding, and authorization headers on the app's existing API client are not connected to it. + +Run `./gradlew :app:testDebugUnitTest :app:lintDebug :app:assembleDebug` with Java 17 to verify the app. End-to-end Google sign-in needs a device or emulator with Google Play services and a backend configured for the updated Firebase project. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0d8f3d3..a8b00ce0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,12 +14,12 @@ plugins { id("com.apollographql.apollo") version "4.0.0" id("kotlin-kapt") id("com.google.dagger.hilt.android") - id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" // this version matches your Kotlin version + id("org.jetbrains.kotlin.plugin.compose") version + "2.0.0" // this version matches your Kotlin version id("org.jetbrains.kotlin.plugin.serialization") id("com.google.gms.google-services") } - android { namespace = "com.cornellappdev.score" compileSdk = 35 @@ -38,46 +38,30 @@ android { } buildTypes { debug { - buildConfigField( - "String", - "BASE_URL", - "\"${secrets.getProperty("API_URL_DEV")}\"" - ) - buildConfigField( - "String", - "SOCKET_URL", - "\"${secrets.getProperty("SOCKET_URL_DEV")}\"" - ) + buildConfigField("String", "BASE_URL", "\"${secrets.getProperty("API_URL_DEV")}\"") + buildConfigField("String", "SOCKET_URL", "\"${secrets.getProperty("SOCKET_URL_DEV")}\"") } release { - buildConfigField( - "String", - "BASE_URL", - "\"${secrets.getProperty("API_URL_PROD")}\"" - ) + buildConfigField("String", "BASE_URL", "\"${secrets.getProperty("API_URL_PROD")}\"") buildConfigField( "String", "SOCKET_URL", - "\"${secrets.getProperty("SOCKET_URL_PROD")}\"" + "\"${secrets.getProperty("SOCKET_URL_PROD")}\"", ) isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } - composeOptions { - kotlinCompilerExtensionVersion = "1.7.0" - } + composeOptions { kotlinCompilerExtensionVersion = "1.7.0" } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - } + kotlinOptions { jvmTarget = "1.8" } } dependencies { @@ -118,16 +102,25 @@ dependencies { lintChecks(libs.compose.lint.checks) implementation(platform("com.google.firebase:firebase-bom:34.3.0")) implementation("com.google.firebase:firebase-analytics") + implementation("com.google.firebase:firebase-auth") + implementation("androidx.credentials:credentials:1.5.0") + implementation("androidx.credentials:credentials-play-services-auth:1.5.0") + implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.1") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") } apollo { service("service") { packageName.set("com.example.score") - secrets.getProperty("API_URL_DEV")?.takeIf { it.isNotBlank() }?.let { apiUrl -> - introspection { - endpointUrl.set(apiUrl) - schemaFile.set(file("src/main/graphql/schema.graphqls")) + secrets + .getProperty("API_URL_DEV") + ?.takeIf { it.isNotBlank() } + ?.let { apiUrl -> + introspection { + endpointUrl.set(apiUrl) + schemaFile.set(file("src/main/graphql/schema.graphqls")) + } } - } } } diff --git a/app/src/main/graphql/Auth.graphql b/app/src/main/graphql/Auth.graphql new file mode 100644 index 00000000..705a265e --- /dev/null +++ b/app/src/main/graphql/Auth.graphql @@ -0,0 +1,25 @@ +mutation LoginUser($idToken: String!) { + loginUser(idToken: $idToken) { + accessToken + refreshToken + } +} + +mutation SignupUser($idToken: String!, $name: String) { + signupUser(idToken: $idToken, name: $name) { + accessToken + refreshToken + } +} + +mutation RefreshAccessToken { + refreshAccessToken { + newAccessToken + } +} + +mutation LogoutUser { + logoutUser { + success + } +} diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index e6c5c91d..1d4f4b93 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -223,14 +223,14 @@ type Mutation { createArticle(image: String, publishedAt: String!, slug: String!, sportsType: String!, title: String!, url: String!): CreateArticle """ - Login by net_id; returns access_token and refresh_token. + Login with Firebase ID token; returns access_token and refresh_token. """ - loginUser("User's net ID (e.g. Cornell netid)." netId: String!): LoginUser + loginUser("Firebase ID token from the client." idToken: String!): LoginUser """ - Create a new user by net_id; returns access_token and refresh_token (no separate login needed). + Create a new user with Firebase ID token; returns access_token and refresh_token. """ - signupUser("Email address." email: String, "Display name." name: String, "User's net ID (e.g. Cornell netid)." netId: String!): SignupUser + signupUser("Email address." email: String, "Display name." name: String, "Firebase ID token from the client." idToken: String!): SignupUser """ Exchange a valid refresh token (in Authorization header) for a new access_token. diff --git a/app/src/main/java/com/cornellappdev/score/MainActivity.kt b/app/src/main/java/com/cornellappdev/score/MainActivity.kt index a2101075..e6b4ee80 100644 --- a/app/src/main/java/com/cornellappdev/score/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/score/MainActivity.kt @@ -3,101 +3,30 @@ package com.cornellappdev.score import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.core.content.edit +import com.cornellappdev.score.auth.AuthViewModel import com.cornellappdev.score.model.OnboardingPreferencesRepository -import com.cornellappdev.score.nav.root.RootNavigation -import com.cornellappdev.score.screen.FavoriteSport -import com.cornellappdev.score.screen.OnboardingFavoriteSportsScreen -import com.cornellappdev.score.screen.OnboardingProfileSelectScreen -import com.cornellappdev.score.screen.OnboardingScreen import com.google.firebase.Firebase -import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.analytics import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -private const val APP_PREFERENCES = "score_app_preferences" -private const val KEY_ONBOARDING_PROFILE_PICTURE = "onboarding_profile_picture" -private const val KEY_ONBOARDING_FAVORITE_SPORTS = "onboarding_favorite_sports" - -private lateinit var firebaseAnalytics: FirebaseAnalytics - -private enum class OnboardingStep { - WELCOME, - PROFILE_SELECT, - SPORT_SELECT, - COMPLETE -} - @AndroidEntryPoint class MainActivity : AppCompatActivity() { - @Inject - lateinit var onboardingPreferencesRepository: OnboardingPreferencesRepository + @Inject lateinit var onboardingPreferences: OnboardingPreferencesRepository + private val authViewModel: AuthViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - - val onboardingCompleted = onboardingPreferencesRepository.isOnboardingCompleted() - val preferences = getSharedPreferences(APP_PREFERENCES, MODE_PRIVATE) - val savedFavoriteSportKeys = preferences - .getStringSet(KEY_ONBOARDING_FAVORITE_SPORTS, emptySet()) - .orEmpty() - .joinToString("|") - + Firebase.analytics setContent { - var onboardingStep by rememberSaveable { - mutableStateOf( - if (onboardingCompleted) OnboardingStep.COMPLETE else OnboardingStep.WELCOME - ) - } - - val completeOnboarding = remember { - { - onboardingPreferencesRepository.markOnboardingCompleted() - onboardingStep = OnboardingStep.COMPLETE - } - } - - when (onboardingStep) { - OnboardingStep.WELCOME -> OnboardingScreen( - onSignUp = { onboardingStep = OnboardingStep.PROFILE_SELECT }, - onLogIn = completeOnboarding, - onLater = completeOnboarding - ) - - OnboardingStep.PROFILE_SELECT -> OnboardingProfileSelectScreen( - onNext = { selectedPicture -> - preferences.edit { - putString(KEY_ONBOARDING_PROFILE_PICTURE, selectedPicture.name) - } - onboardingStep = OnboardingStep.SPORT_SELECT - } - ) - - OnboardingStep.SPORT_SELECT -> OnboardingFavoriteSportsScreen( - initiallySelectedKeys = savedFavoriteSportKeys, - onFinish = { selectedSports -> - preferences.edit { - putStringSet( - KEY_ONBOARDING_FAVORITE_SPORTS, - selectedSports.map(FavoriteSport::storageKey).toSet() - ) - } - completeOnboarding() - } - ) - - OnboardingStep.COMPLETE -> RootNavigation() - } + ScoreApp( + authViewModel = authViewModel, + onboardingPreferences = onboardingPreferences, + onSignIn = { authViewModel.signIn(this) }, + ) } - - firebaseAnalytics = Firebase.analytics } } diff --git a/app/src/main/java/com/cornellappdev/score/ScoreApp.kt b/app/src/main/java/com/cornellappdev/score/ScoreApp.kt new file mode 100644 index 00000000..7d7e246a --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/ScoreApp.kt @@ -0,0 +1,65 @@ +package com.cornellappdev.score + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.cornellappdev.score.auth.AuthState +import com.cornellappdev.score.auth.AuthStatusOverlay +import com.cornellappdev.score.auth.AuthViewModel +import com.cornellappdev.score.model.OnboardingPreferencesRepository +import com.cornellappdev.score.nav.root.RootNavigation +import com.cornellappdev.score.screen.OnboardingScreen +import com.cornellappdev.score.theme.CrimsonPrimary + +@Composable +fun ScoreApp( + authViewModel: AuthViewModel, + onboardingPreferences: OnboardingPreferencesRepository, + onSignIn: () -> Unit, + modifier: Modifier = Modifier, +) { + val authState by authViewModel.authState.collectAsStateWithLifecycle() + val signInState by authViewModel.signInState.collectAsStateWithLifecycle() + var skippedWelcome by rememberSaveable { + mutableStateOf(onboardingPreferences.isOnboardingCompleted()) + } + val signedIn = authState is AuthState.SignedIn + + LaunchedEffect(signedIn) { + if (signedIn) { + onboardingPreferences.markOnboardingCompleted() + skippedWelcome = true + } + } + + Box(modifier.fillMaxSize().background(Color.White)) { + when { + authState == AuthState.Loading -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = CrimsonPrimary) + } + signedIn || skippedWelcome -> RootNavigation() + else -> + OnboardingScreen( + onSignUp = onSignIn, + onLogIn = onSignIn, + onLater = { + onboardingPreferences.markOnboardingCompleted() + skippedWelcome = true + }, + ) + } + AuthStatusOverlay(signInState, authViewModel::dismissError) + } +} diff --git a/app/src/main/java/com/cornellappdev/score/auth/AuthApi.kt b/app/src/main/java/com/cornellappdev/score/auth/AuthApi.kt new file mode 100644 index 00000000..ff1aa023 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/auth/AuthApi.kt @@ -0,0 +1,99 @@ +package com.cornellappdev.score.auth + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Optional +import com.apollographql.apollo.api.http.HttpHeader +import com.apollographql.apollo.exception.ApolloHttpException +import com.example.score.LoginUserMutation +import com.example.score.LogoutUserMutation +import com.example.score.RefreshAccessTokenMutation +import com.example.score.SignupUserMutation +import javax.inject.Inject +import javax.inject.Named + +interface AuthApi { + suspend fun login(idToken: String): AuthTokens + + suspend fun signup(identity: GoogleIdentity): AuthTokens + + suspend fun refresh(refreshToken: String): String + + suspend fun revoke(token: String) +} + +class UserNotFoundException : Exception() + +class InvalidSessionException : Exception() + +class ApolloAuthApi @Inject constructor(@Named("auth") private val client: ApolloClient) : AuthApi { + override suspend fun login(idToken: String): AuthTokens { + val response = client.mutation(LoginUserMutation(idToken)).execute() + if (response.errors?.any { it.message == "User not found." } == true) { + throw UserNotFoundException() + } + response.checkErrors() + val result = response.data?.loginUser ?: error("Missing login response") + return tokens(result.accessToken, result.refreshToken) + } + + override suspend fun signup(identity: GoogleIdentity): AuthTokens { + val response = + client + .mutation( + SignupUserMutation( + identity.firebaseIdToken, + Optional.present(identity.user.name), + ) + ) + .execute() + response.checkErrors() + val result = response.data?.signupUser ?: error("Missing signup response") + return tokens(result.accessToken, result.refreshToken) + } + + override suspend fun refresh(refreshToken: String): String { + val response = + client + .mutation(RefreshAccessTokenMutation()) + .httpHeaders(listOf(HttpHeader("Authorization", "Bearer $refreshToken"))) + .execute() + val status = (response.exception as? ApolloHttpException)?.statusCode + if (status == 422 || response.isSessionRejected()) throw InvalidSessionException() + response.checkErrors() + return response.data?.refreshAccessToken?.newAccessToken?.takeIf { it.isNotBlank() } + ?: error("Missing refresh response") + } + + override suspend fun revoke(token: String) { + client + .mutation(LogoutUserMutation()) + .httpHeaders(listOf(HttpHeader("Authorization", "Bearer $token"))) + .execute() + .checkErrors() + } + + private fun tokens(access: String?, refresh: String?): AuthTokens { + require(!access.isNullOrBlank() && !refresh.isNullOrBlank()) { "Missing session tokens" } + return AuthTokens(access, refresh) + } + + private fun ApolloResponse<*>.isSessionRejected(): Boolean { + if ((exception as? ApolloHttpException)?.statusCode == 401) return true + return errors.orEmpty().any { + it.extensions?.get("code") == "UNAUTHENTICATED" || + it.message in + setOf( + "Token has expired", + "Token has been revoked", + "Signature verification failed", + "Not enough segments", + ) + } + } + + private fun ApolloResponse<*>.checkErrors() { + exception?.let { throw it } + check(errors.isNullOrEmpty()) { "Authentication request failed" } + } +} diff --git a/app/src/main/java/com/cornellappdev/score/auth/AuthModule.kt b/app/src/main/java/com/cornellappdev/score/auth/AuthModule.kt new file mode 100644 index 00000000..791edb74 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/auth/AuthModule.kt @@ -0,0 +1,27 @@ +package com.cornellappdev.score.auth + +import com.apollographql.apollo.ApolloClient +import com.cornellappdev.score.BuildConfig +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class AuthModule { + @Binds @Singleton abstract fun bindStorage(storage: EncryptedSessionStorage): SessionStorage + + @Binds @Singleton abstract fun bindApi(api: ApolloAuthApi): AuthApi + + companion object { + @Provides + @Singleton + @Named("auth") + fun provideAuthClient(): ApolloClient = + ApolloClient.Builder().serverUrl(BuildConfig.BASE_URL).build() + } +} diff --git a/app/src/main/java/com/cornellappdev/score/auth/AuthRepository.kt b/app/src/main/java/com/cornellappdev/score/auth/AuthRepository.kt new file mode 100644 index 00000000..7caa4934 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/auth/AuthRepository.kt @@ -0,0 +1,101 @@ +package com.cornellappdev.score.auth + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +@Singleton +class AuthRepository +@Inject +constructor(private val api: AuthApi, private val storage: SessionStorage) { + private val mutex = Mutex() + private val mutableState = MutableStateFlow(AuthState.Loading) + val state = mutableState.asStateFlow() + private var session: AuthSession? = null + private var restored = false + + suspend fun restoreSession() { + mutex.withLock { restoreStoredSession() } + try { + accessToken() + } catch (error: CancellationException) { + throw error + } catch (_: Exception) {} + } + + suspend fun signIn(identity: GoogleIdentity) = + mutex.withLock { + require(identity.user.email.endsWith("@cornell.edu", ignoreCase = true)) + val tokens = + try { + api.login(identity.firebaseIdToken) + } catch (_: UserNotFoundException) { + api.signup(identity) + } + saveSession(AuthSession(identity.user, tokens.accessToken, tokens.refreshToken)) + restored = true + } + + suspend fun accessToken(): String? = + mutex.withLock { + restoreStoredSession() + val current = session ?: return@withLock null + if (!isTokenExpired(current.accessToken)) return@withLock current.accessToken + if (isTokenExpired(current.refreshToken)) { + clearSession() + return@withLock null + } + try { + val token = api.refresh(current.refreshToken) + saveSession(current.copy(accessToken = token)) + token + } catch (_: InvalidSessionException) { + clearSession() + null + } + } + + suspend fun signOut() { + val previous = + mutex.withLock { + val current = session + clearSession() + current + } ?: return + for (token in listOf(previous.refreshToken, previous.accessToken)) { + try { + api.revoke(token) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) {} + } + } + + private suspend fun restoreStoredSession() { + if (restored) return + session = storage.read() + restored = true + publishState() + } + + private suspend fun saveSession(value: AuthSession) { + storage.save(value) + session = value + publishState() + } + + private suspend fun clearSession() { + storage.clear() + session = null + restored = true + publishState() + } + + private fun publishState() { + mutableState.value = session?.let { AuthState.SignedIn(it.user) } ?: AuthState.SignedOut + } +} diff --git a/app/src/main/java/com/cornellappdev/score/auth/AuthState.kt b/app/src/main/java/com/cornellappdev/score/auth/AuthState.kt new file mode 100644 index 00000000..f8b0eb6c --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/auth/AuthState.kt @@ -0,0 +1,39 @@ +package com.cornellappdev.score.auth + +import java.util.Base64 +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +sealed interface AuthState { + data object Loading : AuthState + + data object SignedOut : AuthState + + data class SignedIn(val user: AuthUser) : AuthState +} + +@Serializable data class AuthUser(val id: String, val name: String, val email: String) + +@Serializable +data class AuthSession(val user: AuthUser, val accessToken: String, val refreshToken: String) + +data class AuthTokens(val accessToken: String, val refreshToken: String) + +data class GoogleIdentity(val user: AuthUser, val firebaseIdToken: String) + +fun isTokenExpired(token: String, nowMillis: Long = System.currentTimeMillis()): Boolean { + return try { + val payload = String(Base64.getUrlDecoder().decode(token.split('.')[1]), Charsets.UTF_8) + val expiresAt = + Json.parseToJsonElement(payload).jsonObject["exp"]?.jsonPrimitive?.longOrNull + ?: return true + expiresAt <= nowMillis / 1000 + 60 + } catch (_: IllegalArgumentException) { + true + } catch (_: IndexOutOfBoundsException) { + true + } +} diff --git a/app/src/main/java/com/cornellappdev/score/auth/AuthStatusOverlay.kt b/app/src/main/java/com/cornellappdev/score/auth/AuthStatusOverlay.kt new file mode 100644 index 00000000..63c53184 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/auth/AuthStatusOverlay.kt @@ -0,0 +1,54 @@ +package com.cornellappdev.score.auth + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.cornellappdev.score.R +import com.cornellappdev.score.theme.CrimsonPrimary + +@Composable +fun AuthStatusOverlay( + state: SignInState, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, +) { + if (state.busy) { + Dialog( + onDismissRequest = {}, + properties = + DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Box( + modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = CrimsonPrimary) + } + } + } + state.error?.let { message -> + AlertDialog( + onDismissRequest = onDismissError, + title = { Text(stringResource(R.string.auth_error_title)) }, + text = { Text(stringResource(message)) }, + confirmButton = { + TextButton(onClick = onDismissError) { Text(stringResource(android.R.string.ok)) } + }, + ) + } +} diff --git a/app/src/main/java/com/cornellappdev/score/auth/AuthViewModel.kt b/app/src/main/java/com/cornellappdev/score/auth/AuthViewModel.kt new file mode 100644 index 00000000..b66fc170 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/auth/AuthViewModel.kt @@ -0,0 +1,61 @@ +package com.cornellappdev.score.auth + +import android.app.Activity +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.NoCredentialException +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.cornellappdev.score.R +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class SignInState(val busy: Boolean = false, val error: Int? = null) + +@HiltViewModel +class AuthViewModel +@Inject +constructor(private val repository: AuthRepository, private val google: GoogleSignInClient) : + ViewModel() { + val authState = repository.state + private val mutableSignInState = MutableStateFlow(SignInState(busy = true)) + val signInState = mutableSignInState.asStateFlow() + + init { + viewModelScope.launch { + repository.restoreSession() + mutableSignInState.value = SignInState() + } + } + + fun signIn(activity: Activity) { + if (mutableSignInState.value.busy) return + viewModelScope.launch { + mutableSignInState.value = SignInState(busy = true) + try { + val identity = google.signIn(activity) + repository.signIn(identity) + mutableSignInState.value = SignInState() + } catch (_: GetCredentialCancellationException) { + mutableSignInState.value = SignInState() + } catch (_: NoCredentialException) { + mutableSignInState.value = SignInState(error = R.string.auth_no_google_account) + } catch (_: CornellAccountRequiredException) { + mutableSignInState.value = SignInState(error = R.string.auth_cornell_required) + } catch (error: CancellationException) { + mutableSignInState.value = SignInState() + throw error + } catch (_: Exception) { + google.signOut() + mutableSignInState.value = SignInState(error = R.string.auth_sign_in_failed) + } + } + } + + fun dismissError() { + mutableSignInState.value = mutableSignInState.value.copy(error = null) + } +} diff --git a/app/src/main/java/com/cornellappdev/score/auth/GoogleSignInClient.kt b/app/src/main/java/com/cornellappdev/score/auth/GoogleSignInClient.kt new file mode 100644 index 00000000..0932eea2 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/auth/GoogleSignInClient.kt @@ -0,0 +1,61 @@ +package com.cornellappdev.score.auth + +import android.app.Activity +import android.content.Context +import androidx.credentials.ClearCredentialStateRequest +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.exceptions.ClearCredentialException +import com.cornellappdev.score.R +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.GoogleAuthProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.tasks.await + +class GoogleSignInClient @Inject constructor(@ApplicationContext private val context: Context) { + suspend fun signIn(activity: Activity): GoogleIdentity { + val option = + GetSignInWithGoogleOption.Builder(activity.getString(R.string.default_web_client_id)) + .build() + val request = GetCredentialRequest.Builder().addCredentialOption(option).build() + val credential = + CredentialManager.create(activity).getCredential(activity, request).credential + require( + credential is CustomCredential && + credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + ) + val googleToken: GoogleIdTokenCredential = + GoogleIdTokenCredential.createFrom(credential.data) + val firebase = FirebaseAuth.getInstance() + val result = + firebase + .signInWithCredential(GoogleAuthProvider.getCredential(googleToken.idToken, null)) + .await() + val user = requireNotNull(result.user) + val email = user.email.orEmpty() + if (!email.endsWith("@cornell.edu", ignoreCase = true)) { + firebase.signOut() + throw CornellAccountRequiredException() + } + val idToken = requireNotNull(user.getIdToken(false).await().token) + return GoogleIdentity( + AuthUser(user.uid, user.displayName ?: email.substringBefore('@'), email), + idToken, + ) + } + + suspend fun signOut() { + FirebaseAuth.getInstance().signOut() + try { + CredentialManager.create(context).clearCredentialState(ClearCredentialStateRequest()) + } catch (_: ClearCredentialException) { + return + } + } +} + +class CornellAccountRequiredException : Exception() diff --git a/app/src/main/java/com/cornellappdev/score/auth/SessionStorage.kt b/app/src/main/java/com/cornellappdev/score/auth/SessionStorage.kt new file mode 100644 index 00000000..eb9d30e0 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/auth/SessionStorage.kt @@ -0,0 +1,90 @@ +package com.cornellappdev.score.auth + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.AtomicFile +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +interface SessionStorage { + suspend fun read(): AuthSession? + + suspend fun save(session: AuthSession) + + suspend fun clear() +} + +class EncryptedSessionStorage @Inject constructor(@ApplicationContext context: Context) : + SessionStorage { + private val file = AtomicFile(File(context.noBackupFilesDir, "auth_session")) + + override suspend fun read(): AuthSession? = + withContext(Dispatchers.IO) { + try { + val bytes = file.readFully() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + encryptionKey(), + GCMParameterSpec(128, bytes.copyOfRange(0, 12)), + ) + val data = cipher.doFinal(bytes.copyOfRange(12, bytes.size)) + Json.decodeFromString(String(data, Charsets.UTF_8)) + } catch (_: Exception) { + file.delete() + null + } + } + + override suspend fun save(session: AuthSession) = + withContext(Dispatchers.IO) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, encryptionKey()) + val encrypted = + cipher.iv + cipher.doFinal(Json.encodeToString(session).toByteArray(Charsets.UTF_8)) + val output = file.startWrite() + try { + output.write(encrypted) + file.finishWrite(output) + } catch (error: Exception) { + file.failWrite(output) + throw error + } + } + + override suspend fun clear() = withContext(Dispatchers.IO) { file.delete() } + + private fun encryptionKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val existingKey = keyStore.getKey(KEY_ALIAS, null) as? SecretKey + if (existingKey != null) return existingKey + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + .apply { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + ) + } + .generateKey() + } + + private companion object { + const val KEY_ALIAS = "score_auth_session" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75e707e9..d9884fbc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,4 +20,8 @@ Finish with %1$d sports Finish with 1 sport Remove favorite sport - \ No newline at end of file + Please use your Cornell Google account (@cornell.edu). + Couldn’t sign in. Check your connection and try again. + Account + No Google account is available for sign-in. Add your Cornell Google account to this device and try again. +