Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ Liu Zhisong <liuzs0666@gmail.com>
Ievgenii Kovtun <jeka.ns@gmail.com>
Dinu-Stefan Rusu <rusudinustefan@gmail.com>
Marwan Salim Ba Matraf <marwans12@hotmail.com>
Do Yeong Tak <dyt9120@gmail.com>
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any?>()
Expand Down Expand Up @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down