From 31f723cbd6902f8f60d8450caaa59b6d70602673 Mon Sep 17 00:00:00 2001 From: beetzung Date: Mon, 2 Mar 2026 20:19:54 +0100 Subject: [PATCH] Implement Google->Firebase->Backend auth flow - Add authGoogleNew GraphQL mutation and token exchange in AuthServiceImpl - Add ApiTokenStore and SharedPreferencesApiTokenStore for API token persistence - Add ApolloAuthInterceptor to inject Bearer token on GraphQL requests - Introduce FirebaseSessionManager for testable Firebase auth orchestration - Update LoginViewModel to surface backend auth errors - Add unit tests for Firebase failure, backend failure, and auth state Made-with: Cursor --- .../airmq/features/login/LoginViewModel.kt | 7 +- gradle/libs.versions.toml | 4 + sdk/build.gradle.kts | 2 + sdk/src/main/graphql/AuthGoogleNew.graphql | 10 ++ .../org/db3/airmq/sdk/auth/ApiTokenStore.kt | 7 ++ .../org/db3/airmq/sdk/auth/AuthServiceImpl.kt | 59 +++++----- .../airmq/sdk/auth/FirebaseSessionManager.kt | 15 +++ .../sdk/auth/FirebaseSessionManagerImpl.kt | 57 ++++++++++ .../auth/SharedPreferencesApiTokenStore.kt | 31 ++++++ .../kotlin/org/db3/airmq/sdk/di/SDKModule.kt | 6 - .../sdk/network/ApolloAuthInterceptor.kt | 33 ++++++ .../airmq/sdk/auth/FirebaseAuthServiceTest.kt | 105 ++++++++++++++++++ 12 files changed, 297 insertions(+), 39 deletions(-) create mode 100644 sdk/src/main/graphql/AuthGoogleNew.graphql create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/auth/ApiTokenStore.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManager.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManagerImpl.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloAuthInterceptor.kt create mode 100644 sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt index 69c6adf..0b76969 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt @@ -78,7 +78,12 @@ class LoginViewModel @Inject constructor( if (signInResult.isSuccess) { _actions.tryEmit(Action.OpenManage) } else { - _actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.toast_oauth_failed))) + val message = signInResult.exceptionOrNull()?.message + _actions.tryEmit( + Action.ShowMessage( + message ?: appContext.getString(R.string.toast_oauth_failed) + ) + ) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac55d23..1d7f685 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ ksp = "2.0.21-1.0.27" google-services = "4.4.4" androidxCredentials = "1.5.0" googleid = "1.1.1" +kotlinxCoroutinesTest = "1.9.0" +mockk = "1.13.13" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -55,6 +57,8 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidxCredentials" } androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidxCredentials" } googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +mockk-jvm = { group = "io.mockk", name = "mockk-jvm", version.ref = "mockk" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index e85168d..c09cd20 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -62,4 +62,6 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk.jvm) } diff --git a/sdk/src/main/graphql/AuthGoogleNew.graphql b/sdk/src/main/graphql/AuthGoogleNew.graphql new file mode 100644 index 0000000..e06c71e --- /dev/null +++ b/sdk/src/main/graphql/AuthGoogleNew.graphql @@ -0,0 +1,10 @@ +mutation AuthGoogleNew($accessToken: String!) { + authGoogleNew(input: { accessToken: $accessToken }) { + token + name + email + roles + _id + regDate + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/ApiTokenStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/ApiTokenStore.kt new file mode 100644 index 0000000..f758744 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/ApiTokenStore.kt @@ -0,0 +1,7 @@ +package org.db3.airmq.sdk.auth + +interface ApiTokenStore { + fun getToken(): String? + fun saveToken(token: String): Result + fun clearToken(): Result +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt index c5c303f..667fc5b 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt @@ -1,25 +1,26 @@ package org.db3.airmq.sdk.auth -import com.google.android.gms.tasks.Task -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.auth.FirebaseUser -import com.google.firebase.auth.GoogleAuthProvider +import com.apollographql.apollo.ApolloClient import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlinx.coroutines.suspendCancellableCoroutine +import org.db3.airmq.sdk.AuthGoogleNewMutation import org.db3.airmq.sdk.auth.model.AuthProvider import org.db3.airmq.sdk.auth.model.User @Singleton class FirebaseAuthService @Inject constructor( - private val firebaseAuth: FirebaseAuth + private val firebaseSessionManager: FirebaseSessionManager, + private val apolloClient: ApolloClient, + private val apiTokenStore: ApiTokenStore ) : AuthService { - override suspend fun getUser(): User? = firebaseAuth.currentUser?.toUser() + override suspend fun getUser(): User? = firebaseSessionManager.getUser() - override suspend fun isAuthenticated(): Boolean = firebaseAuth.currentUser != null + override suspend fun isAuthenticated(): Boolean { + val hasFirebaseUser = firebaseSessionManager.isSignedIn() + val hasApiToken = !apiTokenStore.getToken().isNullOrBlank() + return hasFirebaseUser && hasApiToken + } override suspend fun signIn(provider: AuthProvider, token: String): Result = runCatching { require(token.isNotBlank()) { "ID token is required for Google sign-in." } @@ -29,32 +30,26 @@ class FirebaseAuthService @Inject constructor( } override suspend fun signOut(): Result = runCatching { - firebaseAuth.signOut() + firebaseSessionManager.signOut() + apiTokenStore.clearToken().getOrThrow() } private suspend fun signInWithGoogle(token: String): User { - val credential = GoogleAuthProvider.getCredential(token, null) - val authResult = firebaseAuth.signInWithCredential(credential).awaitResult() - val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.") - return firebaseUser.toUser() + val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token) + val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken) + apiTokenStore.saveToken(apiToken).getOrThrow() + return firebaseSessionUser.user } - private fun FirebaseUser.toUser(): User = User( - userId = uid, - email = email, - displayName = displayName, - isAuthenticated = true - ) -} - -private suspend fun Task.awaitResult(): T = suspendCancellableCoroutine { continuation -> - addOnSuccessListener { result -> - continuation.resume(result) - } - addOnFailureListener { exception -> - continuation.resumeWithException(exception) - } - addOnCanceledListener { - continuation.cancel() + private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String { + val response = apolloClient + .mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken)) + .execute() + response.exception?.let { throw it } + response.errors?.firstOrNull()?.let { gqlError -> + throw IllegalStateException(gqlError.message) + } + return response.data?.authGoogleNew?.token + ?: error("Backend auth exchange succeeded without API token.") } } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManager.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManager.kt new file mode 100644 index 0000000..1c19170 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManager.kt @@ -0,0 +1,15 @@ +package org.db3.airmq.sdk.auth + +import org.db3.airmq.sdk.auth.model.User + +interface FirebaseSessionManager { + suspend fun getUser(): User? + suspend fun isSignedIn(): Boolean + suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser + suspend fun signOut() +} + +data class FirebaseSessionUser( + val user: User, + val firebaseAccessToken: String +) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManagerImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManagerImpl.kt new file mode 100644 index 0000000..78885fa --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManagerImpl.kt @@ -0,0 +1,57 @@ +package org.db3.airmq.sdk.auth + +import com.google.android.gms.tasks.Task +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.GoogleAuthProvider +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import org.db3.airmq.sdk.auth.model.User + +@Singleton +class FirebaseSessionManagerImpl @Inject constructor( + private val firebaseAuth: FirebaseAuth +) : FirebaseSessionManager { + + override suspend fun getUser(): User? = firebaseAuth.currentUser?.toUser() + + override suspend fun isSignedIn(): Boolean = firebaseAuth.currentUser != null + + override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser { + val credential = GoogleAuthProvider.getCredential(idToken, null) + val authResult = firebaseAuth.signInWithCredential(credential).awaitResult() + val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.") + val firebaseAccessToken = firebaseUser.getIdToken(true).awaitResult().token + ?: error("Firebase token exchange completed without an ID token.") + return FirebaseSessionUser( + user = firebaseUser.toUser(), + firebaseAccessToken = firebaseAccessToken + ) + } + + override suspend fun signOut() { + firebaseAuth.signOut() + } + + private fun FirebaseUser.toUser(): User = User( + userId = uid, + email = email, + displayName = displayName, + isAuthenticated = true + ) +} + +private suspend fun Task.awaitResult(): T = suspendCancellableCoroutine { continuation -> + addOnSuccessListener { result -> + continuation.resume(result) + } + addOnFailureListener { exception -> + continuation.resumeWithException(exception) + } + addOnCanceledListener { + continuation.cancel() + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt new file mode 100644 index 0000000..00c0a93 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt @@ -0,0 +1,31 @@ +package org.db3.airmq.sdk.auth + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SharedPreferencesApiTokenStore @Inject constructor( + @ApplicationContext context: Context +) : ApiTokenStore { + + private val sharedPreferences = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + override fun getToken(): String? = sharedPreferences.getString(KEY_API_TOKEN, null) + + override fun saveToken(token: String): Result = runCatching { + require(token.isNotBlank()) { "API token cannot be blank." } + sharedPreferences.edit().putString(KEY_API_TOKEN, token).apply() + } + + override fun clearToken(): Result = runCatching { + sharedPreferences.edit().remove(KEY_API_TOKEN).apply() + } + + private companion object { + private const val PREFERENCES_NAME = "airmq_auth" + private const val KEY_API_TOKEN = "api_token" + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt index 23c26de..c44f076 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt @@ -10,8 +10,6 @@ import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import org.db3.airmq.sdk.auth.ApiTokenStore import org.db3.airmq.sdk.auth.AuthService -import org.db3.airmq.sdk.auth.BackendAuthGateway -import org.db3.airmq.sdk.auth.BackendAuthGatewayImpl import org.db3.airmq.sdk.auth.FirebaseAuthService import org.db3.airmq.sdk.auth.FirebaseSessionManager import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl @@ -58,10 +56,6 @@ abstract class SDKBindModule { @Singleton abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager - @Binds - @Singleton - abstract fun bindBackendAuthGateway(impl: BackendAuthGatewayImpl): BackendAuthGateway - @Binds @Singleton abstract fun bindMapService(impl: MapServiceImpl): MapService diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloAuthInterceptor.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloAuthInterceptor.kt new file mode 100644 index 0000000..c4cf7d4 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloAuthInterceptor.kt @@ -0,0 +1,33 @@ +package org.db3.airmq.sdk.network + +import com.apollographql.apollo.api.ApolloRequest +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.interceptor.ApolloInterceptor +import com.apollographql.apollo.interceptor.ApolloInterceptorChain +import kotlinx.coroutines.flow.Flow +import org.db3.airmq.sdk.auth.ApiTokenStore + +class ApolloAuthInterceptor( + private val apiTokenStore: ApiTokenStore +) : ApolloInterceptor { + override fun intercept( + request: ApolloRequest, + chain: ApolloInterceptorChain + ): Flow> { + val token = apiTokenStore.getToken() + val requestWithAuth = if (token.isNullOrBlank()) { + request + } else { + request.newBuilder() + .addHttpHeader(name = AUTH_HEADER, value = "$BEARER_PREFIX $token") + .build() + } + return chain.proceed(requestWithAuth) + } + + private companion object { + private const val AUTH_HEADER = "Authorization" + private const val BEARER_PREFIX = "Bearer" + } +} diff --git a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt new file mode 100644 index 0000000..3952fcf --- /dev/null +++ b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt @@ -0,0 +1,105 @@ +package org.db3.airmq.sdk.auth + +import com.apollographql.apollo.ApolloClient +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.db3.airmq.sdk.AuthGoogleNewMutation +import org.db3.airmq.sdk.auth.model.AuthProvider +import org.db3.airmq.sdk.auth.model.User +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FirebaseAuthServiceTest { + + @Test + fun signIn_google_failsWhenFirebaseSignInFails() = runTest { + val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed")) + val apolloClient = mockk() + val tokenStore = FakeApiTokenStore() + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore) + + val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token") + + assertTrue(result.isFailure) + assertEquals("firebase failed", result.exceptionOrNull()?.message) + assertEquals(null, tokenStore.storedToken) + } + + @Test + fun signIn_google_failsWhenBackendExchangeFails() = runTest { + val sessionManager = FakeFirebaseSessionManager( + signInResult = FirebaseSessionUser( + user = User("uid-1", "user@airmq.cc", "User", true), + firebaseAccessToken = "firebase-token" + ) + ) + val apolloClient = mockk() + val tokenStore = FakeApiTokenStore() + mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed")) + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore) + + val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token") + + assertTrue(result.isFailure) + assertEquals("backend failed", result.exceptionOrNull()?.message) + assertEquals(null, tokenStore.storedToken) + } + + @Test + fun isAuthenticated_trueOnlyWhenFirebaseAndApiTokenPresent() = runTest { + val sessionManager = FakeFirebaseSessionManager(isSignedIn = true) + val apolloClient = mockk() + val tokenStore = FakeApiTokenStore(storedToken = "api-token") + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore) + + assertTrue(service.isAuthenticated()) + + tokenStore.storedToken = null + assertFalse(service.isAuthenticated()) + } + + private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) { + val mockCall = mockk>() + every { apolloClient.mutation(any()) } returns mockCall + coEvery { mockCall.execute() } throws error + } +} + +private class FakeFirebaseSessionManager( + private val signInResult: FirebaseSessionUser? = null, + private val signInError: Throwable? = null, + private val isSignedIn: Boolean = true +) : FirebaseSessionManager { + override suspend fun getUser(): User? = signInResult?.user + + override suspend fun isSignedIn(): Boolean = isSignedIn + + override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser { + signInError?.let { throw it } + return signInResult ?: error("No signInResult configured") + } + + override suspend fun signOut() = Unit +} + +private class FakeApiTokenStore( + var storedToken: String? = null, + private val saveError: Throwable? = null +) : ApiTokenStore { + override fun getToken(): String? = storedToken + + override fun saveToken(token: String): Result { + saveError?.let { return Result.failure(it) } + storedToken = token + return Result.success(Unit) + } + + override fun clearToken(): Result { + storedToken = null + return Result.success(Unit) + } +}