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) { if (signInResult.isSuccess) {
_actions.tryEmit(Action.OpenManage) _actions.tryEmit(Action.OpenManage)
} else { } 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" google-services = "4.4.4"
androidxCredentials = "1.5.0" androidxCredentials = "1.5.0"
googleid = "1.1.1" googleid = "1.1.1"
kotlinxCoroutinesTest = "1.9.0"
mockk = "1.13.13"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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 = { group = "androidx.credentials", name = "credentials", version.ref = "androidxCredentials" }
androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View File

@@ -62,4 +62,6 @@ dependencies {
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
testImplementation(libs.junit) 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 package org.db3.airmq.sdk.auth
import com.google.android.gms.tasks.Task import com.apollographql.apollo.ApolloClient
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.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume import org.db3.airmq.sdk.AuthGoogleNewMutation
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import org.db3.airmq.sdk.auth.model.AuthProvider import org.db3.airmq.sdk.auth.model.AuthProvider
import org.db3.airmq.sdk.auth.model.User import org.db3.airmq.sdk.auth.model.User
@Singleton @Singleton
class FirebaseAuthService @Inject constructor( class FirebaseAuthService @Inject constructor(
private val firebaseAuth: FirebaseAuth private val firebaseSessionManager: FirebaseSessionManager,
private val apolloClient: ApolloClient,
private val apiTokenStore: ApiTokenStore
) : AuthService { ) : 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 { override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching {
require(token.isNotBlank()) { "ID token is required for Google sign-in." } 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 { override suspend fun signOut(): Result<Unit> = runCatching {
firebaseAuth.signOut() firebaseSessionManager.signOut()
apiTokenStore.clearToken().getOrThrow()
} }
private suspend fun signInWithGoogle(token: String): User { private suspend fun signInWithGoogle(token: String): User {
val credential = GoogleAuthProvider.getCredential(token, null) val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
val authResult = firebaseAuth.signInWithCredential(credential).awaitResult() val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.") apiTokenStore.saveToken(apiToken).getOrThrow()
return firebaseUser.toUser() return firebaseSessionUser.user
} }
private fun FirebaseUser.toUser(): User = User( private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String {
userId = uid, val response = apolloClient
email = email, .mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken))
displayName = displayName, .execute()
isAuthenticated = true response.exception?.let { throw it }
) response.errors?.firstOrNull()?.let { gqlError ->
} throw IllegalStateException(gqlError.message)
}
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { continuation -> return response.data?.authGoogleNew?.token
addOnSuccessListener { result -> ?: error("Backend auth exchange succeeded without API token.")
continuation.resume(result)
}
addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
addOnCanceledListener {
continuation.cancel()
} }
} }

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 javax.inject.Singleton
import org.db3.airmq.sdk.auth.ApiTokenStore import org.db3.airmq.sdk.auth.ApiTokenStore
import org.db3.airmq.sdk.auth.AuthService 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.FirebaseAuthService
import org.db3.airmq.sdk.auth.FirebaseSessionManager import org.db3.airmq.sdk.auth.FirebaseSessionManager
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl
@@ -58,10 +56,6 @@ abstract class SDKBindModule {
@Singleton @Singleton
abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager
@Binds
@Singleton
abstract fun bindBackendAuthGateway(impl: BackendAuthGatewayImpl): BackendAuthGateway
@Binds @Binds
@Singleton @Singleton
abstract fun bindMapService(impl: MapServiceImpl): MapService 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)
}
}