From cd050a614f32ffb2f9cf375432f67acc59272ea9 Mon Sep 17 00:00:00 2001 From: demolaf Date: Fri, 5 Jun 2026 11:42:35 +0100 Subject: [PATCH] wip --- .../android/demo/HighLevelApiDemoActivity.kt | 33 ++ .../firebase/ui/auth/AuthFlowController.kt | 2 +- .../java/com/firebase/ui/auth/AuthState.kt | 29 ++ .../com/firebase/ui/auth/FirebaseAuthUI.kt | 54 +++ .../auth/configuration/AuthUIConfiguration.kt | 36 +- .../auth_provider/AuthProvider.kt | 14 + .../EmailAuthProvider+FirebaseAuthUI.kt | 44 ++- .../FacebookAuthProvider+FirebaseAuthUI.kt | 2 +- .../GoogleAuthProvider+FirebaseAuthUI.kt | 2 +- .../OAuthProvider+FirebaseAuthUI.kt | 15 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 336 +++++++++++------- .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 82 +++++ .../firebase/ui/auth/FirebaseAuthUITest.kt | 91 +++++ .../configuration/AuthUIConfigurationTest.kt | 3 +- .../auth_provider/AuthProviderTest.kt | 75 ++++ 15 files changed, 668 insertions(+), 150 deletions(-) diff --git a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt index a4c708a6b..48bed5359 100644 --- a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt @@ -25,11 +25,19 @@ import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI @@ -212,6 +220,9 @@ private fun AppAuthenticatedContent( val configuration = uiContext.configuration when (state) { is AuthState.Success -> { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var isDeletingAccount by remember { mutableStateOf(false) } val user = uiContext.authUI.getCurrentUser() val identifier = user.displayIdentifier() Column( @@ -261,6 +272,28 @@ private fun AppAuthenticatedContent( Button(onClick = uiContext.onSignOut) { Text(stringProvider.signOutAction) } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + lifecycleOwner.lifecycleScope.launch { + isDeletingAccount = true + try { + uiContext.authUI.delete(context) + } catch (e: AuthException.InvalidCredentialsException) { + // ReauthenticationRequired state was emitted — + // FirebaseAuthScreen navigates to the reauth flow automatically. + Log.d("HighLevelApiDemoActivity", "Reauth required before delete") + } catch (e: AuthException) { + Log.e("HighLevelApiDemoActivity", "Delete failed", e) + } finally { + isDeletingAccount = false + } + } + }, + enabled = !isDeletingAccount + ) { + if (isDeletingAccount) CircularProgressIndicator() else Text("Delete account") + } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt index 917584219..93974a174 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt @@ -103,7 +103,7 @@ import java.util.concurrent.atomic.AtomicBoolean */ class AuthFlowController internal constructor( private val authUI: FirebaseAuthUI, - private val configuration: AuthUIConfiguration + internal val configuration: AuthUIConfiguration ) { private val coroutineScope = CoroutineScope(Dispatchers.Main + Job()) diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt index 061b33a45..697480213 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -208,6 +208,35 @@ abstract class AuthState private constructor() { "AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)" } + /** + * Reauthentication is required before a sensitive operation (e.g. delete account, change email) + * can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch the reauthentication flow. + * + * @property user The [FirebaseUser] that needs to reauthenticate + * @property reason Optional human-readable reason to show the user + */ + class ReauthenticationRequired( + val user: FirebaseUser, + val reason: String? = null, + // Not included in equals/hashCode — lambdas have no meaningful equality. + val retryOperation: (suspend (android.content.Context) -> Unit)? = null, + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ReauthenticationRequired) return false + return user == other.user && reason == other.reason + } + + override fun hashCode(): Int { + var result = user.hashCode() + result = 31 * result + (reason?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "AuthState.ReauthenticationRequired(user=$user, reason=$reason)" + } + /** * Password reset link has been sent to the user's email. */ diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt index 827c37abd..f4b6e7ec7 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -19,6 +19,8 @@ import android.content.Intent import androidx.annotation.RestrictTo import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.filterToLinkedProviders +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle import com.google.firebase.Firebase @@ -211,6 +213,45 @@ class FirebaseAuthUI private constructor( return AuthFlowController(this, configuration) } + /** + * Creates a reauthentication flow scoped to the current user's linked providers. + * + * This method builds a sign-in flow where: + * - Only providers already linked to the current [FirebaseUser] are offered + * - Account creation is disabled + * - The credential path calls [FirebaseUser.reauthenticateWithCredential] instead of + * [FirebaseAuth.signInWithCredential] + * + * Use this before sensitive operations (delete account, change email, etc.) that require + * a recent sign-in. + * + * @param configuration Base [AuthUIConfiguration] whose provider list is filtered to + * the user's linked providers. All other settings are preserved. + * @param reason Optional human-readable string shown to the user explaining why + * reauthentication is needed (e.g. "To delete your account we need to verify it's you"). + * @return An [AuthFlowController] configured for reauthentication + * @throws AuthException.UserNotFoundException if no user is currently signed in + * @throws IllegalStateException if none of the configured providers are linked to the + * current user + * @since 10.0.0 + */ + fun createReauthFlow(configuration: AuthUIConfiguration): AuthFlowController { + val currentUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in" + ) + val linked = configuration.providers.filterToLinkedProviders(currentUser) + check(linked.isNotEmpty()) { + "No configured providers are linked to the current user" + } + val reauthConfig = configuration.copy( + providers = linked, + isNewEmailAccountsAllowed = false, + isReauthenticationMode = true, + ) + return AuthFlowController(this, reauthConfig) + } + /** * Returns a [Flow] that emits [AuthState] changes. * @@ -463,6 +504,19 @@ class FirebaseAuthUI private constructor( // Update state to idle (user deleted and signed out) updateAuthState(AuthState.Idle) + } catch (e: FirebaseAuthRecentLoginRequiredException) { + auth.currentUser?.let { + updateAuthState( + AuthState.ReauthenticationRequired( + user = it, + retryOperation = { ctx -> delete(ctx) }, + ) + ) + } + throw AuthException.InvalidCredentialsException( + message = e.message ?: "Recent login required for this operation", + cause = e + ) } catch (e: CancellationException) { // Handle coroutine cancellation val cancelledException = AuthException.AuthCancelledException( diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index 3fa7f394b..c691acc26 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt @@ -50,6 +50,7 @@ class AuthUIConfigurationBuilder { var isDisplayNameRequired: Boolean = true var isProviderChoiceAlwaysShown: Boolean = false var transitions: AuthUITransitions? = null + internal var isReauthenticationMode: Boolean = false fun providers(block: AuthProvidersBuilder.() -> Unit) = providers.addAll(AuthProvidersBuilder().apply(block).build()) @@ -114,7 +115,8 @@ class AuthUIConfigurationBuilder { isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, isDisplayNameRequired = isDisplayNameRequired, isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown, - transitions = transitions + transitions = transitions, + isReauthenticationMode = isReauthenticationMode, ) } } @@ -204,4 +206,34 @@ class AuthUIConfiguration( * If null, uses default fade in/out transitions. */ val transitions: AuthUITransitions? = null, -) + + /** + * When true, the flow operates as a reauthentication flow: account creation is disabled and + * only providers already linked to the current user are shown. Set by [FirebaseAuthUI.createReauthFlow]. + */ + internal val isReauthenticationMode: Boolean = false, +) { + internal fun copy( + providers: List = this.providers, + isNewEmailAccountsAllowed: Boolean = this.isNewEmailAccountsAllowed, + isReauthenticationMode: Boolean = this.isReauthenticationMode, + ): AuthUIConfiguration = AuthUIConfiguration( + context = this.context, + providers = providers, + theme = this.theme, + locale = this.locale, + stringProvider = this.stringProvider, + isCredentialManagerEnabled = this.isCredentialManagerEnabled, + isMfaEnabled = this.isMfaEnabled, + isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled, + tosUrl = this.tosUrl, + privacyPolicyUrl = this.privacyPolicyUrl, + logo = this.logo, + passwordResetActionCodeSettings = this.passwordResetActionCodeSettings, + isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, + isDisplayNameRequired = this.isDisplayNameRequired, + isProviderChoiceAlwaysShown = this.isProviderChoiceAlwaysShown, + transitions = this.transitions, + isReauthenticationMode = isReauthenticationMode, + ) +} diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt index ffbe5242a..966baaf96 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt @@ -1045,3 +1045,17 @@ abstract class AuthProvider(open val providerId: String, open val providerName: } } } + +/** + * Filters this provider list to only those whose [AuthProvider.providerId] matches a provider + * already linked to [user], as reported by [com.google.firebase.auth.FirebaseUser.providerData]. + * + * Used by [com.firebase.ui.auth.FirebaseAuthUI.createReauthFlow] to restrict the reauthentication + * UI to methods the user has actually registered. + */ +internal fun List.filterToLinkedProviders( + user: com.google.firebase.auth.FirebaseUser, +): List { + val linkedIds = user.providerData.map { it.providerId }.toSet() + return filter { it.providerId in linkedIds } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 670b451bc..30ccd4d5d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -45,6 +45,23 @@ import kotlinx.coroutines.tasks.await private const val TAG = "EmailAuthProvider" +/** + * Signs in or reauthenticates with [credential] depending on [AuthUIConfiguration.isReauthenticationMode]. + * + * - Normal mode: [com.google.firebase.auth.FirebaseAuth.signInWithCredential], returns [AuthResult]. + * - Reauth mode: [com.google.firebase.auth.FirebaseUser.reauthenticate] (Task), returns null. + * Callers must reconstruct auth state from [com.google.firebase.auth.FirebaseAuth.currentUser]. + */ +internal suspend fun FirebaseAuthUI.signInOrReauth( + credential: AuthCredential, + config: AuthUIConfiguration, +): AuthResult? = if (config.isReauthenticationMode) { + auth.currentUser!!.reauthenticate(credential).await() + null +} else { + auth.signInWithCredential(credential).await() +} + /** * Creates an email/password account or links the credential to an anonymous user. * @@ -322,6 +339,14 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in...")) + // In reauth mode build a credential and go through signInAndLinkWithCredential so + // signInOrReauth routes to FirebaseUser.reauthenticate() instead of signInWithCredential(). + if (config.isReauthenticationMode) { + return signInAndLinkWithCredential( + config = config, + credential = EmailAuthProvider.getCredential(email, password), + ) + } return if (canUpgradeAnonymous(config, auth)) { // Anonymous upgrade flow: validate credential in scratch auth val credentialToValidate = EmailAuthProvider.getCredential(email, password) @@ -548,17 +573,22 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in user...")) - return if (canUpgradeAnonymous(config, auth)) { + val result = if (canUpgradeAnonymous(config, auth)) { auth.currentUser?.linkWithCredential(credential)?.await() } else { - auth.signInWithCredential(credential).await() - }.also { result -> - // Merge profile information from the provider - result?.user?.let { - mergeProfile(auth, displayName, photoUrl) + signInOrReauth(credential, config) + } + // signInOrReauth returns null in reauth mode (Task has no AuthResult). + // Reconstruct success state from the now-reauthenticated current user. + if (result == null && config.isReauthenticationMode) { + auth.currentUser?.let { + updateAuthState(AuthState.Success(result = null, user = it, isNewUser = false)) } - updateAuthStateWithResult(result) + return null } + result?.user?.let { mergeProfile(auth, displayName, photoUrl) } + updateAuthStateWithResult(result) + return result } catch (e: FirebaseAuthMultiFactorException) { // MFA required - extract resolver and update state val resolver = e.resolver diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt index 10be33cb9..674f02d33 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -71,7 +71,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( onResult = {}, ) - DisposableEffect(Unit) { + DisposableEffect(config) { loginManager.registerCallback( callbackManager, object : FacebookCallback { diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt index f8cbbdddf..496e1cd44 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -59,7 +59,7 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( provider: AuthProvider.Google, ): () -> Unit { val coroutineScope = rememberCoroutineScope() - return remember(this) { + return remember(this, config) { { coroutineScope.launch { try { diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt index e2d141400..0b23420fc 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -61,7 +61,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( "Ensure FirebaseAuthScreen is used within an Activity." ) - return remember(this, provider.providerId) { + return remember(this, provider.providerId, config) { { coroutineScope.launch { try { @@ -165,11 +165,14 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( return } - // Determine if we should upgrade anonymous user or do normal sign-in - val authResult = if (canUpgradeAnonymous(config, auth)) { - auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await() - } else { - auth.startActivityForSignInWithProvider(activity, oauthProvider).await() + // Determine if we should upgrade anonymous user, reauthenticate, or do normal sign-in + val authResult = when { + canUpgradeAnonymous(config, auth) -> + auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await() + config.isReauthenticationMode -> + auth.currentUser!!.startActivityForReauthenticateWithProvider(activity, oauthProvider).await() + else -> + auth.startActivityForSignInWithProvider(activity, oauthProvider).await() } // Extract OAuth credential and complete sign-in diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index a919223ad..284351b0f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -37,6 +38,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -63,6 +65,7 @@ import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.MfaConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.filterToLinkedProviders import com.firebase.ui.auth.configuration.auth_provider.rememberAnonymousSignInHandler import com.firebase.ui.auth.configuration.auth_provider.rememberGoogleSignInHandler import com.firebase.ui.auth.configuration.auth_provider.rememberOAuthSignInHandler @@ -101,6 +104,7 @@ import kotlinx.coroutines.launch * * @since 10.0.0 */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FirebaseAuthScreen( configuration: AuthUIConfiguration, @@ -134,6 +138,8 @@ fun FirebaseAuthScreen( val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } + val pendingReauthConfig = remember { mutableStateOf(null) } + val pendingReauthOperation = remember { mutableStateOf<(suspend (android.content.Context) -> Unit)?>(null) } val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } val lastSignInPreference = remember { mutableStateOf(null) } @@ -147,99 +153,24 @@ fun FirebaseAuthScreen( lastSignInPreference.value = SignInPreferenceManager.getLastSignIn(context) } - val anonymousProvider = - configuration.providers.filterIsInstance().firstOrNull() - val googleProvider = - configuration.providers.filterIsInstance().firstOrNull() val emailProvider = configuration.providers.filterIsInstance().firstOrNull() - val facebookProvider = - configuration.providers.filterIsInstance().firstOrNull() - val appleProvider = configuration.providers.filterIsInstance().firstOrNull() - val githubProvider = - configuration.providers.filterIsInstance().firstOrNull() - val microsoftProvider = - configuration.providers.filterIsInstance().firstOrNull() - val yahooProvider = configuration.providers.filterIsInstance().firstOrNull() - val twitterProvider = - configuration.providers.filterIsInstance().firstOrNull() - val genericOAuthProviders = - configuration.providers.filterIsInstance() - val logoAsset = configuration.logo - - val onSignInAnonymously = anonymousProvider?.let { - authUI.rememberAnonymousSignInHandler() - } - - val onSignInWithGoogle = googleProvider?.let { - authUI.rememberGoogleSignInHandler( - context = context, - config = configuration, - provider = it - ) - } - - val onSignInWithFacebook = facebookProvider?.let { - authUI.rememberSignInWithFacebookLauncher( - context = context, - config = configuration, - provider = it - ) - } - - val onSignInWithApple = appleProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithGithub = githubProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithMicrosoft = microsoftProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithYahoo = yahooProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithTwitter = twitterProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val genericOAuthHandlers = genericOAuthProviders.associateWith { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } + val onProviderSelected = authUI.rememberOnProviderSelected( + context = context, + activity = activity, + config = configuration, + onNavigate = { route -> navController.navigate(route.route) }, + onUnknownProvider = { provider -> + onSignInFailure( + AuthException.UnknownException( + message = "Provider ${provider.providerId} is not supported in FirebaseAuthScreen", + cause = IllegalArgumentException( + "Provider ${provider.providerId} is not supported in FirebaseAuthScreen" + ) + ) + ) + }, + ) CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, @@ -277,46 +208,7 @@ fun FirebaseAuthScreen( privacyPolicyUrl = configuration.privacyPolicyUrl, lastSignInPreference = lastSignInPreference.value, customLayout = customMethodPickerLayout, - onProviderSelected = { provider -> - when (provider) { - is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() - - is AuthProvider.Email -> { - navController.navigate(AuthRoute.Email.route) - } - - is AuthProvider.Phone -> { - navController.navigate(AuthRoute.Phone.route) - } - - is AuthProvider.Google -> onSignInWithGoogle?.invoke() - - is AuthProvider.Facebook -> onSignInWithFacebook?.invoke() - - is AuthProvider.Apple -> onSignInWithApple?.invoke() - - is AuthProvider.Github -> onSignInWithGithub?.invoke() - - is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke() - - is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke() - - is AuthProvider.Twitter -> onSignInWithTwitter?.invoke() - - is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke() - - else -> { - onSignInFailure( - AuthException.UnknownException( - message = "Provider ${provider.providerId} is not supported in FirebaseAuthScreen", - cause = IllegalArgumentException( - "Provider ${provider.providerId} is not supported in FirebaseAuthScreen" - ) - ) - ) - } - } - } + onProviderSelected = onProviderSelected, ) } } @@ -545,6 +437,17 @@ fun FirebaseAuthScreen( pendingResolver.value = null pendingLinkingCredential.value = null + // If reauth just completed, execute the pending retry and skip normal success handling + pendingReauthOperation.value?.let { retry -> + pendingReauthOperation.value = null + pendingReauthConfig.value = null + // Lock the state to Loading before launching the retry so no + // intermediate Success emission can navigate to AuthRoute.Success. + authUI.updateAuthState(AuthState.Loading()) + coroutineScope.launch { retry(context) } + return@LaunchedEffect + } + state.result?.let { result -> if (state.user.uid != lastSuccessfulUserId.value) { onSignInSuccess(result) @@ -566,6 +469,27 @@ fun FirebaseAuthScreen( } } + is AuthState.ReauthenticationRequired -> { + pendingReauthOperation.value = state.retryOperation + val linked = configuration.providers.filterToLinkedProviders(state.user) + if (linked.isEmpty()) { + authUI.updateAuthState( + AuthState.Error( + AuthException.UnknownException( + "No configured providers are linked to the current user" + ) + ) + ) + return@LaunchedEffect + } + pendingReauthConfig.value = configuration.copy( + providers = linked, + isNewEmailAccountsAllowed = false, + isReauthenticationMode = true, + ) + // ModalBottomSheet appears automatically when pendingReauthConfig is set. + } + is AuthState.RequiresEmailVerification, is AuthState.RequiresProfileCompletion, -> { @@ -589,6 +513,8 @@ fun FirebaseAuthScreen( } is AuthState.Cancelled -> { + pendingReauthOperation.value = null + pendingReauthConfig.value = null pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -602,6 +528,8 @@ fun FirebaseAuthScreen( } is AuthState.Idle -> { + pendingReauthOperation.value = null + pendingReauthConfig.value = null pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -679,6 +607,34 @@ fun FirebaseAuthScreen( if (loadingState != null) { LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading) } + + // Reauth bottom sheet — appears over the current screen without navigating away. + val reauthConfig = pendingReauthConfig.value + if (reauthConfig != null) { + ModalBottomSheet( + onDismissRequest = { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + authUI.updateAuthState(AuthState.Idle) + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + ReauthSheetContent( + authUI = authUI, + reauthConfig = reauthConfig, + activity = activity, + context = context, + emailContent = emailContent, + phoneContent = phoneContent, + customMethodPickerLayout = customMethodPickerLayout, + onDismiss = { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + authUI.updateAuthState(AuthState.Idle) + }, + ) + } + } } } } @@ -896,3 +852,121 @@ private fun LoadingDialog(message: String) { } ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReauthSheetContent( + authUI: FirebaseAuthUI, + reauthConfig: AuthUIConfiguration, + activity: android.app.Activity?, + context: android.content.Context, + emailContent: (@Composable (EmailAuthContentState) -> Unit)?, + phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, + customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)?, + onDismiss: () -> Unit, +) { + val sheetNavController = rememberNavController() + val startRoute = remember(reauthConfig) { getStartRoute(reauthConfig) } + val skipsMethodPicker = startRoute != AuthRoute.MethodPicker + val onProviderSelected = authUI.rememberOnProviderSelected( + context = context, + activity = activity, + config = reauthConfig, + onNavigate = { route -> sheetNavController.navigate(route.route) }, + ) + + NavHost( + navController = sheetNavController, + startDestination = startRoute.route, + enterTransition = { fadeIn(animationSpec = tween(700)) }, + exitTransition = { fadeOut(animationSpec = tween(700)) }, + popEnterTransition = { fadeIn(animationSpec = tween(700)) }, + popExitTransition = { fadeOut(animationSpec = tween(700)) }, + ) { + composable(AuthRoute.MethodPicker.route) { + Scaffold { innerPadding -> + AuthMethodPicker( + modifier = Modifier.padding(innerPadding), + providers = reauthConfig.providers, + customLayout = customMethodPickerLayout, + onProviderSelected = onProviderSelected, + ) + } + } + + composable(AuthRoute.Email.route) { + com.firebase.ui.auth.ui.screens.email.EmailAuthScreen( + context = context, + configuration = reauthConfig, + authUI = authUI, + content = emailContent, + onSuccess = {}, + onError = {}, + onCancel = { + if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss() + } + ) + } + + composable(AuthRoute.Phone.route) { + com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen( + context = context, + configuration = reauthConfig, + authUI = authUI, + content = phoneContent, + onSuccess = {}, + onError = {}, + onCancel = { + if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss() + } + ) + } + } +} + +@Composable +private fun FirebaseAuthUI.rememberOnProviderSelected( + context: android.content.Context, + activity: android.app.Activity?, + config: AuthUIConfiguration, + onNavigate: (AuthRoute) -> Unit, + onUnknownProvider: ((AuthProvider) -> Unit)? = null, +): (AuthProvider) -> Unit { + val anonymousProvider = config.providers.filterIsInstance().firstOrNull() + val googleProvider = config.providers.filterIsInstance().firstOrNull() + val facebookProvider = config.providers.filterIsInstance().firstOrNull() + val appleProvider = config.providers.filterIsInstance().firstOrNull() + val githubProvider = config.providers.filterIsInstance().firstOrNull() + val microsoftProvider = config.providers.filterIsInstance().firstOrNull() + val yahooProvider = config.providers.filterIsInstance().firstOrNull() + val twitterProvider = config.providers.filterIsInstance().firstOrNull() + val genericOAuthProviders = config.providers.filterIsInstance() + + val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler() } + val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it) } + val onSignInWithFacebook = facebookProvider?.let { rememberSignInWithFacebookLauncher(context, config, it) } + val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val genericOAuthHandlers = genericOAuthProviders.associateWith { + rememberOAuthSignInHandler(context, activity, config, it) + } + + return { provider -> + when (provider) { + is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() + is AuthProvider.Email -> onNavigate(AuthRoute.Email) + is AuthProvider.Phone -> onNavigate(AuthRoute.Phone) + is AuthProvider.Google -> onSignInWithGoogle?.invoke() + is AuthProvider.Facebook -> onSignInWithFacebook?.invoke() + is AuthProvider.Apple -> onSignInWithApple?.invoke() + is AuthProvider.Github -> onSignInWithGithub?.invoke() + is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke() + is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke() + is AuthProvider.Twitter -> onSignInWithTwitter?.invoke() + is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke() + else -> onUnknownProvider?.invoke(provider) + } + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt index d2558a8a8..6422557b7 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -18,12 +18,16 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions +import android.content.Context +import com.google.android.gms.tasks.TaskCompletionSource import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver import com.google.firebase.auth.UserInfo +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take @@ -37,6 +41,7 @@ import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Mock import org.mockito.Mockito.mock +import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations @@ -381,4 +386,81 @@ class FirebaseAuthUIAuthStateTest { assertThat(state.user).isEqualTo(mockFirebaseUser) assertThat(state.missingFields).containsExactly("displayName", "photoUrl") } + + // ============================================================================================= + // delete() ReauthenticationRequired state Tests + // ============================================================================================= + + @Test + fun `delete() emits ReauthenticationRequired state when recent login required`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + val tcs = TaskCompletionSource() + tcs.setException( + FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + ) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(tcs.task) + + val context = ApplicationProvider.getApplicationContext() + + try { + authUI.delete(context) + } catch (_: AuthException.InvalidCredentialsException) { + // expected — existing contract preserved + } + + assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.ReauthenticationRequired::class.java) + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + assertThat(state.user).isEqualTo(mockUser) + } + + @Test + fun `delete() attaches retryOperation to ReauthenticationRequired state`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + val tcs = TaskCompletionSource() + tcs.setException( + FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + ) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(tcs.task) + + val context = ApplicationProvider.getApplicationContext() + try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {} + + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + // Fails until delete() passes retryOperation into the state + assertThat(state.retryOperation).isNotNull() + } + + @Test + fun `delete() retryOperation re-invokes delete on execution`() = runTest { + val mockUser = mock(FirebaseUser::class.java) + + val failTcs = TaskCompletionSource() + failTcs.setException( + FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", "Recent login required" + ) + ) + val successTcs = TaskCompletionSource() + successTcs.setResult(null) + + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()) + .thenReturn(failTcs.task) + .thenReturn(successTcs.task) + + val context = ApplicationProvider.getApplicationContext() + try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {} + + val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + // Fails until delete() passes retryOperation into the state + state.retryOperation!!(context) + + verify(mockUser, times(2)).delete() + } } \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt index 05f61c538..7d5a75e3d 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt @@ -19,6 +19,7 @@ import android.content.Intent import android.net.Uri import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.authUIConfiguration import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp @@ -679,6 +680,96 @@ class FirebaseAuthUITest { } } + // ============================================================================================= + // createReauthFlow Tests + // ============================================================================================= + + private fun baseConfig(vararg providers: AuthProvider): com.firebase.ui.auth.configuration.AuthUIConfiguration { + val context = ApplicationProvider.getApplicationContext() + return authUIConfiguration { + this.context = context + providers.forEach { p -> this.providers { provider(p) } } + } + } + + @Test + fun `createReauthFlow throws UserNotFoundException when no user is signed in`() { + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(null) + val authUI = FirebaseAuthUI.create(defaultApp, mockAuth) + + try { + authUI.createReauthFlow(baseConfig(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()))) + assertThat(false).isTrue() + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.message).contains("No user is currently signed in") + } + } + + @Test + fun `createReauthFlow throws when no configured provider is linked to the current user`() { + val mockUser = mock(FirebaseUser::class.java) + val info = mock(UserInfo::class.java) + `when`(info.providerId).thenReturn("password") + `when`(mockUser.providerData).thenReturn(listOf(info)) + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(mockUser) + val authUI = FirebaseAuthUI.create(defaultApp, mockAuth) + + // Config only has Google; user only has email linked + val config = baseConfig(AuthProvider.Google(scopes = emptyList(), serverClientId = "id")) + + try { + authUI.createReauthFlow(config) + assertThat(false).isTrue() + } catch (e: IllegalStateException) { + assertThat(e.message).contains("No configured providers are linked") + } + } + + @Test + fun `createReauthFlow returns a controller whose config has only linked providers`() { + val mockUser = mock(FirebaseUser::class.java) + val emailInfo = mock(UserInfo::class.java) + val googleInfo = mock(UserInfo::class.java) + `when`(emailInfo.providerId).thenReturn("password") + `when`(googleInfo.providerId).thenReturn("google.com") + `when`(mockUser.providerData).thenReturn(listOf(emailInfo, googleInfo)) + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(mockUser) + val authUI = FirebaseAuthUI.create(defaultApp, mockAuth) + + // Three providers configured; only Email and Google are linked — Phone should be stripped + val config = baseConfig( + AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()), + AuthProvider.Google(scopes = emptyList(), serverClientId = "id"), + AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null), + ) + + val controller = authUI.createReauthFlow(config) + + assertThat(controller.configuration.providers.map { it.providerId }) + .containsExactly("password", "google.com") + } + + @Test + fun `createReauthFlow resulting config disables new account creation and enables reauth mode`() { + val mockUser = mock(FirebaseUser::class.java) + val info = mock(UserInfo::class.java) + `when`(info.providerId).thenReturn("password") + `when`(mockUser.providerData).thenReturn(listOf(info)) + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(mockUser) + val authUI = FirebaseAuthUI.create(defaultApp, mockAuth) + + val config = baseConfig(AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList())) + val controller = authUI.createReauthFlow(config) + + assertThat(controller.configuration.isNewEmailAccountsAllowed).isFalse() + assertThat(controller.configuration.isReauthenticationMode).isTrue() + } + + @Test fun `canHandleIntent returns true when auth validates email link`() { val emailLink = "https://example.com/__/auth/action?mode=signIn" diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt index 4afcfa84b..2a439a1a2 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt @@ -465,7 +465,8 @@ class AuthUIConfigurationTest { "isNewEmailAccountsAllowed", "isDisplayNameRequired", "isProviderChoiceAlwaysShown", - "transitions" + "transitions", + "isReauthenticationMode" ) val actualProperties = allProperties.map { it.name }.toSet() diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt index 126600e5d..747e03ff2 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt @@ -4,10 +4,14 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.UserInfo import com.google.firebase.auth.actionCodeSettings import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -405,6 +409,77 @@ class AuthProviderTest { } } + // ============================================================================================= + // filterToLinkedProviders Tests + // ============================================================================================= + + private fun mockUser(vararg providerIds: String): FirebaseUser { + val user = mock(FirebaseUser::class.java) + val infos = providerIds.map { id -> + mock(UserInfo::class.java).also { `when`(it.providerId).thenReturn(id) } + } + `when`(user.providerData).thenReturn(infos) + return user + } + + @Test + fun `filterToLinkedProviders keeps only providers matching user providerData`() { + val user = mockUser("password", "google.com") + val providers = listOf( + AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()), + AuthProvider.Google(scopes = emptyList(), serverClientId = "id"), + AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null), + ) + + val result = providers.filterToLinkedProviders(user) + + assertThat(result.map { it.providerId }).containsExactly("password", "google.com") + } + + @Test + fun `filterToLinkedProviders returns empty list when no providers match`() { + val user = mockUser("password") + val providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = "id"), + ) + + val result = providers.filterToLinkedProviders(user) + + assertThat(result).isEmpty() + } + + @Test + fun `filterToLinkedProviders returns all providers when all are linked`() { + val user = mockUser("password", "phone") + val email = AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()) + val phone = AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null) + + val result = listOf(email, phone).filterToLinkedProviders(user) + + assertThat(result).containsExactly(email, phone) + } + + @Test + fun `filterToLinkedProviders on empty list returns empty list`() { + val user = mockUser("password") + + val result = emptyList().filterToLinkedProviders(user) + + assertThat(result).isEmpty() + } + + @Test + fun `filterToLinkedProviders ignores providers linked to user but absent from list`() { + val user = mockUser("password", "google.com", "facebook.com") + val providers = listOf( + AuthProvider.Email(emailLinkActionCodeSettings = null, passwordValidationRules = emptyList()), + ) + + val result = providers.filterToLinkedProviders(user) + + assertThat(result.map { it.providerId }).containsExactly("password") + } + @Test fun `generic oauth provider with blank button label should throw`() { val provider = AuthProvider.GenericOAuth(