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..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,13 +12,85 @@ 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 { - fun parserExceptionToFlutter(nativeException: Exception?): FlutterError { - if (nativeException == null) { + /** 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) } + // 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() @@ -46,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 || 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 ||