From 82c68c015c8ba84c828ac67a68d47b6fb2da1340 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 25 Aug 2026 01:38:35 +0100 Subject: [PATCH] wip(auth): reshape reauthContent into a ReauthContentState content slot --- .../firebaseui/android/demo/MainActivity.kt | 2 +- .../demo/auth/HighLevelApiDemoActivity.kt | 101 +-- auth/README.md | 39 +- .../java/com/firebase/ui/auth/AuthState.kt | 14 +- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 2 +- .../EmailAuthProvider+FirebaseAuthUI.kt | 24 +- .../OAuthProvider+FirebaseAuthUI.kt | 16 +- .../ui/auth/ui/components/AuthTextField.kt | 3 + .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 270 +++++-- .../ui/auth/ui/screens/ReauthContentState.kt | 90 +++ .../auth/ui/screens/email/EmailAuthScreen.kt | 45 +- .../auth/ui/screens/email/ResetPasswordUI.kt | 2 + .../ui/screens/email/SignInEmailLinkUI.kt | 2 + .../ui/auth/ui/screens/email/SignInUI.kt | 15 +- auth/src/main/res/values/strings.xml | 5 + .../EmailAuthProviderFirebaseAuthUITest.kt | 118 +++ ...irebaseAuthScreenReauthContentStateTest.kt | 692 ++++++++++++++++++ .../FirebaseAuthScreenReauthIdleResetTest.kt | 2 +- .../EmailAuthScreenReauthEmailLockTest.kt | 349 +++++++++ .../ui/auth/ui/screens/email/SignInUITest.kt | 290 +++++++- .../ui/auth/ui/screens/ReauthFlowTest.kt | 171 ++++- 21 files changed, 2075 insertions(+), 177 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt diff --git a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt index 131d11cb6..276e6de43 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -36,7 +36,7 @@ import com.google.firebase.firestore.FirebaseFirestore class MainActivity : ComponentActivity() { companion object { - internal const val USE_AUTH_EMULATOR = true + internal const val USE_AUTH_EMULATOR = false private const val AUTH_EMULATOR_HOST = "10.0.2.2" private const val AUTH_EMULATOR_PORT = 9099 diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt index cfa10b93b..6ad3e1abe 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt @@ -11,8 +11,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -32,7 +35,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope @@ -58,6 +60,7 @@ import com.firebase.ui.auth.configuration.theme.AuthUIAsset import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +import com.firebase.ui.auth.ui.screens.ReauthContentState import com.firebase.ui.auth.util.EmailLinkConstants import com.firebase.ui.auth.util.displayIdentifier import com.firebase.ui.auth.util.getDisplayEmail @@ -229,13 +232,7 @@ class HighLevelApiDemoActivity : ComponentActivity() { onSignInCancelled = { Log.d("HighLevelApiDemoActivity", "Authentication cancelled") }, - reauthContent = { state, onDismiss -> - ReauthDialog( - authUI = authUI, - state = state, - onDismiss = onDismiss, - ) - }, + reauthContent = { state -> ReauthDialog(state = state) }, authenticatedContent = { state, uiContext -> AppAuthenticatedContent(state, uiContext) } @@ -414,20 +411,15 @@ private fun AppAuthenticatedContent( } } +/** + * Custom reauth UI. The slot only chooses a provider — the library owns every credential path, and + * for email/phone it presents its own sub-flow, which replaces this dialog while it is up. Keep the + * slot stateless for that reason. + */ @Composable -private fun ReauthDialog( - authUI: FirebaseAuthUI, - state: AuthState.ReauthenticationRequired, - onDismiss: () -> Unit, -) { - var password by remember { mutableStateOf("") } - var isVerifying by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - val coroutineScope = rememberCoroutineScope() - val email = state.user.email.orEmpty() - +private fun ReauthDialog(state: ReauthContentState) { AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = state.onDismiss, containerColor = MaterialTheme.colorScheme.surfaceVariant, title = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { @@ -442,60 +434,43 @@ private fun ReauthDialog( } }, text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( - "Signing in as $email", + "Signed in as ${state.user.displayIdentifier()}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) - com.firebase.ui.auth.ui.components.AuthTextField( - value = password, - onValueChange = { - password = it - errorMessage = null - }, - label = { Text("Password") }, - isSecureTextField = true, - isError = errorMessage != null, - errorMessage = errorMessage, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - confirmButton = { - Button( - onClick = { - coroutineScope.launch { - isVerifying = true - errorMessage = null - try { - val result = authUI.auth - .signInWithEmailAndPassword(email, password) - .await() - result.user?.let { user -> - authUI.updateAuthState(AuthState.Success(result, user)) - } - } catch (e: Exception) { - errorMessage = "Incorrect password. Please try again." - } finally { - isVerifying = false - } - } - }, - enabled = password.isNotBlank() && !isVerifying, - ) { - if (isVerifying) { + state.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + if (state.isLoading) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, ) - } else { - Text("Verify") + } + state.providers.forEach { provider -> + Button( + onClick = { state.onProviderSelected(provider) }, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Continue with ${provider.providerName}") + } } } }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = state.onDismiss) { Text("Cancel") } + }, ) } diff --git a/auth/README.md b/auth/README.md index e2e1daab2..c0da55678 100644 --- a/auth/README.md +++ b/auth/README.md @@ -827,7 +827,7 @@ FirebaseAuthScreen( phoneContent = { state -> /* ... */ }, mfaEnrollmentContent = { state -> /* ... */ }, mfaChallengeContent = { state -> /* ... */ }, - reauthContent = { state, onDismiss -> /* ... */ }, + reauthContent = { state -> /* ... */ }, ) { authState, uiContext -> // authenticated content } @@ -992,36 +992,37 @@ mfaChallengeContent = { state -> #### Reauthentication (`reauthContent`) -Replaces the default reauthentication bottom sheet shown when a sensitive operation requires the user to re-verify their identity. Receives the `AuthState.ReauthenticationRequired` state (including an optional `reason` string and the signed-in `user`) and an `onDismiss` callback that resets auth state to `Idle`. +Replaces the default reauthentication bottom sheet shown when a sensitive operation requires the user to re-verify their identity. The `ReauthContentState` carries `user`, `reason`, the `providers` already filtered to those linked to that user, and callbacks to select a provider or dismiss. + +The library owns the credential exchange, so the slot only renders a provider chooser. Selecting a federated provider reauthenticates directly; selecting `AuthProvider.Email` or `AuthProvider.Phone` hands off to the library's own email/phone sub-flow, which honours your `emailContent` / `phoneContent` slots and replaces this slot while it is active. Password and OTP entry therefore never appear here. ```kotlin -reauthContent = { state, onDismiss -> +reauthContent = { state -> AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Verify your identity") }, + onDismissRequest = state.onDismiss, + title = { Text(state.reason ?: "Verify your identity") }, text = { - Column { - state.reason?.let { Text(it) } - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - ) + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (state.isLoading) CircularProgressIndicator() + state.providers.forEach { provider -> + Button( + onClick = { state.onProviderSelected(provider) }, + enabled = !state.isLoading, + ) { Text("Continue with ${provider.providerName}") } + } } }, - confirmButton = { - Button(onClick = { - // Re-authenticate then update auth state on success - }) { Text("Confirm") } - }, + confirmButton = {}, dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = state.onDismiss) { Text("Cancel") } }, ) } ``` +While this slot is shown the library suppresses its own loading and error dialogs, so render `state.isLoading` and `state.error` yourself. On success the library resumes the operation that required reauthentication — there is nothing to retry. `state.onDismiss` abandons it; backing out of a single provider attempt returns to the slot with the operation still pending. + For most cases, use [`withReauth`](#reauthentication) instead — it handles the full reauth cycle automatically and only shows the default bottom sheet. Use `reauthContent` when you need a custom design for the reauth UI. ### Reauthentication 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 410107cdd..353ea2b07 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -76,11 +76,14 @@ abstract class AuthState private constructor() { * @property result The [AuthResult] containing the authenticated user, may be null if not available * @property user The authenticated [FirebaseUser] * @property isNewUser Whether this is a newly created user account + * @property reauthenticatedUid The uid this success re-proved, or `null` if it is not a + * reauthentication. Settable only from within the library. */ - class Success( + class Success internal constructor( val result: AuthResult?, val user: FirebaseUser, - val isNewUser: Boolean = false + val isNewUser: Boolean = false, + val reauthenticatedUid: String? = null ) : AuthState() { override val isNotification: Boolean = false override fun equals(other: Any?): Boolean { @@ -88,18 +91,21 @@ abstract class AuthState private constructor() { if (other !is Success) return false return result == other.result && user == other.user && - isNewUser == other.isNewUser + isNewUser == other.isNewUser && + reauthenticatedUid == other.reauthenticatedUid } override fun hashCode(): Int { var result1 = result?.hashCode() ?: 0 result1 = 31 * result1 + user.hashCode() result1 = 31 * result1 + isNewUser.hashCode() + result1 = 31 * result1 + (reauthenticatedUid?.hashCode() ?: 0) return result1 } override fun toString(): String = - "AuthState.Success(result=$result, user=$user, isNewUser=$isNewUser)" + "AuthState.Success(result=$result, user=$user, isNewUser=$isNewUser, " + + "reauthenticatedUid=$reauthenticatedUid)" } /** 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 972b1786b..02e4be4e2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -744,4 +744,4 @@ class FirebaseAuthUI private constructor( const val UNCONFIGURED_CONFIG_VALUE: String = "CHANGE-ME" } -} \ No newline at end of file +} 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 1e480eda9..8f76ff0bb 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 @@ -153,8 +153,14 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( if (shouldLinkCredential) credentialProvider.getCredential(email, password) else null try { - // Check if new accounts are allowed (only for non-upgrade/non-linking flows) - if (!shouldLinkCredential && !provider.isNewAccountsAllowed) { + if (config.isReauthenticationMode) { + throw AuthException.UnknownException( + message = context.getString(R.string.fui_error_reauth_sign_up_not_allowed) + ) + } + if (!shouldLinkCredential && + (!provider.isNewAccountsAllowed || !config.isNewEmailAccountsAllowed) + ) { throw AuthException.UserNotFoundException( message = context.getString(R.string.fui_error_email_does_not_exist) ) @@ -654,9 +660,17 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( // 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)) - } + val reauthenticatedUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in for reauthentication" + ) + updateAuthState( + AuthState.Success( + result = null, + user = reauthenticatedUser, + reauthenticatedUid = reauthenticatedUser.uid, + ) + ) return null } result?.user?.let { mergeProfile(auth, displayName, photoUrl) } 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 e85c4fea4..69e7bd135 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 @@ -202,7 +202,21 @@ internal suspend fun FirebaseAuthUI.signInWithProvider( android.util.Log.w("OAuthProvider", "Failed to save sign-in preference", e) } - updateAuthStateWithResult(authResult) + if (config.isReauthenticationMode) { + val reauthenticatedUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in for reauthentication" + ) + updateAuthState( + AuthState.Success( + result = authResult, + user = reauthenticatedUser, + reauthenticatedUid = reauthenticatedUser.uid, + ) + ) + } else { + updateAuthStateWithResult(authResult) + } } else { throw AuthException.UnknownException( message = "OAuth sign-in did not return a valid credential" diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt index 253a6e260..b404e62a6 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt @@ -86,6 +86,7 @@ import com.firebase.ui.auth.configuration.validators.PasswordValidator * @param visualTransformation Visual transformation for the input (e.g., password). * @param leadingIcon An optional icon to display at the start of the field. * @param trailingIcon An optional icon to display at the start of the field. + * @param readOnly If the value cannot be edited by the user. */ @Composable fun AuthTextField( @@ -103,6 +104,7 @@ fun AuthTextField( visualTransformation: VisualTransformation = VisualTransformation.None, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, + readOnly: Boolean = false, ) { var passwordVisible by remember { mutableStateOf(false) } @@ -133,6 +135,7 @@ fun AuthTextField( label = label, singleLine = true, enabled = enabled, + readOnly = readOnly, isError = isError ?: validator?.hasError ?: false, supportingText = { if (validator?.hasError ?: false) { 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 14e0965f7..f4cd78e91 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 @@ -44,6 +44,7 @@ import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -64,6 +65,7 @@ import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.BuildConfig import com.firebase.ui.auth.FirebaseAuthActivity import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.R import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.MfaConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider @@ -114,6 +116,8 @@ import kotlinx.coroutines.tasks.await * @param customMethodPickerTermsConfiguration Optional custom Terms of Service/Privacy Policy * footer for the *default* method-picker layout. Ignored when [customMethodPickerLayout] is * provided, since that slot takes over the whole screen. + * @param reauthContent Optional slot that replaces the default reauthentication bottom sheet, + * receiving a [ReauthContentState]. The library owns the credential exchange. * * @since 10.0.0 */ @@ -134,7 +138,7 @@ fun FirebaseAuthScreen( phoneContent: (@Composable (PhoneAuthContentState) -> Unit)? = null, mfaEnrollmentContent: (@Composable (MfaEnrollmentContentState) -> Unit)? = null, mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)? = null, - reauthContent: (@Composable (state: AuthState.ReauthenticationRequired, onDismiss: () -> Unit) -> Unit)? = null, + reauthContent: (@Composable (ReauthContentState) -> Unit)? = null, authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null, ) { // Set FirebaseUI version @@ -156,8 +160,13 @@ fun FirebaseAuthScreen( val pendingReauthConfig = remember { mutableStateOf(null) } val pendingReauthState = remember { mutableStateOf(null) } val pendingReauthOperation = remember { mutableStateOf<(suspend (android.content.Context) -> Unit)?>(null) } + val reauthError = remember { mutableStateOf(null) } + val reauthSubRoute = remember { mutableStateOf(null) } val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } val prefillEmail = remember { mutableStateOf(null) } + val reauthPrefillEmail = remember(authUI, configuration.isReauthenticationMode) { + if (configuration.isReauthenticationMode) authUI.auth.currentUser?.email else null + } val lastSignInPreference = remember { mutableStateOf(null) } // Last-processed AuthState, so the Idle branch below can tell a genuine reset apart from @@ -175,7 +184,7 @@ fun FirebaseAuthScreen( val emailProvider = configuration.providers.filterIsInstance().firstOrNull() val logoAsset = configuration.logo - val onProviderSelected = authUI.rememberOnProviderSelected( + val onOuterProviderSelected = authUI.rememberOnProviderSelected( context = context, activity = activity, config = configuration, @@ -192,6 +201,11 @@ fun FirebaseAuthScreen( }, onSignInFailure = onSignInFailure, ) + val onProviderSelected: (AuthProvider) -> Unit = { provider -> + if (pendingReauthState.value == null) { + onOuterProviderSelected(provider) + } + } val continueWithProvider: (String) -> Unit = { providerId -> configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) } } @@ -255,7 +269,7 @@ fun FirebaseAuthScreen( context = context, configuration = configuration, authUI = authUI, - prefillEmail = prefillEmail.value, + prefillEmail = prefillEmail.value ?: reauthPrefillEmail, credentialForLinking = pendingLinkingCredential.value, emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value, onContinueWithProvider = continueWithProvider, @@ -469,26 +483,29 @@ fun FirebaseAuthScreen( pendingResolver.value = null pendingLinkingCredential.value = null - // If reauth just completed, execute the pending retry and skip normal success handling. - // Guarded on !previous.isNotification: a wrong-password Error masks back into - // Success while signed in, and that must not be mistaken for a completed reauth. - if (!previous.isNotification) { - pendingReauthOperation.value?.let { retry -> + val expectedReauthUid = pendingReauthState.value?.user?.uid + if (expectedReauthUid != null) { + if (state.reauthenticatedUid == expectedReauthUid) { + val retry = pendingReauthOperation.value pendingReauthOperation.value = null pendingReauthConfig.value = null pendingReauthState.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 { - try { - retry(context) - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - authUI.updateAuthState(AuthState.Error(e)) + reauthSubRoute.value = null + reauthError.value = null + if (retry != null) { + authUI.updateAuthState(AuthState.Loading()) + coroutineScope.launch { + try { + retry(context) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + authUI.updateAuthState(AuthState.Error(e)) + } } + return@LaunchedEffect } + } else { return@LaunchedEffect } } @@ -515,27 +532,31 @@ fun FirebaseAuthScreen( } is AuthState.ReauthenticationRequired -> { - pendingReauthOperation.value = state.retryOperation val linked = configuration.providers.filterToLinkedProviders(state.user) if (linked.isEmpty()) { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + pendingReauthState.value = null + reauthSubRoute.value = null + reauthError.value = null authUI.updateAuthState( AuthState.Error( AuthException.UnknownException( - "No configured providers are linked to the current user" + context.getString(R.string.fui_error_reauth_no_linked_providers) ) ) ) return@LaunchedEffect } - if (reauthContent != null) { - pendingReauthState.value = state - } else { - pendingReauthConfig.value = configuration.copy( - providers = linked, - isNewEmailAccountsAllowed = false, - isReauthenticationMode = true, - ) - } + pendingReauthOperation.value = state.retryOperation + reauthSubRoute.value = null + reauthError.value = null + pendingReauthState.value = state + pendingReauthConfig.value = configuration.copy( + providers = linked, + isNewEmailAccountsAllowed = false, + isReauthenticationMode = true, + ) } is AuthState.RequiresEmailVerification, @@ -561,9 +582,15 @@ fun FirebaseAuthScreen( } is AuthState.Cancelled -> { + if (pendingReauthState.value != null) { + authUI.updateAuthState(AuthState.Idle) + return@LaunchedEffect + } pendingReauthOperation.value = null pendingReauthConfig.value = null pendingReauthState.value = null + reauthSubRoute.value = null + reauthError.value = null pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -584,6 +611,8 @@ fun FirebaseAuthScreen( pendingReauthOperation.value = null pendingReauthConfig.value = null pendingReauthState.value = null + reauthSubRoute.value = null + reauthError.value = null pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -598,6 +627,8 @@ fun FirebaseAuthScreen( pendingReauthOperation.value = null pendingReauthConfig.value = null pendingReauthState.value = null + reauthSubRoute.value = null + reauthError.value = null pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -614,6 +645,10 @@ fun FirebaseAuthScreen( } } + val reauthSlotActive = reauthContent != null && + pendingReauthState.value != null && + reauthSubRoute.value == null + // Handle errors using top-level dialog controller val errorState = authState as? AuthState.Error if (errorState != null) { @@ -623,13 +658,21 @@ fun FirebaseAuthScreen( else -> AuthException.from(throwable, stringProvider) } + if (reauthSlotActive) { + if (exception !is AuthException.AuthCancelledException) { + reauthError.value = exception.message + } + authUI.updateAuthState(AuthState.Idle) + return@LaunchedEffect + } + dialogController.showErrorDialog( exception = exception, errorState = errorState, onRetry = { _ -> // Child screens handle their own retry logic }, - onRecover = when (exception) { + onRecover = if (pendingReauthState.value != null) null else when (exception) { is AuthException.EmailAlreadyInUseException -> { { navController.navigate(AuthRoute.Email.route) { @@ -693,45 +736,58 @@ fun FirebaseAuthScreen( dialogController.CurrentDialog() val loadingState = authState as? AuthState.Loading - if (loadingState != null) { + if (loadingState != null && !reauthSlotActive) { LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading) } - // Custom reauth UI — rendered when the caller provides reauthContent. - val pendingReauth = pendingReauthState.value - if (pendingReauth != null && reauthContent != null) { - reauthContent(pendingReauth) { + val onReauthDismiss: () -> Unit = remember(authUI) { + { pendingReauthOperation.value = null + pendingReauthConfig.value = null pendingReauthState.value = null + reauthSubRoute.value = null + reauthError.value = null authUI.updateAuthState(AuthState.Idle) } } + val onReauthAttemptStarted: () -> Unit = remember { { reauthError.value = null } } - // Default reauth bottom sheet — used when reauthContent is not provided. val reauthConfig = pendingReauthConfig.value - if (reauthConfig != null) { - ModalBottomSheet( - onDismissRequest = { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - authUI.updateAuthState(AuthState.Idle) - }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - ) { - ReauthSheetContent( + val pendingReauth = pendingReauthState.value + if (reauthConfig != null && pendingReauth != null) { + if (reauthContent != null) { + CustomReauthContent( authUI = authUI, reauthConfig = reauthConfig, + reauthState = pendingReauth, activity = activity, context = context, emailContent = emailContent, phoneContent = phoneContent, - customMethodPickerLayout = customMethodPickerLayout, - onDismiss = { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - authUI.updateAuthState(AuthState.Idle) - }, + isLoading = loadingState != null, + error = reauthError.value, + activeSubRoute = reauthSubRoute, + onAttemptStarted = onReauthAttemptStarted, + onDismiss = onReauthDismiss, + content = reauthContent, ) + } else { + ModalBottomSheet( + onDismissRequest = onReauthDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + ReauthSheetContent( + authUI = authUI, + reauthConfig = reauthConfig, + activity = activity, + context = context, + prefillEmail = pendingReauth.user.email, + emailContent = emailContent, + phoneContent = phoneContent, + customMethodPickerLayout = customMethodPickerLayout, + onDismiss = onReauthDismiss, + ) + } } } } @@ -958,6 +1014,7 @@ private fun ReauthSheetContent( reauthConfig: AuthUIConfiguration, activity: android.app.Activity?, context: android.content.Context, + prefillEmail: String?, emailContent: (@Composable (EmailAuthContentState) -> Unit)?, phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)?, @@ -1002,6 +1059,7 @@ private fun ReauthSheetContent( context = context, configuration = reauthConfig, authUI = authUI, + prefillEmail = prefillEmail, content = emailContent, onSuccess = {}, onError = {}, @@ -1027,6 +1085,102 @@ private fun ReauthSheetContent( } } +/** + * Custom reauth UI — renders the caller's [content] slot, and *replaces* it with the library's own + * email/phone sub-flow while the user is in one, i.e. after selecting [AuthProvider.Email] or + * [AuthProvider.Phone]. Cancelling the sub-flow composes [content] again from scratch, so any state + * the caller `remember`ed inside the slot is lost — the slot is a stateless provider chooser by + * design. Every other provider runs the library credential exchange in place, which routes to + * `reauthenticateWithCredential` because [reauthConfig] is in reauthentication mode. + * + * Only [onDismiss] abandons reauthentication; cancelling a sub-flow merely returns to [content]. + * + * @param activeSubRoute Which sub-flow, if any, currently replaces [content]. + * @param onAttemptStarted Invoked just before a provider attempt begins. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomReauthContent( + authUI: FirebaseAuthUI, + reauthConfig: AuthUIConfiguration, + reauthState: AuthState.ReauthenticationRequired, + activity: android.app.Activity?, + context: android.content.Context, + emailContent: (@Composable (EmailAuthContentState) -> Unit)?, + phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, + isLoading: Boolean, + error: String?, + activeSubRoute: MutableState, + onAttemptStarted: () -> Unit, + onDismiss: () -> Unit, + content: @Composable (ReauthContentState) -> Unit, +) { + val openSubFlow: (AuthRoute) -> Unit = remember(activeSubRoute) { + { route -> activeSubRoute.value = route } + } + val onProviderSelected = authUI.rememberOnProviderSelected( + context = context, + activity = activity, + config = reauthConfig, + onNavigate = openSubFlow, + ) + val onProviderSelectedFromSlot: (AuthProvider) -> Unit = { provider -> + onAttemptStarted() + onProviderSelected(provider) + } + val closeSubFlow: () -> Unit = remember(activeSubRoute) { { activeSubRoute.value = null } } + + when (val subRoute = activeSubRoute.value) { + null -> content( + ReauthContentState( + user = reauthState.user, + reason = reauthState.reason, + providers = reauthConfig.providers, + onProviderSelected = onProviderSelectedFromSlot, + isLoading = isLoading, + error = error, + onDismiss = onDismiss, + ) + ) + + AuthRoute.Email -> ModalBottomSheet( + onDismissRequest = closeSubFlow, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + EmailAuthScreen( + context = context, + configuration = reauthConfig, + authUI = authUI, + prefillEmail = reauthState.user.email, + content = emailContent, + onSuccess = {}, + onError = {}, + onCancel = closeSubFlow, + ) + } + + AuthRoute.Phone -> ModalBottomSheet( + onDismissRequest = closeSubFlow, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + PhoneAuthScreen( + context = context, + configuration = reauthConfig, + authUI = authUI, + content = phoneContent, + onSuccess = {}, + onError = {}, + onCancel = closeSubFlow, + ) + } + + else -> throw IllegalStateException( + "rememberOnProviderSelected navigated to ${subRoute.route}, which has no reauth " + + "sub-flow. Add a branch here when a new provider gains its own screen." + ) + } +} + @Composable private fun FirebaseAuthUI.rememberOnProviderSelected( context: android.content.Context, @@ -1062,7 +1216,17 @@ private fun FirebaseAuthUI.rememberOnProviderSelected( return { provider -> when (provider) { - is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() + is AuthProvider.Anonymous -> if (config.isReauthenticationMode) { + updateAuthState( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_anonymous_not_allowed) + ) + ) + ) + } else { + onSignInAnonymously?.invoke() + } is AuthProvider.Email -> onNavigate(AuthRoute.Email) is AuthProvider.Phone -> onNavigate(AuthRoute.Phone) is AuthProvider.Google -> onSignInWithGoogle?.invoke() diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt new file mode 100644 index 000000000..3f796d595 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens + +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.google.firebase.auth.FirebaseUser + +/** + * State class containing all the necessary information to render a custom UI for the + * reauthentication flow triggered by a sensitive operation (account deletion, password change, + * email change). + * + * This class is passed to the `reauthContent` slot of [FirebaseAuthScreen]. The caller renders a + * provider chooser; the library owns the credential exchange. [AuthProvider.Email] and + * [AuthProvider.Phone] hand off to the library's own sub-flow, which replaces this slot while + * active, so keep the slot stateless. On success the library resumes the pending operation. + * + * ```kotlin + * FirebaseAuthScreen( + * configuration = configuration, + * onSignInSuccess = { }, + * onSignInFailure = { }, + * onSignInCancelled = { }, + * reauthContent = { state -> + * AlertDialog( + * onDismissRequest = state.onDismiss, + * title = { Text(state.reason ?: "Verify your identity") }, + * text = { + * Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + * state.error?.let { Text(it) } + * if (state.isLoading) CircularProgressIndicator() + * state.providers.forEach { provider -> + * Button( + * onClick = { state.onProviderSelected(provider) }, + * enabled = !state.isLoading, + * ) { Text("Continue with ${provider.providerName}") } + * } + * } + * }, + * confirmButton = {}, + * dismissButton = { TextButton(onClick = state.onDismiss) { Text("Cancel") } }, + * ) + * }, + * ) + * ``` + * + * @property user The [FirebaseUser] that needs to reauthenticate. + * @property reason An optional human-readable reason to show the user, as supplied by the caller of the sensitive operation. Will be `null` when no reason was given. + * @property providers The providers the user may reauthenticate with, already filtered by the library to those both configured and linked to [user]. + * @property onProviderSelected Callback invoked with the provider the user chose. Receives the selected [AuthProvider]; the library owns what happens next. + * @property isLoading `true` while a reauthentication attempt is in progress. Use this to show loading indicators and disable the provider buttons. The library's own loading dialog is suppressed while this slot is shown. + * @property error A localized error message for the last failed attempt, or `null` if it did not fail. Persists until the next attempt starts, so it can be rendered inline. Backing out of an attempt is not a failure and leaves this `null`. + * @property onDismiss Callback to abandon reauthentication and drop the pending operation. This is the only way to abandon it — backing out of a single provider attempt returns to this slot with the operation still pending. + * + * @since 10.0.0 + */ +data class ReauthContentState( + /** The [FirebaseUser] that needs to reauthenticate. */ + val user: FirebaseUser, + + /** Optional human-readable reason to show the user. `null` when none was given. */ + val reason: String? = null, + + /** Configured providers linked to [user]. Already filtered by the library. */ + val providers: List = emptyList(), + + /** Callback invoked with the provider the user chose. The library owns the credential path. */ + val onProviderSelected: (AuthProvider) -> Unit = {}, + + /** `true` while a reauthentication attempt is in progress. */ + val isLoading: Boolean = false, + + /** Localized error message for the last failed attempt. `null` if it did not fail. */ + val error: String? = null, + + /** Callback to abandon reauthentication and drop the pending operation. */ + val onDismiss: () -> Unit = {}, +) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt index adf17afc5..c9d0ad173 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt @@ -65,6 +65,8 @@ enum class EmailAuthMode { * @param email The current value of the email input field. * @param onEmailChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp], * [EmailAuthMode.ResetPassword]) A callback to be invoked when the email input changes. + * @param isEmailLocked true when the library fixed [email] and it must not be edited. Render the + * email field read-only while it is true. * @param password An optional custom layout composable for the provider buttons. * @param onPasswordChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp]) The current * value of the password input field. @@ -95,6 +97,7 @@ class EmailAuthContentState( val error: String? = null, val email: String, val onEmailChange: (String) -> Unit, + val isEmailLocked: Boolean = false, val password: String, val onPasswordChange: (String) -> Unit, val confirmPassword: String, @@ -156,6 +159,14 @@ fun EmailAuthScreen( val passwordTextValue = rememberSaveable { mutableStateOf("") } val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") } + val isEmailLocked = remember(prefillEmail, configuration.isReauthenticationMode) { + configuration.isReauthenticationMode && !prefillEmail.isNullOrEmpty() + } + + val isSignUpOffered = provider.isNewAccountsAllowed && + configuration.isNewEmailAccountsAllowed && + !configuration.isReauthenticationMode + // Used for clearing text fields when switching EmailAuthMode changes val textValues = listOf( displayNameValue, @@ -164,6 +175,13 @@ fun EmailAuthScreen( confirmPasswordTextValue ) + val resetTextValues: () -> Unit = { + textValues.forEach { it.value = "" } + if (isEmailLocked) { + emailTextValue.value = prefillEmail.orEmpty() + } + } + val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) val isLoading = authState is AuthState.Loading val authCredentialForLinking = remember { credentialForLinking } @@ -196,10 +214,7 @@ fun EmailAuthScreen( onRetry = { ex -> when (ex) { is AuthException.UserNotFoundException -> { - val provider = configuration.providers - .filterIsInstance() - .first() - if (provider.isNewAccountsAllowed) { + if (isSignUpOffered) { // User not found, but new accounts are allowed, switch to sign-up mode.value = EmailAuthMode.SignUp } @@ -263,6 +278,7 @@ fun EmailAuthScreen( mode = mode.value, displayName = displayNameValue.value, email = emailTextValue.value, + isEmailLocked = isEmailLocked, password = passwordTextValue.value, confirmPassword = confirmPasswordTextValue.value, isLoading = isLoading, @@ -270,7 +286,9 @@ fun EmailAuthScreen( resetLinkSent = resetLinkSentLocal, emailSignInLinkSent = emailSignInLinkSentLocal, onEmailChange = { email -> - emailTextValue.value = email + if (!isEmailLocked) { + emailTextValue.value = email + } }, onPasswordChange = { password -> passwordTextValue.value = password @@ -362,21 +380,23 @@ fun EmailAuthScreen( } }, onGoToSignUp = { - textValues.forEach { it.value = "" } - mode.value = EmailAuthMode.SignUp + if (isSignUpOffered) { + resetTextValues() + mode.value = EmailAuthMode.SignUp + } }, onGoToSignIn = { - textValues.forEach { it.value = "" } + resetTextValues() mode.value = EmailAuthMode.SignIn emailSignInLinkSentLocal = false }, onGoToResetPassword = { - textValues.forEach { it.value = "" } + resetTextValues() mode.value = EmailAuthMode.ResetPassword resetLinkSentLocal = false }, onGoToEmailLinkSignIn = { - textValues.forEach { it.value = "" } + resetTextValues() mode.value = EmailAuthMode.EmailLinkSignIn emailSignInLinkSentLocal = false }, @@ -414,7 +434,8 @@ private fun DefaultEmailAuthContent( onGoToSignUp = state.onGoToSignUp, onGoToResetPassword = state.onGoToResetPassword, onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn, - onNavigateBack = onCancel + onNavigateBack = onCancel, + isEmailLocked = state.isEmailLocked, ) } @@ -422,6 +443,7 @@ private fun DefaultEmailAuthContent( SignInEmailLinkUI( configuration = configuration, email = state.email, + isEmailLocked = state.isEmailLocked, isLoading = state.isLoading, emailSignInLinkSent = state.emailSignInLinkSent, onEmailChange = state.onEmailChange, @@ -455,6 +477,7 @@ private fun DefaultEmailAuthContent( configuration = configuration, isLoading = state.isLoading, email = state.email, + isEmailLocked = state.isEmailLocked, resetLinkSent = state.resetLinkSent, onEmailChange = state.onEmailChange, onSendResetLink = state.onSendResetLinkClick, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt index 7d1de8a23..8e25e8072 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt @@ -62,6 +62,7 @@ fun ResetPasswordUI( configuration: AuthUIConfiguration, isLoading: Boolean, email: String, + isEmailLocked: Boolean = false, resetLinkSent: Boolean, onEmailChange: (String) -> Unit, onSendResetLink: () -> Unit, @@ -143,6 +144,7 @@ fun ResetPasswordUI( value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt index f2ec55fa3..b2b8b6444 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt @@ -69,6 +69,7 @@ fun SignInEmailLinkUI( isLoading: Boolean, emailSignInLinkSent: Boolean, email: String, + isEmailLocked: Boolean = false, onEmailChange: (String) -> Unit, onSignInWithEmailLink: () -> Unit, onGoToSignIn: () -> Unit, @@ -154,6 +155,7 @@ fun SignInEmailLinkUI( value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt index eb8b50159..ea732b3b8 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign @@ -87,6 +88,7 @@ fun SignInUI( onGoToResetPassword: () -> Unit, onGoToEmailLinkSignIn: () -> Unit, onNavigateBack: (() -> Unit)? = null, + isEmailLocked: Boolean = false, ) { val context = LocalContext.current val provider = configuration.providers.filterIsInstance().first() @@ -105,11 +107,16 @@ fun SignInUI( } } + val isSignUpOffered = provider.isNewAccountsAllowed && + configuration.isNewEmailAccountsAllowed && + !configuration.isReauthenticationMode + // Retrieve saved credentials when in SignIn mode val credentialRetrievalAttempted = remember { mutableStateOf(false) } LaunchedEffect(Unit) { if (configuration.isCredentialManagerEnabled && + !configuration.isReauthenticationMode && !credentialRetrievalAttempted.value && PasswordCredentialHandler.hasSavedCredentials(context)) { credentialRetrievalAttempted.value = true @@ -156,7 +163,10 @@ fun SignInUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag("SignInBackButton"), + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -178,6 +188,7 @@ fun SignInUI( value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, @@ -221,7 +232,7 @@ fun SignInUI( modifier = Modifier .align(Alignment.End), ) { - if (provider.isNewAccountsAllowed) { + if (isSignUpOffered) { Button( onClick = { onGoToSignUp() diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 6217412de..549fe8b4e 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -179,6 +179,11 @@ Sending... That email address doesn\'t match an existing account + + None of the available sign-in methods is linked to your account. + Anonymous sign-in cannot be used to confirm your identity. + You cannot create a new account while confirming your identity. + An unknown error occurred. Incorrect password. diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt index b06489e3d..d5dd49e8c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -267,6 +267,81 @@ class EmailAuthProviderFirebaseAuthUITest { } } + /** + * Creating an account cannot re-prove an existing session — it *replaces* it. Left open, the + * reauthentication email sub-flow could route to sign-up, mint a brand new user, and have the + * resulting library-published success consume the pending sensitive operation, which would then + * run against a different, never-reauthenticated account. + */ + @Test + fun `createOrLinkUserWithEmailAndPassword - rejects reauthentication mode outright`() = runTest { + val user = mock(FirebaseUser::class.java) + `when`(user.uid).thenReturn("existing-uid") + `when`(mockFirebaseAuth.currentUser).thenReturn(user) + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList(), + isNewAccountsAllowed = true + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + }.copy(isReauthenticationMode = true) + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "brand-new@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: Exception) { + assertThat(e.message) + .isEqualTo( + applicationContext.getString(R.string.fui_error_reauth_sign_up_not_allowed) + ) + } + verify(mockFirebaseAuth, never()).createUserWithEmailAndPassword(anyString(), anyString()) + } + + /** + * `isNewEmailAccountsAllowed` is the configuration-level veto the reauthentication config sets; + * it had no consumer at all, so it vetoed nothing. + */ + @Test + fun `createOrLinkUserWithEmailAndPassword - respects isNewEmailAccountsAllowed setting`() = runTest { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList(), + isNewAccountsAllowed = true + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + }.copy(isNewEmailAccountsAllowed = false) + + try { + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() // Should not reach here + } catch (e: Exception) { + assertThat(e.message) + .isEqualTo(applicationContext.getString(R.string.fui_error_email_does_not_exist)) + } + verify(mockFirebaseAuth, never()).createUserWithEmailAndPassword(anyString(), anyString()) + } + @Test fun `createOrLinkUserWithEmailAndPassword - respects isNewAccountsAllowed setting`() = runTest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) @@ -688,6 +763,49 @@ class EmailAuthProviderFirebaseAuthUITest { verify(mockFirebaseAuth).signInWithCredential(credential) } + /** + * A successful `reauthenticate` whose `currentUser` has since gone null must surface an error + * rather than publishing nothing: the reauth UI would otherwise sit on its last Loading state + * forever, with no Success and no Error to act on. + */ + @Test + fun `signInAndLinkWithCredential - reauth with a null currentUser reports an error`() = runTest { + val user = mock(FirebaseUser::class.java) + `when`(user.uid).thenReturn("existing-uid") + `when`(user.isAnonymous).thenReturn(false) + + // Non-null while reauthenticating, then gone by the time the success is built. + var currentUser: FirebaseUser? = user + `when`(mockFirebaseAuth.currentUser).thenAnswer { currentUser } + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + `when`(user.reauthenticate(credential)).thenAnswer { + currentUser = null + val source = TaskCompletionSource() + source.setResult(null) + source.task + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + }.copy(isReauthenticationMode = true) + + try { + instance.signInAndLinkWithCredential(config = config, credential = credential) + assertThat(false).isTrue() // Should not reach here + } catch (e: Exception) { + assertThat(e).isInstanceOf(AuthException.UserNotFoundException::class.java) + } + assertThat(instance.authStateFlow().first()) + .isInstanceOf(AuthState.Error::class.java) + } + @Test fun `signInAndLinkWithCredential - handles anonymous upgrade`() = runTest { val anonymousUser = mock(FirebaseUser::class.java) diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt new file mode 100644 index 000000000..10dad2cf2 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt @@ -0,0 +1,692 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.UserInfo +import org.junit.After +import org.junit.Before +import org.junit.Rule +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 + +/** + * Contract tests for the [ReauthContentState] handed to [FirebaseAuthScreen]'s `reauthContent` + * slot: the slot only ever chooses a provider, and the library owns every credential path — + * including temporarily presenting its own email sub-flow (prefilled with the reauthenticating + * user's address) for [AuthProvider.Email]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class FirebaseAuthScreenReauthContentStateTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var authUI: FirebaseAuthUI + private lateinit var stringProvider: DefaultAuthUIStringProvider + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(context).forEach { it.delete() } + FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + authUI = FirebaseAuthUI.getInstance() + stringProvider = DefaultAuthUIStringProvider(context) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(context).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + /** A user linked to the password provider only — phone must be filtered out of the slot. */ + private fun passwordOnlyUser(email: String?): FirebaseUser = userLinkedTo("password", email) + + /** A user linked only to a provider that is *not* configured, so nothing can be offered. */ + private fun googleOnlyUser(email: String?): FirebaseUser = userLinkedTo("google.com", email) + + private fun userLinkedTo(providerId: String, email: String?): FirebaseUser { + val providerInfo = mock(UserInfo::class.java) + `when`(providerInfo.providerId).thenReturn(providerId) + val user = mock(FirebaseUser::class.java) + `when`(user.providerData).thenReturn(listOf(providerInfo)) + `when`(user.email).thenReturn(email) + `when`(user.uid).thenReturn("uid-$providerId") + return user + } + + private fun emailAndPhoneConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = this@FirebaseAuthScreenReauthContentStateTest.context + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + isCredentialManagerEnabled = false + } + + @Test + fun `reauthContent receives only the providers linked to the user`() { + val user = passwordOnlyUser("linked@example.com") + var captured: ReauthContentState? = null + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { state -> + captured = state + Text( + text = "REAUTH:${state.reason}", + modifier = Modifier.testTag("reauth_slot") + ) + } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, reason = "Confirm it is you") + ) + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + composeTestRule.onNodeWithText("REAUTH:Confirm it is you").assertIsDisplayed() + + val state = requireNotNull(captured) { "reauthContent was never composed" } + assertThat(state.providers.map { it.providerId }).containsExactly("password") + assertThat(state.user).isSameInstanceAs(user) + assertThat(state.reason).isEqualTo("Confirm it is you") + assertThat(state.error).isNull() + assertThat(state.isLoading).isFalse() + } + + @Test + fun `selecting email from the reauth slot presents the library email sub-flow prefilled`() { + val user = passwordOnlyUser("linked@example.com") + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + emailContent = { state -> + Text( + text = "EMAIL_SUBFLOW:${state.email}", + modifier = Modifier.testTag("email_subflow") + ) + }, + reauthContent = { state -> + Button( + onClick = { state.onProviderSelected(state.providers.first()) }, + modifier = Modifier.testTag("pick_provider") + ) { + Text("Continue") + } + } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("pick_provider").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("email_subflow").assertIsDisplayed() + composeTestRule.onNodeWithText("EMAIL_SUBFLOW:linked@example.com").assertIsDisplayed() + } + + @Test + fun `cancelling the email sub-flow returns to the reauth slot`() { + val user = passwordOnlyUser("linked@example.com") + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { state -> + Button( + onClick = { state.onProviderSelected(state.providers.first()) }, + modifier = Modifier.testTag("pick_provider") + ) { + Text("Continue") + } + } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("pick_provider").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithContentDescription(stringProvider.backAction).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("pick_provider").assertIsDisplayed() + } + + /** + * A dismissed provider sheet (Credential Manager, an OAuth web flow, …) emits + * [AuthState.Cancelled]. While reauthentication is armed that only cancels *that attempt*: the + * slot must stay up, the flow must not report itself cancelled, and the pending sensitive + * operation must survive so a later successful reauthentication still runs it. + */ + @Test + fun `cancelling a provider attempt keeps the reauth slot armed`() { + val user = passwordOnlyUser("linked@example.com") + var cancelledCount = 0 + var retryRan = false + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = { cancelledCount++ }, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Cancelled()) } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + assertThat(cancelledCount).isEqualTo(0) + assertThat(retryRan).isFalse() + + composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)) + } + composeTestRule.waitUntil(timeoutMillis = 5_000) { retryRan } + + assertThat(retryRan).isTrue() + } + + /** + * The same contract on the default bottom-sheet path: a cancelled provider attempt must not + * report the flow as cancelled nor drop the pending operation. + */ + @Test + fun `cancelling a provider attempt in the default reauth sheet keeps it armed`() { + val phoneInfo = mock(UserInfo::class.java) + `when`(phoneInfo.providerId).thenReturn("phone") + val passwordInfo = mock(UserInfo::class.java) + `when`(passwordInfo.providerId).thenReturn("password") + val user = mock(FirebaseUser::class.java) + `when`(user.providerData).thenReturn(listOf(passwordInfo, phoneInfo)) + `when`(user.email).thenReturn("linked@example.com") + `when`(user.uid).thenReturn("uid-multi") + + var cancelledCount = 0 + var retryRan = false + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = { cancelledCount++ }, + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + ) + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Cancelled()) } + composeTestRule.waitForIdle() + + assertThat(cancelledCount).isEqualTo(0) + assertThat(retryRan).isFalse() + + composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)) + } + composeTestRule.waitUntil(timeoutMillis = 5_000) { retryRan } + + assertThat(retryRan).isTrue() + } + + /** + * [ReauthContentState.error] has to outlive the reset-to-Idle that consumes [AuthState.Error], + * carry the *localized* message rather than the raw throwable message, and be suppressed from + * the library's own error dialog so the failure surfaces exactly once — in the slot. + */ + @Test + fun `a failed attempt latches a localized error into the slot until the next attempt`() { + val user = passwordOnlyUser("linked@example.com") + var captured: ReauthContentState? = null + val rawMessage = "RAW-BACKEND-CODE-17" + val thrown = FirebaseAuthInvalidUserException("ERROR_USER_DISABLED", rawMessage) + val expectedMessage = requireNotNull(AuthException.from(thrown, stringProvider).message) + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { state -> + captured = state + Button( + onClick = { state.onProviderSelected(state.providers.first()) }, + modifier = Modifier.testTag("pick_provider") + ) { + Text("SLOT_ERROR=${state.error}") + } + } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + } + composeTestRule.waitForIdle() + assertThat(requireNotNull(captured).error).isNull() + + composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Error(thrown)) } + composeTestRule.waitForIdle() + + assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) + assertThat(requireNotNull(captured).error).doesNotContain(rawMessage) + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertIsDisplayed() + assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) + + assertThat( + composeTestRule.onAllNodesWithText(expectedMessage).fetchSemanticsNodes() + ).isEmpty() + + composeTestRule.onNodeWithTag("pick_provider").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithContentDescription(stringProvider.backAction).performClick() + composeTestRule.waitForIdle() + + assertThat(requireNotNull(captured).error).isNull() + } + + /** + * When no configured provider is linked to the user there is no reauth UI to show, so nothing + * may stay armed — otherwise a later Loading → Success would consume the pending operation and + * run the sensitive action with no reauthentication at all. + */ + @Test + fun `no linked providers leaves nothing armed`() { + val user = googleOnlyUser("federated@example.com") + var slotComposed = false + var retryRan = false + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + slotComposed = true + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + ) + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() + assertThat(slotComposed).isFalse() + + composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Success(result = null, user = user)) + } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(retryRan).isFalse() + } + + /** + * A [FirebaseAuthUI] over a mocked, *signed-in* [com.google.firebase.auth.FirebaseAuth] — the + * only state reauthentication can happen in, and the one the rest of this suite cannot reach + * (with no current user `authStateFlow()` falls back to [AuthState.Idle] instead). + */ + private fun signedInAuthUI(user: FirebaseUser): FirebaseAuthUI { + `when`(user.isEmailVerified).thenReturn(true) + val auth = mock(FirebaseAuth::class.java) + `when`(auth.currentUser).thenReturn(user) + `when`(auth.app).thenReturn(FirebaseApp.getInstance()) + return FirebaseAuthUI.create(FirebaseApp.getInstance(), auth) + } + + /** + * The sensitive operation must never run without an actual credential exchange. + * + * `authStateFlow()` prefers the internal state and otherwise falls back to the live Firebase + * session, so for the (necessarily signed-in) user being reauthenticated *every* reset to + * [AuthState.Idle] re-emits an [AuthState.Success] for the session that already existed — + * after a cancelled provider attempt, after a latched error, and whenever a provider retracts + * its own [AuthState.Loading] (`clearLoadingState`, e.g. cancelled phone verification). None of + * those is evidence of reauthentication, and no one-step lookback at the previous state can + * tell them apart: this sequence ends on `Loading -> Success`, exactly the shape a genuine + * reauthentication has. + */ + @Test + fun `an ambient Success from the signed-in session does not run the pending operation`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + var retryRan = false + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + }, + authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Cancelled()) } + composeTestRule.waitForIdle() + assertThat(retryRan).isFalse() + + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Idle) } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(retryRan).isFalse() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + } + + /** + * The other half of the contract above: an [AuthState.Success] the library published itself — + * what every provider's credential exchange ends with — does consume the operation, exactly + * once, even though the ambient session is emitting Successes of its own. + */ + @Test + fun `a library-published Success runs the pending operation exactly once`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + var retryCount = 0 + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + }, + authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState(AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid)) + } + composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 } + + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Idle) } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(retryCount).isEqualTo(1) + } + + /** + * The error dialog's recovery actions navigate the *outer* NavHost to the non-reauth email + * screen. While a reauthentication is armed that would run an ordinary sign-in underneath the + * reauth sheet, so the recovery action must not navigate. This is the default-sheet path — with + * a custom slot the error latches into the slot and no dialog is shown at all. + */ + @Test + fun `a recoverable error does not navigate away while reauthentication is armed`() { + val phoneInfo = mock(UserInfo::class.java) + `when`(phoneInfo.providerId).thenReturn("phone") + val passwordInfo = mock(UserInfo::class.java) + `when`(passwordInfo.providerId).thenReturn("password") + val user = mock(FirebaseUser::class.java) + `when`(user.providerData).thenReturn(listOf(passwordInfo, phoneInfo)) + `when`(user.email).thenReturn("linked@example.com") + `when`(user.uid).thenReturn("uid-multi") + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = {}) + ) + } + composeTestRule.waitForIdle() + + // The sheet opens on its method picker (two linked providers), so no password field is on + // screen yet. The outer NavHost is still on the method-picker route behind it. + composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0) + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Error( + AuthException.EmailAlreadyInUseException( + message = "already in use", + email = "linked@example.com", + ) + ) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists() + + // The recovery button still renders; ungated, its onRecover navigates the outer NavHost to + // the non-reauth email screen, which would surface a password field behind the sheet. + composeTestRule.onAllNodesWithText(stringProvider.signInDefault, ignoreCase = true) + .onLast() + .performClick() + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0) + } + + /** + * The method picker stays composed underneath a custom reauth slot, wired to the *non-reauth* + * configuration. A tap reaching it would start an ordinary sign-in while a sensitive operation + * is pending, so provider selection has to be inert. + */ + @Test + fun `provider selection is inert while reauthentication is armed`() { + val user = passwordOnlyUser("linked@example.com") + var retryRan = false + var captured: ReauthContentState? = null + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + customMethodPickerLayout = { providers, onProviderSelected -> + Column { + providers.forEach { provider -> + Button( + onClick = { onProviderSelected(provider) }, + modifier = Modifier.testTag("pick_${provider.providerId}"), + ) { Text(provider.providerId) } + } + } + }, + reauthContent = { state -> + captured = state + Text("reauth_slot", modifier = Modifier.testTag("reauth_slot")) + }, + ) + } + + composeTestRule.onNodeWithTag("pick_password").assertExists() + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertExists() + assertThat(captured).isNotNull() + + composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0) + + // Ungated, selecting email navigates the outer NavHost to its non-reauth email screen, + // surfacing a password field behind the slot. + composeTestRule.onNodeWithTag("pick_password").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0) + composeTestRule.onNodeWithTag("reauth_slot").assertExists() + assertThat(retryRan).isFalse() + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt index 3bd40b643..2da188a6a 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt @@ -116,7 +116,7 @@ class FirebaseAuthScreenReauthIdleResetTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, - reauthContent = { _, _ -> + reauthContent = { Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) } ) diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt new file mode 100644 index 000000000..b3834bf23 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt @@ -0,0 +1,349 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import androidx.compose.runtime.CompositionLocalProvider +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.UserInfo +import org.junit.After +import org.junit.Before +import org.junit.Rule +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 + +/** + * The reauthentication email lock has to survive an [EmailAuthMode] round-trip. + * + * [DefaultEmailAuthContent] dispatches modes with a `when`, so leaving [EmailAuthMode.SignIn] + * *disposes* the [SignInUI] composition group and coming back creates a fresh one. Any lock + * [SignInUI] inferred from its own (mutable) field value was therefore re-decided on every return — + * either dropping the lock, or locking an address the library never prefilled. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class EmailAuthScreenReauthEmailLockTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + private lateinit var authUI: FirebaseAuthUI + + private val prefillEmail = "linked@example.com" + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { it.delete() } + val app = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + val providerInfo = mock(UserInfo::class.java) + `when`(providerInfo.providerId).thenReturn("password") + val user = mock(FirebaseUser::class.java) + `when`(user.providerData).thenReturn(listOf(providerInfo)) + `when`(user.email).thenReturn(prefillEmail) + `when`(user.uid).thenReturn("uid-password") + val auth = mock(FirebaseAuth::class.java) + `when`(auth.currentUser).thenReturn(user) + authUI = FirebaseAuthUI.create(app, auth) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + /** The configuration `FirebaseAuthUI.createReauthFlow` actually produces. */ + private fun reauthConfiguration(): AuthUIConfiguration { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + } + return authUI.createReauthFlow(configuration).configuration + } + + /** The same reauth configuration, but with email-link sign-in available. */ + private fun reauthConfigurationWithEmailLink(): AuthUIConfiguration { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .setAndroidPackageName("com.test", true, null) + .build(), + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + } + return authUI.createReauthFlow(configuration).configuration + } + + @Composable + private fun EmailAuthScreenUnderTest( + configuration: AuthUIConfiguration, + prefill: String?, + ) { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + EmailAuthScreen( + context = applicationContext, + configuration = configuration, + authUI = authUI, + prefillEmail = prefill, + onSuccess = {}, + onError = {}, + onCancel = {}, + ) + } + } + + @Test + fun `the locked email survives leaving and re-entering SignIn mode`() { + composeTestRule.setContent { + EmailAuthScreenUnderTest(reauthConfiguration(), prefill = prefillEmail) + } + + composeTestRule.onNodeWithText(prefillEmail) + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText)) + + composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.recoverPasswordPageTitle).assertExists() + + composeTestRule.onNodeWithText(stringProvider.signInDefault, ignoreCase = true) + .performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(prefillEmail).assertExists() + composeTestRule.onNodeWithText(prefillEmail) + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText)) + } + + /** + * The lock must be rendered in every mode that shows the address, not only SignIn. A field that + * silently swallows typing — focusable, keyboard up, nothing appearing — is worse than one that + * is visibly read-only. + */ + @Test + fun `the locked email is read-only in ResetPassword mode`() { + composeTestRule.setContent { + EmailAuthScreenUnderTest(reauthConfiguration(), prefill = prefillEmail) + } + + composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.recoverPasswordPageTitle).assertExists() + + composeTestRule.onNodeWithText(prefillEmail) + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText)) + } + + /** + * The same for the email-link route, which is the other way out of SignIn mode that shows the + * address. + */ + @Test + fun `the locked email is read-only in EmailLink mode`() { + composeTestRule.setContent { + EmailAuthScreenUnderTest(reauthConfigurationWithEmailLink(), prefill = prefillEmail) + } + + composeTestRule.onNodeWithText(stringProvider.signInWithEmailLink, ignoreCase = true) + .performScrollTo() + .performClick() + composeTestRule.waitForIdle() + + // Prove the mode actually changed: the password field only exists in SignIn mode, so + // without this the assertion below would pass against the (already locked) SignIn field. + composeTestRule.onNodeWithText(stringProvider.passwordHint).assertDoesNotExist() + + composeTestRule.onNodeWithText(prefillEmail) + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText)) + } + + /** + * The mirror case: outside reauthentication nothing is locked, so a round-trip must leave the + * field editable (and the "sign in" mode switch keeps clearing it as it always did). + */ + @Test + fun `the email field stays editable across a round-trip outside reauthentication`() { + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + } + + composeTestRule.setContent { + EmailAuthScreenUnderTest(configuration, prefill = prefillEmail) + } + + composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.signInDefault, ignoreCase = true) + .performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(stringProvider.emailHint) + .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetText)) + } + + /** + * With nothing prefilled there is nothing to lock, so the standalone `createReauthFlow` entry + * point must not strand the user on a blank read-only field. + */ + @Test + fun `nothing is locked in reauthentication mode when nothing was prefilled`() { + composeTestRule.setContent { + EmailAuthScreenUnderTest(reauthConfiguration(), prefill = null) + } + + composeTestRule.onNodeWithText(stringProvider.emailHint) + .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetText)) + } + + /** + * `EmailAuthContentState.isEmailLocked` is the signal a custom `emailContent` slot needs in + * order to render the field read-only itself, and it must not flip as the user moves modes. + */ + @Test + fun `isEmailLocked is reported to a custom content slot and is stable across modes`() { + val observed = mutableListOf>() + var goToResetPassword: (() -> Unit)? = null + var goToSignIn: (() -> Unit)? = null + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + EmailAuthScreen( + context = applicationContext, + configuration = reauthConfiguration(), + authUI = authUI, + prefillEmail = prefillEmail, + onSuccess = {}, + onError = {}, + onCancel = {}, + content = { state -> + observed.add(state.mode to state.isEmailLocked) + goToResetPassword = state.onGoToResetPassword + goToSignIn = state.onGoToSignIn + }, + ) + } + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { requireNotNull(goToResetPassword).invoke() } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { requireNotNull(goToSignIn).invoke() } + composeTestRule.waitForIdle() + + assertThat(observed.map { it.first }).contains(EmailAuthMode.ResetPassword) + assertThat(observed.map { it.first }.last()).isEqualTo(EmailAuthMode.SignIn) + assertThat(observed.map { it.second }.toSet()).containsExactly(true) + } + + /** A locked address is inert: nothing may substitute another account for the one being re-proved. */ + @Test + fun `onEmailChange cannot replace a locked address`() { + var email: String? = null + var onEmailChange: ((String) -> Unit)? = null + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + EmailAuthScreen( + context = applicationContext, + configuration = reauthConfiguration(), + authUI = authUI, + prefillEmail = prefillEmail, + onSuccess = {}, + onError = {}, + onCancel = {}, + content = { state -> + email = state.email + onEmailChange = state.onEmailChange + }, + ) + } + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { requireNotNull(onEmailChange).invoke("attacker@example.com") } + composeTestRule.waitForIdle() + + assertThat(email).isEqualTo(prefillEmail) + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt index 6a3775287..46c1580fa 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt @@ -16,26 +16,80 @@ package com.firebase.ui.auth.ui.screens.email import android.content.Context import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PasswordCredential import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.credentialmanager.CredentialManagerProvider +import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler +import com.firebase.ui.auth.util.CredentialPersistenceManager +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.UserInfo +import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.any import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +/** Address of the (different) account the fake Credential Manager offers. */ +private const val SAVED_CREDENTIAL_USERNAME = "saved-other@example.com" + +/** + * A Credential Manager that always offers a saved password for an account *other* than the one + * being reauthenticated — the case that used to strand the user on a locked, wrong address. + */ +private object FakeCredentialManagerProvider : CredentialManagerProvider { + /** Set the moment the screen reaches for a saved credential at all. */ + @Volatile + var wasQueried: Boolean = false + + override fun getCredentialManager(context: Context): CredentialManager { + wasQueried = true + val response = GetCredentialResponse( + PasswordCredential(SAVED_CREDENTIAL_USERNAME, "saved-password") + ) + return org.mockito.kotlin.mock { + onBlocking { + getCredential(any(), any()) + } doReturn response + } + } +} + /** - * Unit tests for [SignInUI], covering the sign-up button's visibility and email pre-fill. + * Unit tests for [SignInUI], covering the sign-up button's visibility, email pre-fill, and the + * reauthentication-mode restrictions on the email field and Credential Manager autofill. * * @suppress Internal test class */ @@ -53,6 +107,23 @@ class SignInUITest { fun setUp() { applicationContext = ApplicationProvider.getApplicationContext() stringProvider = DefaultAuthUIStringProvider(applicationContext) + runBlocking { CredentialPersistenceManager.clearSavedCredentialsFlag(applicationContext) } + FakeCredentialManagerProvider.wasQueried = false + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { it.delete() } + } + + @After + fun tearDown() { + PasswordCredentialHandler.testCredentialManagerProvider = null + runBlocking { CredentialPersistenceManager.clearSavedCredentialsFlag(applicationContext) } + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } } private fun setSignInUIContent(isNewAccountsAllowed: Boolean) { @@ -170,4 +241,221 @@ class SignInUITest { composeTestRule.onNodeWithText("user@example.com").assertDoesNotExist() } + + /** + * The configuration [FirebaseAuthUI.createReauthFlow] actually produces, so these tests + * exercise the public standalone-reauthentication entry point rather than a hand-rolled copy. + */ + private fun createReauthFlowConfiguration(): AuthUIConfiguration { + val providerInfo = mock(UserInfo::class.java) + `when`(providerInfo.providerId).thenReturn("password") + val user = mock(FirebaseUser::class.java) + `when`(user.providerData).thenReturn(listOf(providerInfo)) + val auth = mock(FirebaseAuth::class.java) + `when`(auth.currentUser).thenReturn(user) + + val app = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = credentialManagerEnabled + } + return FirebaseAuthUI.create(app, auth).createReauthFlow(configuration).configuration + } + + /** + * Set before [createReauthFlowConfiguration] to build a Credential-Manager-enabled config. + * + * Safe as mutable per-instance state only because JUnit4 constructs a *fresh* instance of this + * class for every `@Test` method, so it cannot leak from one test to the next. It would need + * resetting in [setUp] under a runner that reuses the instance. + */ + private var credentialManagerEnabled = false + + private fun setStatefulSignInUIContent( + configuration: AuthUIConfiguration, + initialEmail: String, + isEmailLocked: Boolean = false, + onSignInClicked: () -> Unit = {}, + ) { + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + var email by remember { mutableStateOf(initialEmail) } + var password by remember { mutableStateOf("") } + SignInUI( + configuration = configuration, + isLoading = false, + emailSignInLinkSent = false, + email = email, + password = password, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onRetrievedCredential = { }, + onSignInClick = onSignInClicked, + onGoToSignUp = { }, + onGoToResetPassword = { }, + onGoToEmailLinkSignIn = { }, + isEmailLocked = isEmailLocked, + ) + } + } + } + + /** + * Reauthentication can only ever re-prove the signed-in user's own account, so when the library + * says the address is locked the field is read-only: a different one would only produce an + * opaque credential mismatch. The lock is an explicit input rather than something this screen + * infers from the current field value — see the round-trip test in + * [com.firebase.ui.auth.ui.screens.email.EmailAuthScreenReauthEmailLockTest]. + */ + @Test + fun `email field is read-only when the address is locked`() { + val prefillEmail = "linked@example.com" + + setStatefulSignInUIContent( + createReauthFlowConfiguration(), + initialEmail = prefillEmail, + isEmailLocked = true, + ) + + composeTestRule.onNodeWithText(prefillEmail) + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText)) + composeTestRule.onNodeWithText(prefillEmail).assertExists() + } + + /** + * Regression guard: locking on the *mode* rather than on an actual prefill left the standalone + * `createReauthFlow` path with a blank field the user could not type into, because nothing + * prefills it unless a "Continue as" chip was tapped. An unlocked field must stay editable — and + * must not flip to read-only on the first keystroke either. + */ + @Test + fun `email field stays editable in reauthentication mode when nothing was prefilled`() { + setStatefulSignInUIContent(createReauthFlowConfiguration(), initialEmail = "") + + composeTestRule.onNodeWithText(stringProvider.emailHint) + .performTextInput("typed@example.com") + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("typed@example.com").assertExists() + composeTestRule.onNodeWithText("typed@example.com") + .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetText)) + } + + /** + * SIGN UP creates a brand new account, which cannot re-prove an existing session — it replaces + * it. The button was still offered during reauthentication because it is gated on + * `AuthProvider.Email.isNewAccountsAllowed` (default `true`), which the reauthentication config + * never touches. + */ + @Test + fun `sign up button is hidden in reauthentication mode`() { + setStatefulSignInUIContent( + createReauthFlowConfiguration(), + initialEmail = "linked@example.com", + isEmailLocked = true, + ) + + composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction()) + .assertDoesNotExist() + } + + /** The configuration-level veto has to work on its own, independently of the provider flag. */ + @Test + fun `sign up button is hidden when new email accounts are not allowed by the configuration`() { + val provider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = true, + passwordValidationRules = emptyList() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + }.copy(isNewEmailAccountsAllowed = false) + + setStatefulSignInUIContent(configuration, initialEmail = "") + + composeTestRule.onNode(hasText(stringProvider.signupPageTitle.uppercase()) and hasClickAction()) + .assertDoesNotExist() + } + + /** + * `isCredentialManagerEnabled` defaults to true and the reauthentication config preserves it, + * so this effect used to fire during reauthentication too — writing a saved credential straight + * into the form and auto-submitting it. A saved password for a *different* account would then + * silently submit the wrong credential (a read-only field does not stop a programmatic write), + * stranding the user. The control test below proves the harness really does autofill. + */ + @Test + fun `credential manager autofill is skipped in reauthentication mode`() { + credentialManagerEnabled = true + runBlocking { CredentialPersistenceManager.setCredentialsSaved(applicationContext) } + PasswordCredentialHandler.testCredentialManagerProvider = FakeCredentialManagerProvider + var signInClicks = 0 + + setStatefulSignInUIContent( + createReauthFlowConfiguration(), + initialEmail = "linked@example.com", + onSignInClicked = { signInClicks++ }, + ) + awaitOrTimeout { FakeCredentialManagerProvider.wasQueried } + + assertThat(FakeCredentialManagerProvider.wasQueried).isFalse() + composeTestRule.onNodeWithText(SAVED_CREDENTIAL_USERNAME).assertDoesNotExist() + composeTestRule.onNodeWithText("linked@example.com").assertExists() + assertThat(signInClicks).isEqualTo(0) + } + + /** Polls [condition] for up to two seconds, idling composition in between. */ + private fun awaitOrTimeout(condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + 2_000 + while (System.currentTimeMillis() < deadline && !condition()) { + composeTestRule.waitForIdle() + Thread.sleep(25) + } + } + + /** Control for the test above: outside reauthentication mode the autofill still happens. */ + @Test + fun `credential manager autofill still happens outside reauthentication mode`() { + runBlocking { CredentialPersistenceManager.setCredentialsSaved(applicationContext) } + PasswordCredentialHandler.testCredentialManagerProvider = FakeCredentialManagerProvider + var signInClicks = 0 + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = true + } + + setStatefulSignInUIContent( + configuration, + initialEmail = "", + onSignInClicked = { signInClicks++ }, + ) + composeTestRule.waitUntil(timeoutMillis = 5_000) { signInClicks > 0 } + + composeTestRule.onNodeWithText(SAVED_CREDENTIAL_USERNAME).assertExists() + assertThat(signInClicks).isEqualTo(1) + } } diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt index 80a456ac7..86af8ea24 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/ReauthFlowTest.kt @@ -9,7 +9,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText @@ -184,10 +186,9 @@ class ReauthFlowTest { .fetchSemanticsNodes().isNotEmpty() } - // Step 3: Enter credentials in the reauth bottom sheet. composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) .performScrollTo() - .performTextInput(email) + .assertTextContains(email) composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) .performScrollTo() .performTextInput(password) @@ -207,22 +208,37 @@ class ReauthFlowTest { } /** - * Verifies that when reauthContent is provided, it receives the ReauthenticationRequired state - * and calling onDismiss resets the auth state to Idle. + * Verifies the [ReauthContentState] contract for the custom reauthContent slot: it receives the + * reauthenticating user, the reason, and the configured providers already filtered to the ones + * linked to that user; dismissing it drops the pending retry operation without firing it. + * + * The user stays signed in, as they always are during reauthentication. That is why dismissing + * does *not* leave the state on [AuthState.Idle]: `onDismiss` resets the library's internal + * state, and `authStateFlow()` then falls back to the live session, which is an + * [AuthState.Success] for the session that already existed. */ @Test - fun `custom reauthContent receives ReauthenticationRequired state and dismisses to Idle`() { + fun `custom reauthContent receives linked providers and dismisses without retrying`() { val email = "reauth-custom-${System.currentTimeMillis()}@example.com" val password = "test123" val user = ensureFreshUser(authUI, email, password) requireNotNull(user) { "Failed to create user" } + try { + verifyEmailInEmulator(authUI, emulatorApi, user) + } catch (e: Exception) { + Assume.assumeTrue( + "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}", + false + ) + } + val capturedUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in after creation" } - authUI.auth.signOut() - shadowOf(Looper.getMainLooper()).idle() var currentAuthState: AuthState = AuthState.Idle + var retryOperationCalled = false + var capturedState: ReauthContentState? = null val expectedReason = "Sensitive operation requires sign-in" val configuration = authUIConfiguration { @@ -234,6 +250,13 @@ class ReauthFlowTest { passwordValidationRules = emptyList() ) ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) } isCredentialManagerEnabled = false } @@ -248,10 +271,11 @@ class ReauthFlowTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, - reauthContent = { reauthState, onDismiss -> + reauthContent = { reauthState -> + capturedState = reauthState Column { Text("REAUTH REQUIRED - ${reauthState.reason}") - Button(onClick = onDismiss) { Text("DISMISS REAUTH") } + Button(onClick = reauthState.onDismiss) { Text("DISMISS REAUTH") } } }, ) { _, _ -> @@ -269,6 +293,7 @@ class ReauthFlowTest { AuthState.ReauthenticationRequired( user = capturedUser, reason = expectedReason, + retryOperation = { retryOperationCalled = true }, ) ) @@ -284,18 +309,135 @@ class ReauthFlowTest { composeAndroidTestRule.onNodeWithText("REAUTH REQUIRED - $expectedReason") .assertIsDisplayed() - // Dismiss the custom reauth UI via the onDismiss callback. + val state = requireNotNull(capturedState) { "reauthContent was never composed" } + assertThat(state.user.uid).isEqualTo(capturedUser.uid) + assertThat(state.reason).isEqualTo(expectedReason) + assertThat(state.providers.map { it.providerId }).containsExactly("password") + composeAndroidTestRule.onNodeWithText("DISMISS REAUTH").performClick() shadowOf(Looper.getMainLooper()).idle() - // Verify that dismissing resets auth state to Idle. composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { shadowOf(Looper.getMainLooper()).idle() - currentAuthState is AuthState.Idle + composeAndroidTestRule.onAllNodesWithText("CONTENT").fetchSemanticsNodes().isNotEmpty() } - assertThat(currentAuthState).isInstanceOf(AuthState.Idle::class.java) + composeAndroidTestRule.onAllNodesWithText("REAUTH REQUIRED - $expectedReason") + .assertCountEquals(0) + val observedState = currentAuthState + assertThat(observedState).isInstanceOf(AuthState.Success::class.java) + assertThat((observedState as AuthState.Success).user.uid).isEqualTo(capturedUser.uid) + assertThat(observedState.result).isNull() + assertThat(retryOperationCalled).isFalse() + } + + /** + * The custom slot only picks a provider: selecting email makes the library present its own + * email sub-flow (prefilled with the user's address), and completing it fires the pending + * retry operation — mirroring the default bottom sheet path. + */ + @Test + fun `reauth through the custom slot email sub-flow triggers the retry operation`() { + val email = "reauth-slot-email-${System.currentTimeMillis()}@example.com" + val password = "test123" + + val user = ensureFreshUser(authUI, email, password) + requireNotNull(user) { "Failed to create user" } + + try { + verifyEmailInEmulator(authUI, emulatorApi, user) + } catch (e: Exception) { + Assume.assumeTrue( + "Skipping: Firebase Auth Emulator OOB codes not available. Error: ${e.message}", + false + ) + } + + val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } + + var retryOperationCalled = false + + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + } + + composeAndroidTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(applicationContext) + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { reauthState -> + Column { + Text("PICK A PROVIDER") + reauthState.providers.forEach { provider -> + Button( + onClick = { reauthState.onProviderSelected(provider) } + ) { Text("USE ${provider.providerId}") } + } + } + }, + ) { _, _ -> + Text("AUTHENTICATED") + } + } + } + + shadowOf(Looper.getMainLooper()).idle() + + authUI.updateAuthState( + AuthState.ReauthenticationRequired( + user = signedInUser, + reason = "Please verify your identity to continue", + retryOperation = { retryOperationCalled = true }, + ) + ) + + shadowOf(Looper.getMainLooper()).idle() + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText("USE password") + .fetchSemanticsNodes().isNotEmpty() + } + + composeAndroidTestRule.onNodeWithText("USE password").performClick() + shadowOf(Looper.getMainLooper()).idle() + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText(email).fetchSemanticsNodes().isNotEmpty() + } + + composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) + .performScrollTo() + .performTextInput(password) + composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase()) + .performScrollTo() + .performClick() + + shadowOf(Looper.getMainLooper()).idle() + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + retryOperationCalled + } + + assertThat(retryOperationCalled).isTrue() } @Test @@ -393,10 +535,9 @@ class ReauthFlowTest { .fetchSemanticsNodes().isNotEmpty() } - // Step 3: enter the WRONG password in the reauth sheet. composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) .performScrollTo() - .performTextInput(email) + .assertTextContains(email) composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) .performScrollTo() .performTextInput(wrongPassword)