Skip to content

Commit 96506d9

Browse files
feat(auth): implement legacyFetchSignInWithEmail configuration option
1 parent d1466d1 commit 96506d9

7 files changed

Lines changed: 181 additions & 16 deletions

File tree

auth/src/main/java/com/firebase/ui/auth/AuthException.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,26 @@ abstract class AuthException(
200200
cause: Throwable? = null
201201
) : AuthException(message, cause)
202202

203+
/**
204+
* A different sign-in method should be used for this email address.
205+
*
206+
* This exception is used for the opt-in legacy recovery path backed by
207+
* `fetchSignInMethodsForEmail`, allowing the UI to guide users toward a previously
208+
* used provider when email enumeration protection has been disabled.
209+
*
210+
* @property email The email address being recovered
211+
* @property signInMethods The sign-in methods returned by Firebase Auth
212+
* @property suggestedSignInMethod The preferred method the UI should direct the user toward
213+
* @property cause The underlying authentication failure that triggered the lookup
214+
*/
215+
class DifferentSignInMethodRequiredException(
216+
message: String,
217+
val email: String,
218+
val signInMethods: List<String>,
219+
val suggestedSignInMethod: String,
220+
cause: Throwable? = null
221+
) : AuthException(message, cause)
222+
203223
/**
204224
* Authentication was cancelled by the user.
205225
*

auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class AuthUIConfigurationBuilder {
4949
var isNewEmailAccountsAllowed: Boolean = true
5050
var isDisplayNameRequired: Boolean = true
5151
var isProviderChoiceAlwaysShown: Boolean = false
52+
var legacyFetchSignInWithEmail: Boolean = false
5253
var transitions: AuthUITransitions? = null
5354

5455
fun providers(block: AuthProvidersBuilder.() -> Unit) =
@@ -114,6 +115,7 @@ class AuthUIConfigurationBuilder {
114115
isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
115116
isDisplayNameRequired = isDisplayNameRequired,
116117
isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown,
118+
legacyFetchSignInWithEmail = legacyFetchSignInWithEmail,
117119
transitions = transitions
118120
)
119121
}
@@ -199,6 +201,15 @@ class AuthUIConfiguration(
199201
*/
200202
val isProviderChoiceAlwaysShown: Boolean = false,
201203

