feat(sdk): AuthGoogleApp mutation and Google ID token auth without Firebase

Made-with: Cursor
This commit is contained in:
2026-04-06 22:48:37 +02:00
parent 9165d26b72
commit 34ad7e4af7
6 changed files with 194 additions and 133 deletions

View File

@@ -1,4 +1,4 @@
mutation AuthGoogleNew($accessToken: String!) {
mutation AuthGoogleApp($accessToken: String!) {
authGoogleApp(input: { accessToken: $accessToken }) {
token
name

View File

@@ -4,7 +4,7 @@ import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Optional
import javax.inject.Inject
import javax.inject.Singleton
import org.db3.airmq.sdk.AuthGoogleNewMutation
import org.db3.airmq.sdk.AuthGoogleAppMutation
import org.db3.airmq.sdk.LoginLocalMutation
import org.db3.airmq.sdk.RegisterMutation
import org.db3.airmq.sdk.auth.model.AuthProvider
@@ -82,28 +82,22 @@ class FirebaseAuthService @Inject constructor(
localEmailAuthStore.clearProfile().getOrThrow()
}
private suspend fun signInWithGoogle(token: String): User {
/**
* Google OAuth: send the Google ID token from Credential Manager to [authGoogleApp], then persist the API JWT
* (same storage as email login). Does not sign in to Firebase; the backend verifies the Google JWT.
*/
private suspend fun signInWithGoogle(googleIdToken: String): User {
localEmailAuthStore.clearProfile().getOrThrow()
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
try {
val apiToken = exchangeFirebaseIdTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
apiTokenStore.saveToken(apiToken).getOrThrow()
return firebaseSessionUser.user
} catch (e: Throwable) {
rollbackSessionAfterFailedGoogleBackendAuth()
throw e
}
}
/** Firebase may already be signed in when the backend exchange fails; clear everything so the user stays logged out. */
private suspend fun rollbackSessionAfterFailedGoogleBackendAuth() {
try {
firebaseSessionManager.signOut()
apiTokenStore.clearToken().getOrThrow()
localEmailAuthStore.clearProfile().getOrThrow()
} catch (_: Throwable) {
// Best-effort; caller still receives the original sign-in failure.
val response = apolloClient
.mutation(AuthGoogleAppMutation(accessToken = googleIdToken))
.execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { gqlError ->
throw IllegalStateException(gqlError.message)
}
val auth = response.data?.authGoogleApp
?: error("Google sign-in response missing data.")
return commitEmailAuthSession(auth.token, auth._id, auth.email, auth.name)
}
private suspend fun commitEmailAuthSession(
@@ -130,16 +124,4 @@ class FirebaseAuthService @Inject constructor(
isAuthenticated = true
)
}
private suspend fun exchangeFirebaseIdTokenWithBackend(firebaseIdToken: String): String {
val response = apolloClient
.mutation(AuthGoogleNewMutation(accessToken = firebaseIdToken))
.execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { gqlError ->
throw IllegalStateException(gqlError.message)
}
return response.data?.authGoogleApp?.token
?: error("Backend auth exchange succeeded without API token.")
}
}

View File

@@ -5,11 +5,5 @@ 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

@@ -1,14 +1,9 @@
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
@@ -20,18 +15,6 @@ class FirebaseSessionManagerImpl @Inject constructor(
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()
}
@@ -43,15 +26,3 @@ class FirebaseSessionManagerImpl @Inject constructor(
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

@@ -3,6 +3,7 @@ package org.db3.airmq.sdk.network
import android.util.Log
import com.apollographql.apollo.api.ApolloRequest
import com.apollographql.apollo.api.ApolloResponse
import com.apollographql.apollo.api.Error
import com.apollographql.apollo.api.Operation
import com.apollographql.apollo.interceptor.ApolloInterceptor
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
@@ -12,6 +13,9 @@ import kotlinx.coroutines.flow.onEach
/**
* Logs every GraphQL operation name, document, variable payload ([Operation] `toString`),
* and response data or errors to logcat (tag [TAG]).
*
* GraphQL [Error]s are logged with [Error.path], [Error.extensions] (e.g. `code`, `stacktrace`),
* and [Error.nonStandardFields], not only message and locations.
*/
class ApolloLoggingInterceptor : ApolloInterceptor {
override fun <D : Operation.Data> intercept(
@@ -27,36 +31,145 @@ class ApolloLoggingInterceptor : ApolloInterceptor {
if (response.exception != null) {
Log.e(
TAG,
"---------- GraphQL response: $operationName FAILED ----------",
"---------- GraphQL response: $operationName FAILED (transport / parse) ----------",
response.exception
)
logGraphqlErrorsIfAny(operationName, response)
logResponseExtensionsIfAny(operationName, response)
val dataStr = response.data?.toString() ?: "null"
logChunked("$TAG.response.$operationName.data", dataStr)
} else {
Log.d(TAG, "---------- GraphQL response: $operationName ----------")
val errors = response.errors
if (!errors.isNullOrEmpty()) {
errors.forEachIndexed { i, err ->
Log.w(TAG, "errors[$i]: ${err.message} (locations=${err.locations})")
}
} else {
Log.d(TAG, "errors: none")
}
logGraphqlErrorsIfAny(operationName, response)
logResponseExtensionsIfAny(operationName, response)
val dataStr = response.data?.toString() ?: "null"
logChunked("$TAG.response.$operationName.data", dataStr)
}
}
}
/** Android single-line log limit is ~4k; split large payloads. */
private fun logChunked(label: String, text: String, chunkSize: Int = 3500) {
private fun <D : Operation.Data> logResponseExtensionsIfAny(
operationName: String,
response: ApolloResponse<D>
) {
val ext = response.extensions
if (ext.isEmpty()) {
Log.d(TAG, "response.extensions: none")
return
}
logChunked(
"$TAG.response.$operationName.protocolExtensions",
ext.entries.joinToString(prefix = "{", postfix = "}") { (k, v) ->
"\n $k = ${formatExtensionValue(v)}"
}
)
}
private fun <D : Operation.Data> logGraphqlErrorsIfAny(
operationName: String,
response: ApolloResponse<D>
) {
val errors = response.errors
if (errors.isNullOrEmpty()) {
Log.d(TAG, "graphql.errors: none")
return
}
Log.w(TAG, "graphql.errors: count=${errors.size} (partial data may still be present)")
errors.forEachIndexed { index, err ->
val detail = formatGraphqlErrorDetail(err)
logChunked(
"$TAG.response.$operationName.graphqlError[$index]",
detail,
priority = Log.WARN
)
}
}
private fun formatGraphqlErrorDetail(error: Error): String = buildString {
appendLine("message: ${error.message}")
val path = error.path
if (!path.isNullOrEmpty()) {
appendLine("path: ${path.joinToString(prefix = "[", postfix = "]")}")
} else {
appendLine("path: (none)")
}
val locs = error.locations
if (!locs.isNullOrEmpty()) {
appendLine(
"locations: " + locs.joinToString { loc ->
"line=${loc.line}, column=${loc.column}"
}
)
} else {
appendLine("locations: (none)")
}
appendLine("extensions:")
appendIndentedMapOrEmpty(error.extensions, indent = 2)
appendLine("nonStandardFields:")
appendIndentedMapOrEmpty(error.nonStandardFields, indent = 2)
}
private fun StringBuilder.appendIndentedMapOrEmpty(
map: Map<String, Any?>?,
indent: Int
) {
val prefix = " ".repeat(indent)
if (map.isNullOrEmpty()) {
appendLine("$prefix(none)")
return
}
map.forEach { (k, v) ->
append(prefix)
append(k)
append(" = ")
appendLine(formatExtensionValue(v, indent + 2))
}
}
private fun formatExtensionValue(value: Any?, nestedIndent: Int = 0): String = when (value) {
null -> "null"
is Map<*, *> -> {
val prefix = "\n" + " ".repeat(nestedIndent)
value.entries.joinToString(prefix = "{", postfix = "}") { (k, v) ->
"$prefix${k.toString()} = ${formatExtensionValue(v, nestedIndent + 2)}"
}
}
is Iterable<*> -> {
val prefix = "\n" + " ".repeat(nestedIndent)
value.withIndex().joinToString(prefix = "[", postfix = "]") { (i, v) ->
"$prefix[$i] ${formatExtensionValue(v, nestedIndent + 2)}"
}
}
is Array<*> -> formatExtensionValue(value.toList(), nestedIndent)
else -> value.toString()
}
/**
* Android single-line log limit is ~4k; split large payloads.
* @param priority [Log.DEBUG] for normal traffic; [Log.WARN] for GraphQL error bodies.
*/
private fun logChunked(
label: String,
text: String,
chunkSize: Int = 3500,
priority: Int = Log.DEBUG
) {
val logLine: (String) -> Unit = { line ->
when (priority) {
Log.WARN -> Log.w(TAG, line)
Log.ERROR -> Log.e(TAG, line)
else -> Log.d(TAG, line)
}
}
if (text.length <= chunkSize) {
Log.d(TAG, "$label: $text")
logLine("$label: $text")
return
}
var start = 0
var part = 0
while (start < text.length) {
val end = minOf(start + chunkSize, text.length)
Log.d(TAG, "$label [part $part]: ${text.substring(start, end)}")
logLine("$label [part $part]: ${text.substring(start, end)}")
part++
start = end
}

View File

@@ -1,13 +1,15 @@
package org.db3.airmq.sdk.auth
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.ApolloResponse
import com.benasher44.uuid.uuid4
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.runTest
import org.db3.airmq.sdk.AuthGoogleNewMutation
import org.db3.airmq.sdk.AuthGoogleAppMutation
import org.db3.airmq.sdk.auth.model.AuthProvider
import org.db3.airmq.sdk.auth.model.User
import org.junit.Assert.assertEquals
@@ -20,32 +22,12 @@ import org.junit.Test
class FirebaseAuthServiceTest {
@Test
fun signIn_google_failsWhenFirebaseSignInFails() = runTest {
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed"))
fun signIn_google_failsWhenAuthGoogleAppFails() = runTest {
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore()
val localEmailStore = FakeLocalEmailAuthStore()
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
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()
val localEmailStore = FakeLocalEmailAuthStore()
mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed"))
mockAuthGoogleAppError(apolloClient, IllegalStateException("backend failed"))
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
@@ -53,17 +35,48 @@ class FirebaseAuthServiceTest {
assertTrue(result.isFailure)
assertEquals("backend failed", result.exceptionOrNull()?.message)
assertEquals(null, tokenStore.storedToken)
assertEquals(0, sessionManager.signOutCallCount)
}
@Test
fun signIn_google_succeedsWhenAuthGoogleAppReturnsToken() = runTest {
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore()
val localEmailStore = FakeLocalEmailAuthStore()
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleAppMutation.Data>>()
every { apolloClient.mutation(any<AuthGoogleAppMutation>()) } returns mockCall
val mutationData = AuthGoogleAppMutation.Data(
authGoogleApp = AuthGoogleAppMutation.AuthGoogleApp(
token = "api-jwt",
name = "N",
email = "u@test.dev",
roles = null,
_id = "backend-id",
regDate = null
)
)
val apolloResponse = ApolloResponse.Builder(
AuthGoogleAppMutation(accessToken = "google-id-token"),
uuid4()
).data(mutationData).build()
coEvery { mockCall.execute() } returns apolloResponse
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
assertTrue(result.isSuccess)
assertEquals("api-jwt", tokenStore.storedToken)
assertEquals("backend-id", localEmailStore.getProfile()?.userId)
assertEquals("u@test.dev", localEmailStore.getProfile()?.email)
assertEquals(1, sessionManager.signOutCallCount)
}
@Test
fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest {
val sessionManager = FakeFirebaseSessionManager(
isSignedIn = true,
signInResult = FirebaseSessionUser(
user = User("uid-fb", "fb@test.dev", "FB", true),
firebaseAccessToken = "ft"
)
user = User("uid-fb", "fb@test.dev", "FB", true),
isSignedIn = true
)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
@@ -78,7 +91,7 @@ class FirebaseAuthServiceTest {
@Test
fun isAuthenticated_trueWhenLocalEmailProfileAndApiTokenPresent() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val localEmailStore = FakeLocalEmailAuthStore().apply {
@@ -92,10 +105,7 @@ class FirebaseAuthServiceTest {
@Test
fun getUser_returnsNullWhenFirebaseUserButNoApiToken() = runTest {
val sessionManager = FakeFirebaseSessionManager(
signInResult = FirebaseSessionUser(
user = User("uid-fb", "fb@test.dev", "FB", true),
firebaseAccessToken = "ft"
)
user = User("uid-fb", "fb@test.dev", "FB", true)
)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = null)
@@ -108,10 +118,7 @@ class FirebaseAuthServiceTest {
@Test
fun getUser_returnsFirebaseUserWhenApiTokenPresentAndNoLocalProfile() = runTest {
val sessionManager = FakeFirebaseSessionManager(
signInResult = FirebaseSessionUser(
user = User("uid-fb", "fb@test.dev", "FB", true),
firebaseAccessToken = "ft"
)
user = User("uid-fb", "fb@test.dev", "FB", true)
)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
@@ -126,7 +133,7 @@ class FirebaseAuthServiceTest {
@Test
fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val localEmailStore = FakeLocalEmailAuthStore().apply {
@@ -144,7 +151,7 @@ class FirebaseAuthServiceTest {
@Test
fun signOut_clearsTokenAndLocalProfile() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val localEmailStore = FakeLocalEmailAuthStore().apply {
@@ -157,30 +164,24 @@ class FirebaseAuthServiceTest {
assertNull(localEmailStore.getProfile())
}
private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) {
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall
private fun mockAuthGoogleAppError(apolloClient: ApolloClient, error: Throwable) {
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleAppMutation.Data>>()
every { apolloClient.mutation(any<AuthGoogleAppMutation>()) } returns mockCall
coEvery { mockCall.execute() } throws error
}
}
private class FakeFirebaseSessionManager(
private val signInResult: FirebaseSessionUser? = null,
private val signInError: Throwable? = null,
private val user: User? = null,
private val isSignedIn: Boolean = true
) : FirebaseSessionManager {
var signOutCallCount = 0
private set
override suspend fun getUser(): User? = signInResult?.user
override suspend fun getUser(): User? = 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() {
signOutCallCount++
}