diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt index ae9d96e53..779b93a8d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt @@ -123,6 +123,25 @@ abstract class AuthException( val reason: String? = null ) : AuthException(message, cause) + /** + * The password violates one or more Google Identity Platform password policy requirements. + * + * This exception is thrown when GIdP password policy enforcement is enabled and the supplied + * password fails one or more configured constraints (e.g. minimum length, missing uppercase). + * + * [message] is a newline-separated, human-readable description of each failing constraint + * as returned by the server, suitable for direct display in the UI. + * + * @property message Human-readable description of the failing constraints + * @property failingRequirements The individual constraint strings from the server + * @property cause The underlying [Throwable] that caused this exception + */ + class PasswordPolicyViolationException( + message: String, + val failingRequirements: List, + cause: Throwable? = null + ) : AuthException(message, cause) + /** * An account with the given email already exists. * @@ -354,7 +373,32 @@ abstract class AuthException( // If already an AuthException, return it directly is AuthException -> firebaseException - // Handle specific Firebase Auth exceptions first (before general FirebaseException) + // Handle specific Firebase Auth exceptions first (before general FirebaseException). + // FirebaseAuthWeakPasswordException extends FirebaseAuthInvalidCredentialsException, + // so it must be checked before the parent type. + is FirebaseAuthWeakPasswordException -> { + val sourceText = firebaseException.reason ?: firebaseException.message ?: "" + if (sourceText.contains("PASSWORD_DOES_NOT_MEET_REQUIREMENTS", ignoreCase = true)) { + val requirements = parsePasswordPolicyRequirements(sourceText) + PasswordPolicyViolationException( + message = requirements.joinToString("\n").ifEmpty { + stringProvider?.errorWeakPasswordGeneric.nonEmpty() + ?: "Password does not meet policy requirements" + }, + failingRequirements = requirements, + cause = firebaseException + ) + } else { + WeakPasswordException( + message = stringProvider?.errorWeakPasswordGeneric.nonEmpty() + ?: firebaseException.message + ?: "Password is too weak", + cause = firebaseException, + reason = firebaseException.reason + ) + } + } + is FirebaseAuthInvalidCredentialsException -> { InvalidCredentialsException( message = stringProvider?.errorInvalidCredentials.nonEmpty() @@ -389,16 +433,6 @@ abstract class AuthException( } } - is FirebaseAuthWeakPasswordException -> { - WeakPasswordException( - message = stringProvider?.errorWeakPasswordGeneric.nonEmpty() - ?: firebaseException.message - ?: "Password is too weak", - cause = firebaseException, - reason = firebaseException.reason - ) - } - is FirebaseAuthUserCollisionException -> { when (firebaseException.errorCode) { "ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException( @@ -469,12 +503,24 @@ abstract class AuthException( } is FirebaseException -> { - NetworkException( - message = stringProvider?.errorNetworkGeneric.nonEmpty() - ?: firebaseException.message - ?: "Network error occurred", - cause = firebaseException - ) + val msg = firebaseException.message ?: "" + if (msg.contains("PASSWORD_DOES_NOT_MEET_REQUIREMENTS", ignoreCase = true)) { + val requirements = parsePasswordPolicyRequirements(msg) + PasswordPolicyViolationException( + message = requirements.joinToString("\n").ifEmpty { + stringProvider?.errorWeakPasswordGeneric.nonEmpty() + ?: "Password does not meet policy requirements" + }, + failingRequirements = requirements, + cause = firebaseException + ) + } else { + NetworkException( + message = stringProvider?.errorNetworkGeneric.nonEmpty() + ?: msg.ifEmpty { "Network error occurred" }, + cause = firebaseException + ) + } } else -> { @@ -500,5 +546,21 @@ abstract class AuthException( } private fun String?.nonEmpty(): String? = this?.ifEmpty { null } + + // Finds the [...] content that immediately follows PASSWORD_DOES_NOT_MEET_REQUIREMENTS + // in both FirebaseException and FirebaseAuthWeakPasswordException messages. + // GIdP returns human-readable requirement strings inside those brackets, e.g. + // "...PASSWORD_DOES_NOT_MEET_REQUIREMENTS:Missing password requirements: [Password must contain at least 10 characters]" + private fun parsePasswordPolicyRequirements(message: String): List { + val policyIndex = message.indexOf("PASSWORD_DOES_NOT_MEET_REQUIREMENTS", ignoreCase = true) + if (policyIndex == -1) return emptyList() + val start = message.indexOf('[', policyIndex) + val end = message.indexOf(']', policyIndex) + if (start == -1 || end == -1 || end <= start) return emptyList() + return message.substring(start + 1, end) + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt index dff4daa60..230bb835a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt @@ -140,6 +140,11 @@ private fun getRecoveryMessage( } ?: baseMessage } + is AuthException.PasswordPolicyViolationException -> { + error.message?.takeIf { it.isNotBlank() } + ?: stringProvider.weakPasswordRecoveryMessage + } + is AuthException.EmailAlreadyInUseException -> { // Include email if available val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage @@ -201,6 +206,7 @@ private fun getRecoveryActionText( is AuthException.NetworkException, is AuthException.InvalidCredentialsException, is AuthException.WeakPasswordException, + is AuthException.PasswordPolicyViolationException, is AuthException.TooManyRequestsException, is AuthException.PhoneVerificationCooldownException -> stringProvider.retryAction is AuthException.UnknownException -> stringProvider.retryAction @@ -221,6 +227,7 @@ private fun isRecoverable(error: AuthException): Boolean { is AuthException.InvalidCredentialsException -> true is AuthException.UserNotFoundException -> true is AuthException.WeakPasswordException -> true + is AuthException.PasswordPolicyViolationException -> true is AuthException.EmailAlreadyInUseException -> true is AuthException.TooManyRequestsException -> false // User must wait is AuthException.PhoneVerificationCooldownException -> false // User must wait for cooldown diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt index 62972d18c..1f566f5cf 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt @@ -306,7 +306,7 @@ fun EmailAuthScreen( password = passwordTextValue.value, ) } catch (e: Exception) { - + onError(AuthException.from(e, stringProvider)) } } }, @@ -318,7 +318,7 @@ fun EmailAuthScreen( actionCodeSettings = configuration.passwordResetActionCodeSettings, ) } catch (e: Exception) { - + onError(AuthException.from(e, stringProvider)) } } }, diff --git a/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt b/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt index caa382bb1..ea8ec7ecd 100644 --- a/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt @@ -19,6 +19,7 @@ import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseException import com.google.firebase.auth.FirebaseAuthException import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseAuthWeakPasswordException import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock @@ -113,6 +114,7 @@ class AuthExceptionTest { assertThat(AuthException.InvalidCredentialsException("Test")).isInstanceOf(AuthException::class.java) assertThat(AuthException.UserNotFoundException("Test")).isInstanceOf(AuthException::class.java) assertThat(AuthException.WeakPasswordException("Test")).isInstanceOf(AuthException::class.java) + assertThat(AuthException.PasswordPolicyViolationException("Test", emptyList())).isInstanceOf(AuthException::class.java) assertThat(AuthException.EmailAlreadyInUseException("Test")).isInstanceOf(AuthException::class.java) assertThat(AuthException.TooManyRequestsException("Test")).isInstanceOf(AuthException::class.java) assertThat(AuthException.MfaRequiredException("Test")).isInstanceOf(AuthException::class.java) @@ -183,4 +185,108 @@ class AuthExceptionTest { assertThat(result.message).isEqualTo("Firebase: user disabled") } + + // ============================================================================================= + // GIdP password policy + // ============================================================================================= + + @Test + fun `from() maps GIdP policy violation FirebaseException to PasswordPolicyViolationException`() { + val msg = "An internal error has occurred. [ PASSWORD_DOES_NOT_MEET_REQUIREMENTS:" + + "Missing password requirements: [Password must contain at least 10 characters] ]" + val firebaseException = object : com.google.firebase.FirebaseException(msg) {} + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + val policyEx = result as AuthException.PasswordPolicyViolationException + assertThat(policyEx.failingRequirements).containsExactly( + "Password must contain at least 10 characters" + ) + assertThat(policyEx.message).isEqualTo("Password must contain at least 10 characters") + assertThat(policyEx.cause).isEqualTo(firebaseException) + } + + @Test + fun `from() maps GIdP policy violation with multiple requirements`() { + val msg = "An internal error has occurred. [ PASSWORD_DOES_NOT_MEET_REQUIREMENTS:" + + "Missing password requirements: [Password must contain at least 10 characters, " + + "Password must contain at least one uppercase letter] ]" + val firebaseException = object : com.google.firebase.FirebaseException(msg) {} + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + val policyEx = result as AuthException.PasswordPolicyViolationException + assertThat(policyEx.failingRequirements).containsExactly( + "Password must contain at least 10 characters", + "Password must contain at least one uppercase letter" + ).inOrder() + assertThat(policyEx.message).isEqualTo( + "Password must contain at least 10 characters\nPassword must contain at least one uppercase letter" + ) + } + + @Test + fun `from() maps GIdP policy violation in FirebaseAuthWeakPasswordException reason`() { + val firebaseException = FirebaseAuthWeakPasswordException( + "ERROR_WEAK_PASSWORD", + "weak", + "PASSWORD_DOES_NOT_MEET_REQUIREMENTS : [Password must contain uppercase, Password must contain a number]" + ) + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + val policyEx = result as AuthException.PasswordPolicyViolationException + assertThat(policyEx.failingRequirements).containsExactly( + "Password must contain uppercase", + "Password must contain a number" + ).inOrder() + assertThat(policyEx.message).isEqualTo("Password must contain uppercase\nPassword must contain a number") + } + + @Test + fun `from() passes through unknown requirement strings as-is`() { + val msg = "An internal error has occurred. [ PASSWORD_DOES_NOT_MEET_REQUIREMENTS:" + + "Missing password requirements: [Some future requirement] ]" + val firebaseException = object : com.google.firebase.FirebaseException(msg) {} + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + val policyEx = result as AuthException.PasswordPolicyViolationException + assertThat(policyEx.failingRequirements).containsExactly("Some future requirement") + assertThat(policyEx.message).isEqualTo("Some future requirement") + } + + @Test + fun `from() maps plain weak password (no policy) to WeakPasswordException`() { + val firebaseException = FirebaseAuthWeakPasswordException( + "ERROR_WEAK_PASSWORD", + "The given password is invalid.", + "Password should be at least 6 characters" + ) + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.WeakPasswordException::class.java) + } + + @Test + fun `from() maps plain FirebaseException without policy to NetworkException`() { + val firebaseException = object : com.google.firebase.FirebaseException("Network timeout") {} + + val result = AuthException.from(firebaseException) + + assertThat(result).isInstanceOf(AuthException.NetworkException::class.java) + } + + @Test + fun `PasswordPolicyViolationException stores failingRequirements correctly`() { + val requirements = listOf("MISSING_UPPERCASE_CHARACTER", "MISSING_NUMERIC_CHARACTER") + val exception = AuthException.PasswordPolicyViolationException("msg", requirements) + + assertThat(exception.failingRequirements).isEqualTo(requirements) + } } \ No newline at end of file