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
This commit is contained in:
2026-03-02 20:19:54 +01:00
parent 436e165679
commit 31f723cbd6
12 changed files with 297 additions and 39 deletions

View File

@@ -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)
)
)
}
}
}

View File

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

View File

@@ -62,4 +62,6 @@ dependencies {
ksp(libs.hilt.compiler)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk.jvm)
}

View File

@@ -0,0 +1,10 @@
mutation AuthGoogleNew($accessToken: String!) {
authGoogleNew(input: { accessToken: $accessToken }) {
token
name
email
roles
_id
regDate
}
}

View File

@@ -0,0 +1,7 @@
package org.db3.airmq.sdk.auth
interface ApiTokenStore {
fun getToken(): String?
fun saveToken(token: String): Result<Unit>
fun clearToken(): Result<Unit>
}

View File

@@ -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<User> = 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<Unit> = 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 <T> Task<T>.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.")
}
}

View File

@@ -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
)

View File

@@ -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 <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { continuation ->
addOnSuccessListener { result ->
continuation.resume(result)
}
addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
addOnCanceledListener {
continuation.cancel()
}
}

View File

@@ -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<Unit> = runCatching {
require(token.isNotBlank()) { "API token cannot be blank." }
sharedPreferences.edit().putString(KEY_API_TOKEN, token).apply()
}
override fun clearToken(): Result<Unit> = 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"
}
}

View File

@@ -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

View File

@@ -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 <D : Operation.Data> intercept(
request: ApolloRequest<D>,
chain: ApolloInterceptorChain
): Flow<ApolloResponse<D>> {
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"
}
}

View File

@@ -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<ApolloClient>()
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<ApolloClient>()
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<ApolloClient>()
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<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } 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<Unit> {
saveError?.let { return Result.failure(it) }
storedToken = token
return Result.success(Unit)
}
override fun clearToken(): Result<Unit> {
storedToken = null
return Result.success(Unit)
}
}