Skip to content
Open
27 changes: 27 additions & 0 deletions auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2223,6 +2223,33 @@ Or override individual strings in your `strings.xml`:
</resources>
```

### Error message resolution

Error text is chosen when the exception is built, not when the dialog renders it, and it resolves in this order:

1. **The type-level hook**, one `fui_error_*` resource per exception type. These ship **deliberately blank**, and a blank value means "skip me" rather than "show nothing".
2. **A per-code string**, selected from the Firebase error code, so a mistyped SMS code and a wrong password no longer produce the same sentence.
3. **The Firebase SDK's own message**, English only, reached only for codes the library does not map.

Setting a type-level hook therefore overrides *every* code of that type at once. That is occasionally what you want — uniform copy resists account enumeration, since distinguishing "no such account" from "wrong password" tells an attacker which addresses are registered — but it costs you the specific per-code messages:

```xml
<resources>
<!-- Replaces the phone-format, wrong-code, bad-email and wrong-password messages alike -->
<string name="fui_error_invalid_credentials">Those sign-in details aren\'t correct.</string>
</resources>
```

Developer misconfiguration is handled separately. `AuthException.MisconfigurationException` carries generic translated copy on `message`, and keeps Firebase's diagnostic on `cause` so it reaches your logs without reaching your users:

```kotlin
is AuthException.MisconfigurationException -> {
Log.e(TAG, "Check the Firebase console", exception.cause)
}
```

One limit worth knowing: `FirebaseAuthUI.signOut`, `withReauth` and `delete` take a `Context` and no configuration, so their messages resolve against that `Context` rather than a `stringProvider` or `locale` you configured. Pass a locale-aware `Context` if that matters.

## Error Handling

FirebaseUI provides a comprehensive exception hierarchy:
Expand Down
511 changes: 469 additions & 42 deletions auth/src/main/java/com/firebase/ui/auth/AuthException.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ interface AuthUIStringProvider {
/** Error message when password doesn't meet minimum length requirement. Should support string formatting with minimum length parameter. */
fun passwordTooShort(minimumLength: Int): String

/** Error message when the password is longer than the maximum length allowed. Should support string formatting with maximum length parameter. */
fun passwordTooLong(maximumLength: Int): String

/** Error message when password is missing at least one uppercase letter (A-Z) */
val passwordMissingUppercase: String

Expand Down Expand Up @@ -628,4 +631,62 @@ interface AuthUIStringProvider {

/** Error when authentication is cancelled. Return empty to use the Firebase SDK message. */
val errorAuthCancelled: String

// =============================================================================================
// AuthException messages selected by Firebase Auth error code
//
// Every member below has a default so that adding one is not a breaking change, and each
// default delegates to a coarser member rather than returning a hardcoded English literal —
// a host that has implemented this interface itself keeps getting its own translated copy.
// =============================================================================================

/** Error when sign-in fails and the server will not say whether the email or the password was wrong. */
val errorIncorrectEmailOrPassword: String get() = errorInvalidCredentials

/** Error when the SMS verification session is gone and a new code has to be requested. */
val errorInvalidVerificationId: String get() = errorInvalidCredentials

/** Error when phone verification did not complete and has to be retried. */
val errorRetryPhoneAuth: String get() = errorInvalidCredentials

/** Error when the supplied credentials belong to a different account than the one being confirmed. */
val errorUserMismatch: String get() = errorUnknownAuth

/** Error when the phone number is not set up as a verification method on the account. */
val errorPhoneNumberNotEnrolled: String get() = errorInvalidCredentials

/** Error when a sign-in or verification session has expired. */
val errorSessionExpired: String get() = errorInvalidCredentials

/** Error when the sign-in session expired part-way through two-step verification. */
val errorMultiFactorSessionExpired: String get() = errorSessionExpired

/** Error when an emailed sign-in or password reset link has expired or is malformed. */
val errorActionCodeInvalid: String get() = errorInvalidCredentials

/** Error when the account email has to be verified before the operation can continue. */
val errorUnverifiedEmail: String get() = errorUnknownAuth

/** Error when adding a verification method that is already set up on the account. */
val errorSecondFactorAlreadyEnrolled: String get() = errorUnknownAuth

/** Error when the account already has the maximum number of verification methods. */
val errorMaximumSecondFactorCountExceeded: String get() = errorUnknownAuth

/**
* Error when the password fails the project's password policy and the server named no
* individual requirement. When the server does name them, each one is rendered through
* [passwordTooShort], [passwordTooLong], [passwordMissingUppercase],
* [passwordMissingLowercase], [passwordMissingDigit] and [passwordMissingSpecialCharacter]
* instead, and this string is not used.
*/
val errorPasswordPolicyGeneric: String get() = errorWeakPasswordGeneric

/**
* Error when the account has no passkey enrolled and the user has to sign in another way.
*
* Defaults to [errorUnknownAuth], not [errorInvalidCredentials]: this message is shown for a
* non-recoverable error, so the dialog offers no retry and credential copy would contradict it.
*/
val errorPasskeyNotFound: String get() = errorUnknownAuth
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ class DefaultAuthUIStringProvider(
override fun passwordTooShort(minimumLength: Int): String =
localizedContext.getString(R.string.fui_error_password_too_short, minimumLength)

override fun passwordTooLong(maximumLength: Int): String =
localizedContext.getString(R.string.fui_error_password_too_long, maximumLength)

override val passwordMissingUppercase: String
get() = localizedContext.getString(R.string.fui_error_password_missing_uppercase)
override val passwordMissingLowercase: String
Expand Down Expand Up @@ -567,4 +570,43 @@ class DefaultAuthUIStringProvider(

override val errorAuthCancelled: String
get() = localizedContext.getString(R.string.fui_error_auth_cancelled)

override val errorIncorrectEmailOrPassword: String
get() = localizedContext.getString(R.string.fui_error_incorrect_email_or_password)

override val errorInvalidVerificationId: String
get() = localizedContext.getString(R.string.fui_error_invalid_verification_id)

override val errorRetryPhoneAuth: String
get() = localizedContext.getString(R.string.fui_error_retry_phone_auth)

override val errorUserMismatch: String
get() = localizedContext.getString(R.string.fui_error_user_mismatch)

override val errorPhoneNumberNotEnrolled: String
get() = localizedContext.getString(R.string.fui_error_phone_number_not_enrolled)

override val errorSessionExpired: String
get() = localizedContext.getString(R.string.fui_error_session_expired)

override val errorMultiFactorSessionExpired: String
get() = localizedContext.getString(R.string.fui_error_multi_factor_session_expired)

override val errorActionCodeInvalid: String
get() = localizedContext.getString(R.string.fui_error_action_code_invalid)

override val errorUnverifiedEmail: String
get() = localizedContext.getString(R.string.fui_error_unverified_email)

override val errorSecondFactorAlreadyEnrolled: String
get() = localizedContext.getString(R.string.fui_error_second_factor_already_enrolled)

override val errorMaximumSecondFactorCountExceeded: String
get() = localizedContext.getString(R.string.fui_error_maximum_second_factor_count_exceeded)

override val errorPasswordPolicyGeneric: String
get() = localizedContext.getString(R.string.fui_error_password_policy_generic)

override val errorPasskeyNotFound: String
get() = localizedContext.getString(R.string.fui_error_passkey_not_found)
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,19 +143,27 @@ internal fun getRecoveryMessage(
stringProvider: AuthUIStringProvider
): String {
return when (error) {
is AuthException.NetworkException -> stringProvider.networkErrorRecoveryMessage
// AuthException.from already puts generic translated copy on the message and keeps the
// raw diagnostic on the cause, so this arm is belt-and-braces: an instance constructed
// directly with a raw diagnostic still cannot leak it into the dialog.
is AuthException.MisconfigurationException -> stringProvider.unknownErrorRecoveryMessage
is AuthException.NetworkException ->
error.message?.takeIf { it.isNotBlank() } ?: stringProvider.networkErrorRecoveryMessage
is AuthException.InvalidCredentialsException -> {
// Use the actual error message from Firebase if available, otherwise fallback to generic message
error.message?.takeIf { it.isNotBlank() && it != "Invalid credentials provided" }
// AuthException.from now picks library-owned copy per Firebase error code, so the
// message is the specific one; the generic string is only the empty-message fallback.
error.message?.takeIf { it.isNotBlank() }
?: stringProvider.invalidCredentialsRecoveryMessage
}
is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage
is AuthException.SignInMethodUnavailableException ->
// Passkey-specific fallback behind a general type — see the exception's KDoc.
error.message?.takeIf { it.isNotBlank() } ?: stringProvider.errorPasskeyNotFound
is AuthException.UserNotFoundException ->
error.message?.takeIf { it.isNotBlank() } ?: stringProvider.userNotFoundRecoveryMessage
is AuthException.WeakPasswordException -> {
// Include specific reason if available
val baseMessage = stringProvider.weakPasswordRecoveryMessage
error.reason?.let { reason ->
"$baseMessage\n\nReason: $reason"
} ?: baseMessage
// `error.reason` is untranslated SDK text, so it is deliberately not appended.
error.message?.takeIf { it.isNotBlank() }
?: stringProvider.weakPasswordRecoveryMessage
}

is AuthException.PasswordPolicyViolationException -> {
Expand All @@ -165,24 +173,30 @@ internal fun getRecoveryMessage(

is AuthException.EmailAlreadyInUseException -> {
// Include email if available
val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage
val baseMessage = error.message?.takeIf { it.isNotBlank() }
?: stringProvider.emailAlreadyInUseRecoveryMessage
error.email?.let { email ->
"$baseMessage ($email)"
} ?: baseMessage
}

is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage
is AuthException.TooManyRequestsException ->
error.message?.takeIf { it.isNotBlank() }
?: stringProvider.tooManyRequestsRecoveryMessage
is AuthException.PhoneVerificationCooldownException -> {
// Use the custom message which includes remaining cooldown time
error.message ?: stringProvider.unknownErrorRecoveryMessage
error.message?.takeIf { it.isNotBlank() } ?: stringProvider.unknownErrorRecoveryMessage
}
is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage
is AuthException.MfaRequiredException ->
error.message?.takeIf { it.isNotBlank() } ?: stringProvider.mfaRequiredRecoveryMessage
is AuthException.AccountLinkingRequiredException -> {
// Use the custom message which includes email and provider details
error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
error.message?.takeIf { it.isNotBlank() }
?: stringProvider.accountLinkingRequiredRecoveryMessage
}
is AuthException.DifferentSignInMethodRequiredException -> {
error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
error.message?.takeIf { it.isNotBlank() }
?: stringProvider.accountLinkingRequiredRecoveryMessage
}
is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage
is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage
Expand All @@ -194,7 +208,8 @@ internal fun getRecoveryMessage(
val providerName = error.providerName ?: stringProvider.emailProvider
stringProvider.emailLinkCrossDeviceLinkingMessage(providerName)
}
is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage
is AuthException.AuthCancelledException ->
error.message?.takeIf { it.isNotBlank() } ?: stringProvider.authCancelledRecoveryMessage
is AuthException.UnknownException -> {
// Use custom message if available (e.g., for configuration errors)
error.message?.takeIf { it.isNotBlank() } ?: stringProvider.unknownErrorRecoveryMessage
Expand All @@ -214,6 +229,7 @@ internal fun getRecoveryActionText(
error: AuthException,
stringProvider: AuthUIStringProvider
): String {
if (!isRecoverable(error)) return stringProvider.dismissAction
return when (error) {
is AuthException.AuthCancelledException -> stringProvider.continueText
is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text
Expand All @@ -224,14 +240,11 @@ internal fun getRecoveryActionText(
is AuthException.EmailLinkPromptForEmailException -> stringProvider.continueText
is AuthException.EmailLinkCrossDeviceLinkingException -> stringProvider.continueText
is AuthException.EmailLinkWrongDeviceException -> stringProvider.continueText
is AuthException.EmailLinkDifferentAnonymousUserException -> stringProvider.dismissAction
is AuthException.UserNotFoundException -> stringProvider.signupPageTitle // Navigate to sign-up when user not found
is AuthException.NetworkException,
is AuthException.InvalidCredentialsException,
is AuthException.WeakPasswordException,
is AuthException.PasswordPolicyViolationException,
is AuthException.TooManyRequestsException,
is AuthException.PhoneVerificationCooldownException -> stringProvider.retryAction
is AuthException.PasswordPolicyViolationException -> stringProvider.retryAction
is AuthException.UnknownException -> stringProvider.retryAction

else -> stringProvider.retryAction
Expand Down Expand Up @@ -262,6 +275,9 @@ internal fun isRecoverable(error: AuthException): Boolean {
is AuthException.EmailLinkCrossDeviceLinkingException -> true
is AuthException.EmailLinkWrongDeviceException -> true
is AuthException.EmailLinkDifferentAnonymousUserException -> false
is AuthException.MisconfigurationException -> false // Retrying cannot fix project setup
// The method is not available on this account; repeating it cannot change that.
is AuthException.SignInMethodUnavailableException -> false
is AuthException.UnknownException -> true
else -> true
}
Expand Down
Loading