diff --git a/sdk/src/main/graphql/AuthGoogleNew.graphql b/sdk/src/main/graphql/AuthGoogleApp.graphql similarity index 71% rename from sdk/src/main/graphql/AuthGoogleNew.graphql rename to sdk/src/main/graphql/AuthGoogleApp.graphql index bd1e822..2e5876f 100644 --- a/sdk/src/main/graphql/AuthGoogleNew.graphql +++ b/sdk/src/main/graphql/AuthGoogleApp.graphql @@ -1,4 +1,4 @@ -mutation AuthGoogleNew($accessToken: String!) { +mutation AuthGoogleApp($accessToken: String!) { authGoogleApp(input: { accessToken: $accessToken }) { token name 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 6dbd019..444d917 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 @@ -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.") - } } 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 index 1c19170..90c294e 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManager.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManager.kt @@ -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 -) 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 index 78885fa..0525f09 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManagerImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/FirebaseSessionManagerImpl.kt @@ -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 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/network/ApolloLoggingInterceptor.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloLoggingInterceptor.kt index a784612..6770c3f 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloLoggingInterceptor.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloLoggingInterceptor.kt @@ -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 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 logResponseExtensionsIfAny( + operationName: String, + response: ApolloResponse + ) { + 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 logGraphqlErrorsIfAny( + operationName: String, + response: ApolloResponse + ) { + 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?, + 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 } 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 index b4a5ec6..638023e 100644 --- a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt +++ b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt @@ -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() 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() - 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() + val tokenStore = FakeApiTokenStore() + val localEmailStore = FakeLocalEmailAuthStore() + val mockCall = mockk>() + every { apolloClient.mutation(any()) } 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() 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() 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() 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() 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() 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() 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>() - every { apolloClient.mutation(any()) } returns mockCall + private fun mockAuthGoogleAppError(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 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++ }