From 94c938e208996b638d5554a4db343795e6da2e34 Mon Sep 17 00:00:00 2001 From: Do Yeong Tak Date: Sat, 22 Aug 2026 12:20:12 +0900 Subject: [PATCH 1/2] fix(auth, android): surface the error code when getIdToken() fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getIdToken()` awaits the native Task with `Tasks.await()` (since #11362), which reports a failed Task as an `ExecutionException` wrapping the Task's own exception. `parserExceptionToFlutter` only read the error code off the exception it was handed, so every refused refresh — a disabled or deleted account, a revoked token — reached Dart as `[firebase_auth/unknown]` with the message "The user's credential is no longer valid. The user must sign in again.", instead of `user-disabled`, `user-not-found` or `user-token-expired`, the codes the other platforms produce (the `[firebase_auth/unknown]` seen in #18561 is this). Unwrap the `ExecutionException` to its cause before classifying, and add an e2e test that disables the signed-in account behind its back and expects `user-disabled` from a forced refresh. --- AUTHORS | 1 + .../FlutterFirebaseAuthPluginException.kt | 11 ++++++-- .../firebase_auth_user_e2e_test.dart | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index cfe2577f7420..0680b1b92398 100644 --- a/AUTHORS +++ b/AUTHORS @@ -67,3 +67,4 @@ Liu Zhisong Ievgenii Kovtun Dinu-Stefan Rusu Marwan Salim Ba Matraf +Do Yeong Tak diff --git a/packages/firebase_auth/firebase_auth/android/src/main/kotlin/io/flutter/plugins/firebase/auth/FlutterFirebaseAuthPluginException.kt b/packages/firebase_auth/firebase_auth/android/src/main/kotlin/io/flutter/plugins/firebase/auth/FlutterFirebaseAuthPluginException.kt index 6692a7f6f767..0934f254d173 100644 --- a/packages/firebase_auth/firebase_auth/android/src/main/kotlin/io/flutter/plugins/firebase/auth/FlutterFirebaseAuthPluginException.kt +++ b/packages/firebase_auth/firebase_auth/android/src/main/kotlin/io/flutter/plugins/firebase/auth/FlutterFirebaseAuthPluginException.kt @@ -13,12 +13,19 @@ import com.google.firebase.auth.FirebaseAuthMultiFactorException import com.google.firebase.auth.FirebaseAuthUserCollisionException import com.google.firebase.auth.FirebaseAuthWeakPasswordException import java.util.UUID +import java.util.concurrent.ExecutionException object FlutterFirebaseAuthPluginException { - fun parserExceptionToFlutter(nativeException: Exception?): FlutterError { - if (nativeException == null) { + fun parserExceptionToFlutter(rawException: Exception?): FlutterError { + if (rawException == null) { return FlutterError("UNKNOWN", null, null) } + // Tasks.await() reports a failed Task as an ExecutionException wrapping the + // Task's own exception, so the FirebaseAuthException carrying the error + // code is the cause. Unwrap it, otherwise every such failure reaches Dart + // as "unknown". + val nativeException = + (rawException as? ExecutionException)?.cause as? Exception ?: rawException var code = "UNKNOWN" var message = nativeException.message val additionalData = HashMap() diff --git a/packages/firebase_auth/firebase_auth/example/integration_test/firebase_auth_user_e2e_test.dart b/packages/firebase_auth/firebase_auth/example/integration_test/firebase_auth_user_e2e_test.dart index 0339d95da4d2..5e5049656b70 100644 --- a/packages/firebase_auth/firebase_auth/example/integration_test/firebase_auth_user_e2e_test.dart +++ b/packages/firebase_auth/firebase_auth/example/integration_test/firebase_auth_user_e2e_test.dart @@ -101,6 +101,33 @@ void main() { } fail('should have thrown an error'); }); + + test('should surface the error code when a forced refresh is refused', + () async { + // Demonstrate fix for this issue works: https://github.com/firebase/flutterfire/issues/18561 + // A refresh the backend refuses (user disabled, deleted, token + // revoked) reached Dart as `unknown` on Android, because the + // failed Task surfaced as an ExecutionException that hid the + // FirebaseAuthException carrying the code. + final userCredential = + await FirebaseAuth.instance.createUserWithEmailAndPassword( + email: generateRandomEmail(), + password: testPassword, + ); + final user = userCredential.user!; + + // Disable the account behind the signed-in user's back, then force + // a refresh so the SDK has to ask the backend again. + await emulatorDisableUser(user.uid); + + try { + await user.getIdToken(true); + } on FirebaseAuthException catch (e) { + expect(e.code, 'user-disabled'); + return; + } + fail('should have thrown a FirebaseAuthException'); + }); }, skip: !kIsWeb && (defaultTargetPlatform == TargetPlatform.windows || From 33c8ef692f20b862bec83e35987aa21d62ce2084 Mon Sep 17 00:00:00 2001 From: Do Yeong Tak Date: Sat, 5 Sep 2026 11:24:45 +0900 Subject: [PATCH 2/2] fix(auth, android): report an IO failure during token refresh as network-request-failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kotlin twin of the fix proven on the 6.5.7 backport branch: a token refresh that lost the connection reached Dart as [firebase_auth/unknown], either as 'An internal error has occurred. [ unexpected end of stream on com.android.okhttp.Address@... ]' or as a bare SSL handshake failure. The type check comes first and covers every IOException; the SDK drops the cause on the wrapped shape, so that one is matched on the wrapper prefix plus a fixed list of IO phrases inside the brackets, never on a FirebaseAuthException that carries its own code. Ported mechanically and not built here — the emulator proof and the JVM regression test live on koppa/firebase_auth-v6.5.7-getidtoken-error-code. Co-Authored-By: Claude Opus 5 --- .../FlutterFirebaseAuthPluginException.kt | 99 ++++++++++++++++++- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/packages/firebase_auth/firebase_auth/android/src/main/kotlin/io/flutter/plugins/firebase/auth/FlutterFirebaseAuthPluginException.kt b/packages/firebase_auth/firebase_auth/android/src/main/kotlin/io/flutter/plugins/firebase/auth/FlutterFirebaseAuthPluginException.kt index 0934f254d173..9231cb32e9c0 100644 --- a/packages/firebase_auth/firebase_auth/android/src/main/kotlin/io/flutter/plugins/firebase/auth/FlutterFirebaseAuthPluginException.kt +++ b/packages/firebase_auth/firebase_auth/android/src/main/kotlin/io/flutter/plugins/firebase/auth/FlutterFirebaseAuthPluginException.kt @@ -12,10 +12,75 @@ import com.google.firebase.auth.FirebaseAuthException import com.google.firebase.auth.FirebaseAuthMultiFactorException import com.google.firebase.auth.FirebaseAuthUserCollisionException import com.google.firebase.auth.FirebaseAuthWeakPasswordException +import java.io.IOException import java.util.UUID import java.util.concurrent.ExecutionException object FlutterFirebaseAuthPluginException { + /** The wrapper the Android SDK puts around a failure it has no code for. */ + private const val INTERNAL_ERROR_PREFIX = "An internal error has occurred." + + /** + * Phrases that only ever describe a transport failure. Matched inside the wrapper's brackets, + * never against arbitrary text, and never widened without a production message that needs it. + */ + private val IO_MESSAGE_FRAGMENTS = + arrayOf( + "unexpected end of stream", + "Unable to resolve host", + "Failed to connect", + "Connection reset", + "Network is unreachable", + "Software caused connection abort", + "timed out", + "SSL", + "Broken pipe") + + /** + * True when the exception, or anything it was caused by, is an [IOException]. Walks at most five + * levels, so a cause chain that loops cannot spin here. + */ + private fun isIoFailure(throwable: Throwable?): Boolean { + var current = throwable + var depth = 0 + while (current != null && depth < 5) { + if (current is IOException) { + return true + } + val cause = current.cause + if (cause === current) { + break + } + current = cause + depth++ + } + return false + } + + /** + * True when the message is the SDK's "internal error" wrapper and what it wrapped reads as an IO + * failure. Only the text between the brackets is examined. + */ + private fun isWrappedIoMessage(message: String?): Boolean { + if (message == null || !message.startsWith(INTERNAL_ERROR_PREFIX)) { + return false + } + val open = message.indexOf('[') + val close = message.lastIndexOf(']') + if (open < 0 || close < open) { + return false + } + val wrapped = message.substring(open + 1, close) + return IO_MESSAGE_FRAGMENTS.any { wrapped.contains(it) } + } + + private fun networkRequestFailed(): FlutterError { + return FlutterError( + "network-request-failed", + "A network error (such as timeout, interrupted connection or unreachable host) has occurred.", + null) + } + fun parserExceptionToFlutter(rawException: Exception?): FlutterError { if (rawException == null) { return FlutterError("UNKNOWN", null, null) @@ -53,10 +118,36 @@ object FlutterFirebaseAuthPluginException { if (nativeException is FirebaseNetworkException || nativeException.cause is FirebaseNetworkException) { - return FlutterError( - "network-request-failed", - "A network error (such as timeout, interrupted connection or unreachable host) has occurred.", - null) + return networkRequestFailed() + } + + // A token refresh that loses the connection does not always arrive as a + // FirebaseNetworkException. Production (2026-09-05) showed two other shapes, + // both reaching Dart as [firebase_auth/unknown]: + // + // An internal error has occurred. + // [ unexpected end of stream on com.android.okhttp.Address@f7f69f0a ] + // Failure in SSL library, usually a protocol error + // error:100000d7:SSL routines:OPENSSL_internal:SSL_HANDSHAKE_FAILURE ... + // + // The second is the IOException itself, so the type check comes first: it + // reads what the exception *is*, which covers SSLException, + // SocketTimeoutException, UnknownHostException and ConnectException alike + // without depending on anyone's wording. + if (isIoFailure(nativeException)) { + return networkRequestFailed() + } + + // The first shape is not reachable by type. The Android SDK wraps the IO + // failure in a plain FirebaseException, keeps its text inside the brackets + // and drops the cause — verified on the emulator, where the chain is one + // level deep and getCause() is null. Only the message survives, so this arm + // reads it, and is kept narrow on purpose: the exact wrapper prefix, a fixed + // list of IO phrases, matched only inside the brackets, and never against a + // FirebaseAuthException, which carries a real error code that must never be + // relabelled. + if (nativeException !is FirebaseAuthException && isWrappedIoMessage(nativeException.message)) { + return networkRequestFailed() } if (nativeException is FirebaseApiNotAvailableException ||