Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
20 changes: 20 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,26 @@ abstract class AuthException(
cause: Throwable? = null
) : AuthException(message, cause)

/**
* A different sign-in method should be used for this email address.
*
* This exception is used for the opt-in legacy recovery path backed by
* `fetchSignInMethodsForEmail`, allowing the UI to guide users toward a previously
* used provider when email enumeration protection has been disabled.
*
* @property email The email address being recovered
* @property signInMethods The sign-in methods returned by Firebase Auth
* @property suggestedSignInMethod The preferred method the UI should direct the user toward
* @property cause The underlying authentication failure that triggered the lookup
*/
class DifferentSignInMethodRequiredException(
message: String,
val email: String,
val signInMethods: List<String>,
val suggestedSignInMethod: String,
cause: Throwable? = null
) : AuthException(message, cause)

/**
* Authentication was cancelled by the user.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class AuthUIConfigurationBuilder {
var isNewEmailAccountsAllowed: Boolean = true
var isDisplayNameRequired: Boolean = true
var isProviderChoiceAlwaysShown: Boolean = false
var legacyFetchSignInWithEmail: Boolean = false
var transitions: AuthUITransitions? = null

fun providers(block: AuthProvidersBuilder.() -> Unit) =
Expand Down Expand Up @@ -114,6 +115,7 @@ class AuthUIConfigurationBuilder {
isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
isDisplayNameRequired = isDisplayNameRequired,
isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown,
legacyFetchSignInWithEmail = legacyFetchSignInWithEmail,
transitions = transitions
)
}
Expand Down Expand Up @@ -199,6 +201,15 @@ class AuthUIConfiguration(
*/
val isProviderChoiceAlwaysShown: Boolean = false,

/**
* Enables legacy provider recovery via `fetchSignInMethodsForEmail`.
*
* This should only be enabled when email enumeration protection is disabled for the
* Firebase project and the application explicitly wants to use the legacy API to
* recover from email/password attempts made with the wrong provider.
*/
val legacyFetchSignInWithEmail: Boolean = false,