204+
/**
205+
* Enables legacy provider recovery via `fetchSignInMethodsForEmail`.
206+
*
207+
* This should only be enabled when email enumeration protection is disabled for the
208+
* Firebase project and the application explicitly wants to use the legacy API to
209+
* recover from email/password attempts made with the wrong provider.
210+
*/
211+
val legacyFetchSignInWithEmail: Boolean = false,
212+
202213
/**
203214
* Custom screen transition animations.
204215
* If null, uses default fade in/out transitions.

auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import com.google.firebase.auth.EmailAuthProvider
4040
import com.google.firebase.auth.FirebaseAuth
4141
import com.google.firebase.auth.FirebaseAuthMultiFactorException
4242
import com.google.firebase.auth.FirebaseAuthUserCollisionException
43+
import com.google.firebase.auth.SignInMethodQueryResult
4344
import kotlinx.coroutines.CancellationException
4445
import kotlinx.coroutines.tasks.await
4546

@@ -450,12 +451,82 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
450451
updateAuthState(AuthState.Error(e))
451452
throw e
452453
} catch (e: Exception) {
453-
val authException = AuthException.from(e)
454+
val authException = recoverLegacyDifferentSignInMethod(config, email, e)
455+
?: AuthException.from(e)
454456
updateAuthState(AuthState.Error(authException))
455457
throw authException
456458
}
457459
}
458460

461+
private suspend fun FirebaseAuthUI.recoverLegacyDifferentSignInMethod(
462+
config: AuthUIConfiguration,
463+
email: String,
464+
cause: Exception,
465+
): AuthException.DifferentSignInMethodRequiredException? {
466+
if (!config.legacyFetchSignInWithEmail) {
467+
return null
468+
}
469+
470+
val authException = AuthException.from(cause)
471+
if (authException !is AuthException.InvalidCredentialsException &&
472+
authException !is AuthException.UserNotFoundException) {
473+
return null
474+
}
475+
476+
val signInMethods = fetchLegacySignInMethods(email)
477+
val suggestedSignInMethod = selectSuggestedLegacySignInMethod(config, signInMethods) ?: return null
478+
if (signInMethods.isEmpty()) {
479+
return null
480+
}
481+
482+
return AuthException.DifferentSignInMethodRequiredException(
483+
message = config.stringProvider.accountLinkingRequiredRecoveryMessage,
484+
email = email,
485+
signInMethods = signInMethods,
486+
suggestedSignInMethod = suggestedSignInMethod,
487+
cause = cause
488+
)
489+
}
490+
491+
private fun selectSuggestedLegacySignInMethod(
492+
config: AuthUIConfiguration,
493+
signInMethods: List<String>,
494+
): String? {
495+
if (signInMethods.isEmpty() ||
496+
EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD in signInMethods) {
497+
return null
498+
}
499+
500+
val emailProvider = config.providers.filterIsInstance<AuthProvider.Email>().firstOrNull()
501+
val configuredProviderIds = config.providers.map { it.providerId }.toSet()
502+
503+
return signInMethods.firstOrNull { signInMethod ->
504+
when {
505+
signInMethod == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> {
506+
emailProvider?.isEmailLinkSignInEnabled == true
507+
}
508+
509+
signInMethod == EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD -> false
510+
else -> signInMethod in configuredProviderIds
511+
}
512+
}
513+
}
514+
515+
private suspend fun FirebaseAuthUI.fetchLegacySignInMethods(email: String): List<String> {
516+
return try {
517+
@Suppress("DEPRECATION")
518+
auth.fetchSignInMethodsForEmail(email)
519+
.await()
520+
.toSignInMethods()
521+
} catch (fetchException: Exception) {
522+
Log.w(TAG, "Legacy fetchSignInMethodsForEmail failed for: $email", fetchException)
523+
emptyList()
524+
}
525+
}
526+
527+
private fun SignInMethodQueryResult?.toSignInMethods(): List<String> =
528+
this?.signInMethods?.filter { it.isNotBlank() } ?: emptyList()
529+
459530
/**
460531
* Signs in with a credential or links it to an existing anonymous user.
461532
*

auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ import androidx.compose.ui.Modifier
2323
import androidx.compose.ui.text.style.TextAlign
2424
import androidx.compose.ui.window.DialogProperties
2525
import com.firebase.ui.auth.AuthException
26+
import com.google.firebase.auth.EmailAuthProvider
27+
import com.google.firebase.auth.FacebookAuthProvider
28+
import com.google.firebase.auth.GithubAuthProvider
29+
import com.google.firebase.auth.GoogleAuthProvider
30+
import com.google.firebase.auth.PhoneAuthProvider
31+
import com.google.firebase.auth.TwitterAuthProvider
2632
import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
2733

2834
/**
@@ -158,6 +164,9 @@ private fun getRecoveryMessage(
158164
// Use the custom message which includes email and provider details
159165
error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
160166
}
167+
is AuthException.DifferentSignInMethodRequiredException -> {
168+
error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
169+
}
161170
is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage
162171
is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage
163172
is AuthException.EmailLinkWrongDeviceException -> stringProvider.emailLinkWrongDeviceMessage
@@ -192,6 +201,8 @@ private fun getRecoveryActionText(
192201
is AuthException.AuthCancelledException -> error.message ?: stringProvider.continueText
193202
is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text
194203
is AuthException.AccountLinkingRequiredException -> stringProvider.signInDefault // User needs to sign in to link accounts
204+
is AuthException.DifferentSignInMethodRequiredException ->
205+
getDifferentSignInMethodActionText(error.suggestedSignInMethod, stringProvider)
195206
is AuthException.MfaRequiredException -> stringProvider.continueText // Use "Continue" for MFA
196207
is AuthException.EmailLinkPromptForEmailException -> stringProvider.continueText
197208
is AuthException.EmailLinkCrossDeviceLinkingException -> stringProvider.continueText
@@ -226,6 +237,7 @@ private fun isRecoverable(error: AuthException): Boolean {
226237
is AuthException.PhoneVerificationCooldownException -> false // User must wait for cooldown
227238
is AuthException.MfaRequiredException -> true
228239
is AuthException.AccountLinkingRequiredException -> true
240+
is AuthException.DifferentSignInMethodRequiredException -> true
229241
is AuthException.AuthCancelledException -> true
230242
is AuthException.EmailLinkPromptForEmailException -> true
231243
is AuthException.EmailLinkCrossDeviceLinkingException -> true
@@ -235,3 +247,21 @@ private fun isRecoverable(error: AuthException): Boolean {
235247
else -> true
236248
}
237249
}
250+
251+
private fun getDifferentSignInMethodActionText(
252+
signInMethod: String,
253+
stringProvider: AuthUIStringProvider,
254+
): String {
255+
return when (signInMethod) {
256+
GoogleAuthProvider.PROVIDER_ID -> stringProvider.continueWithGoogle
257+
FacebookAuthProvider.PROVIDER_ID -> stringProvider.continueWithFacebook
258+
TwitterAuthProvider.PROVIDER_ID -> stringProvider.continueWithTwitter
259+
GithubAuthProvider.PROVIDER_ID -> stringProvider.continueWithGithub
260+
PhoneAuthProvider.PROVIDER_ID -> stringProvider.continueWithPhone
261+
"apple.com" -> stringProvider.continueWithApple
262+
"microsoft.com" -> stringProvider.continueWithMicrosoft
263+
"yahoo.com" -> stringProvider.continueWithYahoo
264+
EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> stringProvider.signInWithEmailLink
265+
else -> stringProvider.continueText
266+
}
267+
}

auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class TopLevelDialogController(
8585
fun showErrorDialog(
8686
exception: AuthException,
8787
onRetry: (AuthException) -> Unit = {},
88-
onRecover: (AuthException) -> Unit = {},
88+
onRecover: ((AuthException) -> Unit)? = null,
8989
onDismiss: () -> Unit = {}
9090
) {
9191
// Get current error state
@@ -135,9 +135,11 @@ class TopLevelDialogController(
135135
state.onRetry(exception)
136136
state.onDismiss()
137137
},
138-
onRecover = { exception ->
139-
state.onRecover(exception)
140-
state.onDismiss()
138+
onRecover = state.onRecover?.let { onRecover ->
139+
{ exception ->
140+
onRecover(exception)
141+
state.onDismiss()
142+
}
141143
},
142144
onDismiss = state.onDismiss
143145
)
@@ -152,7 +154,7 @@ class TopLevelDialogController(
152154
data class ErrorDialog(
153155
val exception: AuthException,
154156
val onRetry: (AuthException) -> Unit,
155-
val onRecover: (AuthException) -> Unit,
157+
val onRecover: ((AuthException) -> Unit)?,
156158
val onDismiss: () -> Unit
157159
) : DialogState()
158160
}

auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,17 @@ fun FirebaseAuthScreen(
318318
authUI = authUI,
319319
credentialForLinking = pendingLinkingCredential.value,
320320
emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value,
321+
onContinueWithProvider = { providerId ->
322+
when (providerId) {
323+
googleProvider?.providerId -> onSignInWithGoogle?.invoke()
324+
facebookProvider?.providerId -> onSignInWithFacebook?.invoke()
325+
appleProvider?.providerId -> onSignInWithApple?.invoke()
326+
githubProvider?.providerId -> onSignInWithGithub?.invoke()
327+
microsoftProvider?.providerId -> onSignInWithMicrosoft?.invoke()
328+
yahooProvider?.providerId -> onSignInWithYahoo?.invoke()
329+
twitterProvider?.providerId -> onSignInWithTwitter?.invoke()
330+
}
331+
},
321332
onSuccess = {
322333
pendingLinkingCredential.value = null
323334
},
@@ -617,39 +628,43 @@ fun FirebaseAuthScreen(
617628
onRetry = { _ ->
618629
// Child screens handle their own retry logic
619630
},
620-
onRecover = { exception ->
621-
when (exception) {
622-
is AuthException.EmailAlreadyInUseException -> {
631+
onRecover = when (exception) {
632+
is AuthException.EmailAlreadyInUseException -> {
633+
{
623634
navController.navigate(AuthRoute.Email.route) {
624635
launchSingleTop = true
625636
}
626637
}
638+
}
627639

628-
is AuthException.AccountLinkingRequiredException -> {
640+
is AuthException.AccountLinkingRequiredException -> {
641+
{
629642
pendingLinkingCredential.value = exception.credential
630643
navController.navigate(AuthRoute.Email.route) {
631644
launchSingleTop = true
632645
}
633646
}
647+
}
634648

635-
is AuthException.EmailLinkPromptForEmailException -> {
636-
// Cross-device flow: User needs to enter their email
649+
is AuthException.EmailLinkPromptForEmailException -> {
650+
{
637651
emailLinkFromDifferentDevice.value = exception.emailLink
638652
navController.navigate(AuthRoute.Email.route) {
639653
launchSingleTop = true
640654
}
641655
}
656+
}
642657

643-
is AuthException.EmailLinkCrossDeviceLinkingException -> {
644-
// Cross-device linking flow: User needs to enter email to link provider
658+
is AuthException.EmailLinkCrossDeviceLinkingException -> {
659+
{
645660
emailLinkFromDifferentDevice.value = exception.emailLink
646661
navController.navigate(AuthRoute.Email.route) {
647662
launchSingleTop = true
648663
}
649664
}
650-
651-
else -> Unit
652665
}
666+
667+
else -> null
653668
},
654669
onDismiss = {
655670
// Dialog dismissed

auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundExceptio
4242
import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController
4343
import com.google.firebase.auth.AuthCredential
4444
import com.google.firebase.auth.AuthResult
45+
import com.google.firebase.auth.EmailAuthProvider
4546
import kotlinx.coroutines.launch
4647

4748
enum class EmailAuthMode {
@@ -130,6 +131,7 @@ fun EmailAuthScreen(
130131
authUI: FirebaseAuthUI,
131132
credentialForLinking: AuthCredential? = null,
132133
emailLinkFromDifferentDevice: String? = null,
134+
onContinueWithProvider: (String) -> Unit = {},
133135
onSuccess: (AuthResult) -> Unit,
134136
onError: (AuthException) -> Unit,
135137
onCancel: () -> Unit,
@@ -209,6 +211,20 @@ fun EmailAuthScreen(
209211
else -> Unit
210212
}
211213
},
214+
onRecover = if (exception is AuthException.DifferentSignInMethodRequiredException) {
215+
{ ex ->
216+
val differentProviderException =
217+
ex as AuthException.DifferentSignInMethodRequiredException
218+
if (differentProviderException.suggestedSignInMethod ==
219+
EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) {
220+
mode.value = EmailAuthMode.EmailLinkSignIn
221+
} else {
222+
onContinueWithProvider(differentProviderException.suggestedSignInMethod)
223+
}
224+
}
225+
} else {
226+
null
227+
},
212228
onDismiss = {
213229
// Dialog dismissed
214230
}

0 commit comments

Comments
 (0)