Skip to content
Merged
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
96 changes: 79 additions & 17 deletions auth/src/main/java/com/firebase/ui/auth/AuthException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
cause: Throwable? = null
) : AuthException(message, cause)

/**
* An account with the given email already exists.
*
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 -> {
Expand All @@ -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<String> {
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() }
}
Comment thread
demolaf marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ fun EmailAuthScreen(
password = passwordTextValue.value,
)
} catch (e: Exception) {

onError(AuthException.from(e, stringProvider))
}
}
},
Expand All @@ -318,7 +318,7 @@ fun EmailAuthScreen(
actionCodeSettings = configuration.passwordResetActionCodeSettings,
)
} catch (e: Exception) {

onError(AuthException.from(e, stringProvider))
}
}
},
Expand Down
106 changes: 106 additions & 0 deletions auth/src/test/java/com/firebase/ui/auth/AuthExceptionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Loading