/**
* Custom screen transition animations.
* If null, uses default fade in/out transitions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseAuthMultiFactorException
import com.google.firebase.auth.FirebaseAuthUserCollisionException
import com.google.firebase.auth.SignInMethodQueryResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.tasks.await

Expand Down Expand Up @@ -450,12 +451,82 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
updateAuthState(AuthState.Error(e))
throw e
} catch (e: Exception) {
val authException = AuthException.from(e)
val authException = recoverLegacyDifferentSignInMethod(config, email, e)
?: AuthException.from(e)
updateAuthState(AuthState.Error(authException))
throw authException
}
}

private suspend fun FirebaseAuthUI.recoverLegacyDifferentSignInMethod(
config: AuthUIConfiguration,
email: String,
cause: Exception,
): AuthException.DifferentSignInMethodRequiredException? {
if (!config.legacyFetchSignInWithEmail) {
return null
}

val authException = AuthException.from(cause)
if (authException !is AuthException.InvalidCredentialsException &&
authException !is AuthException.UserNotFoundException) {
return null
}

val signInMethods = fetchLegacySignInMethods(email)
val suggestedSignInMethod = selectSuggestedLegacySignInMethod(config, signInMethods) ?: return null
if (signInMethods.isEmpty()) {
return null
}
Comment thread
russellwheatley marked this conversation as resolved.
Outdated

return AuthException.DifferentSignInMethodRequiredException(
message = config.stringProvider.accountLinkingRequiredRecoveryMessage,
email = email,
signInMethods = signInMethods,
suggestedSignInMethod = suggestedSignInMethod,
cause = cause
)
}

private fun selectSuggestedLegacySignInMethod(
config: AuthUIConfiguration,
signInMethods: List<String>,
): String? {
if (signInMethods.isEmpty() ||
EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD in signInMethods) {
return null
}

val emailProvider = config.providers.filterIsInstance<AuthProvider.Email>().firstOrNull()
val configuredProviderIds = config.providers.map { it.providerId }.toSet()

return signInMethods.firstOrNull { signInMethod ->
when {
signInMethod == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> {
emailProvider?.isEmailLinkSignInEnabled == true
}

signInMethod == EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD -> false
else -> signInMethod in configuredProviderIds
}
}
}

private suspend fun FirebaseAuthUI.fetchLegacySignInMethods(email: String): List<String> {
return try {
@Suppress("DEPRECATION")
auth.fetchSignInMethodsForEmail(email)
.await()
.toSignInMethods()
} catch (fetchException: Exception) {
Log.w(TAG, "Legacy fetchSignInMethodsForEmail failed for: $email", fetchException)
emptyList()
}
}

private fun SignInMethodQueryResult?.toSignInMethods(): List<String> =
this?.signInMethods?.filter { it.isNotBlank() } ?: emptyList()

/**
* Signs in with a credential or links it to an existing anonymous user.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.window.DialogProperties
import com.firebase.ui.auth.AuthException
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FacebookAuthProvider
import com.google.firebase.auth.GithubAuthProvider
import com.google.firebase.auth.GoogleAuthProvider
import com.google.firebase.auth.PhoneAuthProvider
import com.google.firebase.auth.TwitterAuthProvider
import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider

/**
Expand Down Expand Up @@ -158,6 +164,9 @@ private fun getRecoveryMessage(
// Use the custom message which includes email and provider details
error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
}
is AuthException.DifferentSignInMethodRequiredException -> {
error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
}
is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage
is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage
is AuthException.EmailLinkWrongDeviceException -> stringProvider.emailLinkWrongDeviceMessage
Expand Down Expand Up @@ -192,6 +201,8 @@ private fun getRecoveryActionText(
is AuthException.AuthCancelledException -> error.message ?: stringProvider.continueText
is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text
is AuthException.AccountLinkingRequiredException -> stringProvider.signInDefault // User needs to sign in to link accounts
is AuthException.DifferentSignInMethodRequiredException ->
getDifferentSignInMethodActionText(error.suggestedSignInMethod, stringProvider)
is AuthException.MfaRequiredException -> stringProvider.continueText // Use "Continue" for MFA
is AuthException.EmailLinkPromptForEmailException -> stringProvider.continueText
is AuthException.EmailLinkCrossDeviceLinkingException -> stringProvider.continueText
Expand Down Expand Up @@ -226,6 +237,7 @@ private fun isRecoverable(error: AuthException): Boolean {
is AuthException.PhoneVerificationCooldownException -> false // User must wait for cooldown
is AuthException.MfaRequiredException -> true
is AuthException.AccountLinkingRequiredException -> true
is AuthException.DifferentSignInMethodRequiredException -> true
is AuthException.AuthCancelledException -> true
is AuthException.EmailLinkPromptForEmailException -> true
is AuthException.EmailLinkCrossDeviceLinkingException -> true
Expand All @@ -235,3 +247,21 @@ private fun isRecoverable(error: AuthException): Boolean {
else -> true
}
}

private fun getDifferentSignInMethodActionText(
signInMethod: String,
stringProvider: AuthUIStringProvider,
): String {
return when (signInMethod) {
GoogleAuthProvider.PROVIDER_ID -> stringProvider.continueWithGoogle
FacebookAuthProvider.PROVIDER_ID -> stringProvider.continueWithFacebook
TwitterAuthProvider.PROVIDER_ID -> stringProvider.continueWithTwitter
GithubAuthProvider.PROVIDER_ID -> stringProvider.continueWithGithub
PhoneAuthProvider.PROVIDER_ID -> stringProvider.continueWithPhone
"apple.com" -> stringProvider.continueWithApple
"microsoft.com" -> stringProvider.continueWithMicrosoft
"yahoo.com" -> stringProvider.continueWithYahoo
EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> stringProvider.signInWithEmailLink
else -> stringProvider.continueText
}
Comment on lines +255 to +266
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a Provider enum in AuthProvider.kt, maybe we can use that to avoid the hardcoded strings but I don't think this would change anytime soon, just a nice to have.

Suggested change
return when (signInMethod) {
GoogleAuthProvider.PROVIDER_ID -> stringProvider.continueWithGoogle
FacebookAuthProvider.PROVIDER_ID -> stringProvider.continueWithFacebook
TwitterAuthProvider.PROVIDER_ID -> stringProvider.continueWithTwitter
GithubAuthProvider.PROVIDER_ID -> stringProvider.continueWithGithub
PhoneAuthProvider.PROVIDER_ID -> stringProvider.continueWithPhone
"apple.com" -> stringProvider.continueWithApple
"microsoft.com" -> stringProvider.continueWithMicrosoft
"yahoo.com" -> stringProvider.continueWithYahoo
EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> stringProvider.signInWithEmailLink
else -> stringProvider.continueText
}
return when (Provider.fromId(signInMethod)) {
Provider.GOOGLE -> stringProvider.continueWithGoogle
Provider.FACEBOOK -> stringProvider.continueWithFacebook
Provider.TWITTER -> stringProvider.continueWithTwitter
Provider.GITHUB -> stringProvider.continueWithGithub
Provider.PHONE -> stringProvider.continueWithPhone
Provider.APPLE -> stringProvider.continueWithApple
Provider.MICROSOFT -> stringProvider.continueWithMicrosoft
Provider.YAHOO -> stringProvider.continueWithYahoo
null -> if (signInMethod == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) {
stringProvider.signInWithEmailLink
} else {
stringProvider.continueText
}
else -> stringProvider.continueText
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class TopLevelDialogController(
fun showErrorDialog(
exception: AuthException,
onRetry: (AuthException) -> Unit = {},
onRecover: (AuthException) -> Unit = {},
onRecover: ((AuthException) -> Unit)? = null,
onDismiss: () -> Unit = {}
) {
// Get current error state
Expand Down Expand Up @@ -135,9 +135,11 @@ class TopLevelDialogController(
state.onRetry(exception)
state.onDismiss()
},
onRecover = { exception ->
state.onRecover(exception)
state.onDismiss()
onRecover = state.onRecover?.let { onRecover ->
{ exception ->
onRecover(exception)
state.onDismiss()
}
},
onDismiss = state.onDismiss
)
Expand All @@ -152,7 +154,7 @@ class TopLevelDialogController(
data class ErrorDialog(
val exception: AuthException,
val onRetry: (AuthException) -> Unit,
val onRecover: (AuthException) -> Unit,
val onRecover: ((AuthException) -> Unit)?,
val onDismiss: () -> Unit
) : DialogState()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,17 @@ fun FirebaseAuthScreen(
authUI = authUI,
credentialForLinking = pendingLinkingCredential.value,
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value,
onContinueWithProvider = { providerId ->
when (providerId) {
googleProvider?.providerId -> onSignInWithGoogle?.invoke()
facebookProvider?.providerId -> onSignInWithFacebook?.invoke()
appleProvider?.providerId -> onSignInWithApple?.invoke()
githubProvider?.providerId -> onSignInWithGithub?.invoke()
microsoftProvider?.providerId -> onSignInWithMicrosoft?.invoke()
yahooProvider?.providerId -> onSignInWithYahoo?.invoke()
twitterProvider?.providerId -> onSignInWithTwitter?.invoke()
}
Comment thread
russellwheatley marked this conversation as resolved.
Outdated
},
onSuccess = {
pendingLinkingCredential.value = null
},
Expand Down Expand Up @@ -617,39 +628,43 @@ fun FirebaseAuthScreen(
onRetry = { _ ->
// Child screens handle their own retry logic
},
onRecover = { exception ->
when (exception) {
is AuthException.EmailAlreadyInUseException -> {
onRecover = when (exception) {
is AuthException.EmailAlreadyInUseException -> {
{
navController.navigate(AuthRoute.Email.route) {
launchSingleTop = true
}
}
}

is AuthException.AccountLinkingRequiredException -> {
is AuthException.AccountLinkingRequiredException -> {
{
pendingLinkingCredential.value = exception.credential
navController.navigate(AuthRoute.Email.route) {
launchSingleTop = true
}
}
}

is AuthException.EmailLinkPromptForEmailException -> {
// Cross-device flow: User needs to enter their email
is AuthException.EmailLinkPromptForEmailException -> {
{
emailLinkFromDifferentDevice.value = exception.emailLink
navController.navigate(AuthRoute.Email.route) {
launchSingleTop = true
}
}
}

is AuthException.EmailLinkCrossDeviceLinkingException -> {
// Cross-device linking flow: User needs to enter email to link provider
is AuthException.EmailLinkCrossDeviceLinkingException -> {
{
emailLinkFromDifferentDevice.value = exception.emailLink
navController.navigate(AuthRoute.Email.route) {
launchSingleTop = true
}
}

else -> Unit
}

else -> null
Comment thread
russellwheatley marked this conversation as resolved.
},
onDismiss = {
// Dialog dismissed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundExceptio
import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.EmailAuthProvider
import kotlinx.coroutines.launch

enum class EmailAuthMode {
Expand Down Expand Up @@ -130,6 +131,7 @@ fun EmailAuthScreen(
authUI: FirebaseAuthUI,
credentialForLinking: AuthCredential? = null,
emailLinkFromDifferentDevice: String? = null,
onContinueWithProvider: (String) -> Unit = {},
onSuccess: (AuthResult) -> Unit,
onError: (AuthException) -> Unit,
onCancel: () -> Unit,
Expand Down Expand Up @@ -209,6 +211,20 @@ fun EmailAuthScreen(
else -> Unit
}
},
onRecover = if (exception is AuthException.DifferentSignInMethodRequiredException) {
{ ex ->
val differentProviderException =
ex as AuthException.DifferentSignInMethodRequiredException
if (differentProviderException.suggestedSignInMethod ==
EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) {
mode.value = EmailAuthMode.EmailLinkSignIn
} else {
onContinueWithProvider(differentProviderException.suggestedSignInMethod)
}
}
} else {
null
},
onDismiss = {
// Dialog dismissed
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ class AuthUIConfigurationTest {
assertThat(config.isNewEmailAccountsAllowed).isTrue()
assertThat(config.isDisplayNameRequired).isTrue()
assertThat(config.isProviderChoiceAlwaysShown).isFalse()
assertThat(config.legacyFetchSignInWithEmail).isFalse()
}

@Test
Expand Down Expand Up @@ -129,6 +130,7 @@ class AuthUIConfigurationTest {
isNewEmailAccountsAllowed = false
isDisplayNameRequired = false
isProviderChoiceAlwaysShown = true
legacyFetchSignInWithEmail = true
}

assertThat(config.context).isEqualTo(applicationContext)
Expand All @@ -147,6 +149,7 @@ class AuthUIConfigurationTest {
assertThat(config.isNewEmailAccountsAllowed).isFalse()
assertThat(config.isDisplayNameRequired).isFalse()
assertThat(config.isProviderChoiceAlwaysShown).isTrue()
assertThat(config.legacyFetchSignInWithEmail).isTrue()
}

@Test
Expand Down Expand Up @@ -465,6 +468,7 @@ class AuthUIConfigurationTest {
"isNewEmailAccountsAllowed",
"isDisplayNameRequired",
"isProviderChoiceAlwaysShown",
"legacyFetchSignInWithEmail",
"transitions"
)

Expand Down
Loading
Loading