From 4179d976ab9638af10671494ed26e5746d981c91 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 25 Aug 2026 01:38:35 +0100 Subject: [PATCH 1/7] feat(auth): reshape reauthContent into a ReauthContentState content slot --- .../demo/auth/HighLevelApiDemoActivity.kt | 101 +- auth/README.md | 39 +- .../java/com/firebase/ui/auth/AuthState.kt | 33 +- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 2 +- .../EmailAuthProvider+FirebaseAuthUI.kt | 24 +- .../OAuthProvider+FirebaseAuthUI.kt | 16 +- .../ui/auth/ui/components/AuthTextField.kt | 3 + .../auth/ui/components/ErrorRecoveryDialog.kt | 18 +- .../ui/components/TopLevelDialogController.kt | 15 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 329 ++++-- .../ui/auth/ui/screens/ReauthContentState.kt | 93 ++ .../auth/ui/screens/email/EmailAuthScreen.kt | 78 +- .../auth/ui/screens/email/ResetPasswordUI.kt | 2 + .../ui/screens/email/SignInEmailLinkUI.kt | 2 + .../ui/auth/ui/screens/email/SignInUI.kt | 59 +- .../ui/auth/ui/screens/email/SignUpUI.kt | 2 + auth/src/main/res/values/strings.xml | 5 + .../EmailAuthProviderFirebaseAuthUITest.kt | 120 +++ ...irebaseAuthScreenReauthContentStateTest.kt | 978 ++++++++++++++++++ .../FirebaseAuthScreenReauthIdleResetTest.kt | 2 +- .../EmailAuthScreenReauthEmailLockTest.kt | 418 ++++++++ .../ui/auth/ui/screens/email/SignInUITest.kt | 290 +++++- .../ui/auth/ui/screens/ReauthFlowTest.kt | 171 ++- 23 files changed, 2559 insertions(+), 241 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/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 2bb0ac31e..65ece1df7 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 and calls `onSignInCancelled`, since the pending operation will never run; backing out of a single provider attempt returns to the slot with the operation still pending and does *not* call `onSignInCancelled`. Render the slot so it blocks interaction with the content behind it — that content stays composed, and the library only makes its own affordances inert. + 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..707f977ea 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -28,7 +28,8 @@ import com.google.firebase.auth.PhoneAuthProvider * This class encapsulates all possible authentication states that can occur during * the authentication flow, including success, error, and intermediate states. * - * Use the companion object factory methods or specific subclass constructors to create instances. + * Instances come from the companion object factory methods or a subclass constructor; states only + * the library may publish have an `internal` constructor. * * @since 10.0.0 */ @@ -76,11 +77,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 +92,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)" } /** @@ -257,21 +264,15 @@ abstract class AuthState private constructor() { class ReauthenticationRequired( val user: FirebaseUser, val reason: String? = null, - // Not included in equals/hashCode — lambdas have no meaningful equality. val retryOperation: (suspend (android.content.Context) -> Unit)? = null, ) : AuthState() { override val isNotification: Boolean = false - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ReauthenticationRequired) return false - return user == other.user && reason == other.reason - } - override fun hashCode(): Int { - var result = user.hashCode() - result = 31 * result + (reason?.hashCode() ?: 0) - return result - } + // Identity, not value: arming a second sensitive operation for the same user must replace + // the first, and MutableStateFlow silently drops a write equal to the current value. + override fun equals(other: Any?): Boolean = this === other + + override fun hashCode(): Int = System.identityHashCode(this) override fun toString(): String = "AuthState.ReauthenticationRequired(user=$user, reason=$reason)" 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 432d25c82..c6c592d62 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -722,4 +722,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/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt index a3e216ebe..cd78974af 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties import com.firebase.ui.auth.AuthException @@ -33,6 +34,9 @@ import com.google.firebase.auth.PhoneAuthProvider import com.google.firebase.auth.TwitterAuthProvider import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +/** Test tag on the dialog's recovery/retry action button, which only renders when it has an action. */ +internal const val ERROR_DIALOG_ACTION_TEST_TAG = "ErrorRecoveryDialogAction" + /** * A composable dialog for displaying authentication errors with recovery options. * @@ -61,7 +65,8 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider * * @param error The [AuthException] to display recovery information for * @param stringProvider The [AuthUIStringProvider] for localized strings - * @param onRetry Callback invoked when the user taps the retry action + * @param onRetry Callback invoked when the user taps the retry action, or `null` when there is + * nothing to retry — the action button is then not rendered at all * @param onDismiss Callback invoked when the user dismisses the dialog * @param modifier Optional [Modifier] for the dialog * @param onRecover Optional callback for custom recovery actions based on the exception type @@ -73,7 +78,7 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider fun ErrorRecoveryDialog( error: AuthException, stringProvider: AuthUIStringProvider, - onRetry: (AuthException) -> Unit, + onRetry: ((AuthException) -> Unit)?, onDismiss: () -> Unit, modifier: Modifier = Modifier, onRecover: ((AuthException) -> Unit)? = null, @@ -97,11 +102,12 @@ fun ErrorRecoveryDialog( ) }, confirmButton = { - if (isRecoverable(error)) { + // No callback means no action to take, so an action button would be a no-op. + val action = onRecover ?: onRetry + if (action != null && isRecoverable(error)) { TextButton( - onClick = { - onRecover?.invoke(error) ?: onRetry(error) - } + onClick = { action(error) }, + modifier = Modifier.testTag(ERROR_DIALOG_ACTION_TEST_TAG), ) { Text( text = getRecoveryActionText(error, stringProvider), diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt index e5d22c1a8..a5b73917e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt @@ -82,14 +82,15 @@ class TopLevelDialogController( * for de-duplication. Pass this explicitly when the caller might not be the only observer of * the same error: by the time this runs, another observer may have already reset the live * auth state to `Idle`, so falling back to [currentAuthState] alone would miss the dedup. - * @param onRetry Callback when user clicks retry button + * @param onRetry Callback when user clicks retry button, or `null` when there is nothing to + * retry — [ErrorRecoveryDialog] then renders no action button at all * @param onRecover Callback when user clicks recover button (e.g., navigate to different screen) * @param onDismiss Callback when dialog is dismissed */ fun showErrorDialog( exception: AuthException, errorState: AuthState.Error? = null, - onRetry: (AuthException) -> Unit = {}, + onRetry: ((AuthException) -> Unit)? = null, onRecover: ((AuthException) -> Unit)? = null, onDismiss: () -> Unit = {} ) { @@ -135,9 +136,11 @@ class TopLevelDialogController( ErrorRecoveryDialog( error = state.exception, stringProvider = stringProvider, - onRetry = { exception -> - state.onRetry(exception) - state.onDismiss() + onRetry = state.onRetry?.let { onRetry -> + { exception: AuthException -> + onRetry(exception) + state.onDismiss() + } }, onRecover = state.onRecover?.let { onRecover -> { exception -> @@ -157,7 +160,7 @@ class TopLevelDialogController( private sealed class DialogState { data class ErrorDialog( val exception: AuthException, - val onRetry: (AuthException) -> Unit, + val onRetry: ((AuthException) -> Unit)?, val onRecover: ((AuthException) -> Unit)?, val onDismiss: () -> Unit ) : DialogState() 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..3b1e89598 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 @@ -49,6 +49,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -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,9 @@ fun FirebaseAuthScreen( context = context, configuration = configuration, authUI = authUI, - prefillEmail = prefillEmail.value, + // The reauth user's own address wins: a stale "Continue as" identifier + // would lock the field to an account that cannot be re-proved here. + prefillEmail = reauthPrefillEmail ?: prefillEmail.value, credentialForLinking = pendingLinkingCredential.value, emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value, onContinueWithProvider = continueWithProvider, @@ -319,14 +335,17 @@ fun FirebaseAuthScreen( } }, onManageMfa = { - if (configuration.isMfaEnabled) { - navController.navigate(AuthRoute.MfaEnrollment.route) - } else { - val exception = AuthException.AuthCancelledException( - message = "Multi-factor authentication is disabled in the configuration. " + - "Enable MFA in AuthUIConfiguration to use this feature." - ) - authUI.updateAuthState(AuthState.Error(exception)) + // Inert while armed: this content stays composed beneath the slot. + if (pendingReauthState.value == null) { + if (configuration.isMfaEnabled) { + navController.navigate(AuthRoute.MfaEnrollment.route) + } else { + val exception = AuthException.AuthCancelledException( + message = "Multi-factor authentication is disabled in the configuration. " + + "Enable MFA in AuthUIConfiguration to use this feature." + ) + authUI.updateAuthState(AuthState.Error(exception)) + } } }, onReloadUser = { @@ -359,7 +378,10 @@ fun FirebaseAuthScreen( } }, onNavigate = { route -> - navController.navigate(route.route) + // Inert while armed: this content stays composed beneath the slot. + if (pendingReauthState.value == null) { + navController.navigate(route.route) + } } ) } @@ -425,7 +447,9 @@ fun FirebaseAuthScreen( // Handle email link sign-in (deep links) LaunchedEffect(emailLink) { - if (emailLink != null && emailProvider != null) { + // A link arriving while armed would sign in on the non-reauth configuration, and + // could sign in a different user the armed operation can then never match. + if (emailLink != null && emailProvider != null && pendingReauthState.value == null) { try { // Try to retrieve saved email from DataStore (same-device flow) val savedEmail = @@ -469,26 +493,48 @@ 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)) + } + } + } else if (currentRoute != AuthRoute.Success.route) { + // Nothing to resume, but the slot is gone: land on Success + // rather than whatever route it was covering. + navController.navigate(AuthRoute.Success.route) { + popUpTo(navController.graph.findStartDestination().id) { + inclusive = true + } + launchSingleTop = true } } + // A reauthentication is never a sign-in: onSignInSuccess must not + // fire for it, whether or not an operation was attached. + return@LaunchedEffect + } else { + // Only the ambient re-emission for the armed user is benign; a + // stamp for another account leaves the slot inert, unexplained. + if (state.reauthenticatedUid != null || + authUI.auth.currentUser?.uid != expectedReauthUid + ) { + reauthError.value = + context.getString(R.string.fui_error_reauth_incomplete) + } return@LaunchedEffect } } @@ -515,32 +561,39 @@ 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, is AuthState.RequiresProfileCompletion, -> { + // Reachable while armed (a wrong password in the sub-flow falls back to + // this): navigating would wipe the back stack out from under the slot. + if (pendingReauthState.value != null) return@LaunchedEffect pendingResolver.value = null pendingLinkingCredential.value = null if (currentRoute != AuthRoute.Success.route) { @@ -561,9 +614,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 +643,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 +659,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 +677,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 +690,20 @@ 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) { + // Child screens own their retry logic, so there is nothing to retry here. + onRetry = null, + onRecover = if (pendingReauthState.value != null) null else when (exception) { is AuthException.EmailAlreadyInUseException -> { { navController.navigate(AuthRoute.Email.route) { @@ -693,45 +767,64 @@ 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, onSignInCancelled) { + { pendingReauthOperation.value = null + pendingReauthConfig.value = null pendingReauthState.value = null + reauthSubRoute.value = null + reauthError.value = null authUI.updateAuthState(AuthState.Idle) + // Abandoning reauthentication drops the pending operation for good, so the + // host has to learn it will never run. A cancelled provider attempt does not. + onSignInCancelled() } } + val onReauthAttemptStarted: () -> Unit = remember { { reauthError.value = null } } + val onReauthSubRouteChange: (AuthRoute?) -> Unit = + remember { { route -> reauthSubRoute.value = route } } - // 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.value, + onActiveSubRouteChange = onReauthSubRouteChange, + 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 +1051,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 +1096,7 @@ private fun ReauthSheetContent( context = context, configuration = reauthConfig, authUI = authUI, + prefillEmail = prefillEmail, content = emailContent, onSuccess = {}, onError = {}, @@ -1027,6 +1122,110 @@ 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 onActiveSubRouteChange Invoked when the active sub-flow opens or closes. + * @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: AuthRoute?, + onActiveSubRouteChange: (AuthRoute?) -> Unit, + onAttemptStarted: () -> Unit, + onDismiss: () -> Unit, + content: @Composable (ReauthContentState) -> Unit, +) { + val openSubFlow: (AuthRoute) -> Unit = remember(onActiveSubRouteChange) { + { route -> onActiveSubRouteChange(route) } + } + val onProviderSelected = authUI.rememberOnProviderSelected( + context = context, + activity = activity, + config = reauthConfig, + onNavigate = openSubFlow, + ) + // rememberOnProviderSelected returns a fresh lambda per recomposition, so read it through a + // holder rather than keying on it — otherwise this remember would never hit. + val currentOnProviderSelected = rememberUpdatedState(onProviderSelected) + val onProviderSelectedFromSlot: (AuthProvider) -> Unit = remember(onAttemptStarted) { + { provider -> + onAttemptStarted() + currentOnProviderSelected.value(provider) + } + } + val closeSubFlow: () -> Unit = + remember(onActiveSubRouteChange) { { onActiveSubRouteChange(null) } } + + when (val subRoute = activeSubRoute) { + 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, 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..09c2bf2be --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt @@ -0,0 +1,93 @@ +/* + * 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. + * + * Render the slot so it blocks interaction with the content behind it (a dialog or modal sheet): + * that content stays composed, and the library only makes its own affordances inert. + * + * ```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..b09be9643 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 @@ -88,6 +88,8 @@ enum class EmailAuthMode { * @param onGoToSignUp A callback to switch the UI to the SignUp mode. * @param onGoToSignIn A callback to switch the UI to the SignIn mode. * @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode. + * @param isEmailLocked true when the library fixed [email] and it must not be edited. Render the + * email field read-only while it is true. */ class EmailAuthContentState( val mode: EmailAuthMode, @@ -112,6 +114,7 @@ class EmailAuthContentState( val onGoToSignIn: () -> Unit, val onGoToResetPassword: () -> Unit, val onGoToEmailLinkSignIn: () -> Unit, + val isEmailLocked: Boolean = false, ) /** @@ -156,13 +159,32 @@ 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, - emailTextValue, - passwordTextValue, - confirmPasswordTextValue - ) + val textValues = remember { + listOf( + displayNameValue, + emailTextValue, + passwordTextValue, + confirmPasswordTextValue + ) + } + + val resetTextValues: () -> Unit = remember(textValues, isEmailLocked, prefillEmail) { + { + 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 @@ -196,10 +218,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 +282,7 @@ fun EmailAuthScreen( mode = mode.value, displayName = displayNameValue.value, email = emailTextValue.value, + isEmailLocked = isEmailLocked, password = passwordTextValue.value, confirmPassword = confirmPasswordTextValue.value, isLoading = isLoading, @@ -270,7 +290,9 @@ fun EmailAuthScreen( resetLinkSent = resetLinkSentLocal, emailSignInLinkSent = emailSignInLinkSentLocal, onEmailChange = { email -> - emailTextValue.value = email + if (!isEmailLocked) { + emailTextValue.value = email + } }, onPasswordChange = { password -> passwordTextValue.value = password @@ -362,23 +384,31 @@ 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 = "" } - mode.value = EmailAuthMode.ResetPassword - resetLinkSentLocal = false + // Reauthentication is a modal confirmation of the signed-in account: diverting it to + // an out-of-band email step strands the pending operation behind something it can't see. + if (!configuration.isReauthenticationMode) { + resetTextValues() + mode.value = EmailAuthMode.ResetPassword + resetLinkSentLocal = false + } }, onGoToEmailLinkSignIn = { - textValues.forEach { it.value = "" } - mode.value = EmailAuthMode.EmailLinkSignIn - emailSignInLinkSentLocal = false + if (!configuration.isReauthenticationMode) { + resetTextValues() + mode.value = EmailAuthMode.EmailLinkSignIn + emailSignInLinkSentLocal = false + } }, ) @@ -414,7 +444,8 @@ private fun DefaultEmailAuthContent( onGoToSignUp = state.onGoToSignUp, onGoToResetPassword = state.onGoToResetPassword, onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn, - onNavigateBack = onCancel + onNavigateBack = onCancel, + isEmailLocked = state.isEmailLocked, ) } @@ -422,6 +453,7 @@ private fun DefaultEmailAuthContent( SignInEmailLinkUI( configuration = configuration, email = state.email, + isEmailLocked = state.isEmailLocked, isLoading = state.isLoading, emailSignInLinkSent = state.emailSignInLinkSent, onEmailChange = state.onEmailChange, @@ -446,7 +478,8 @@ private fun DefaultEmailAuthContent( onConfirmPasswordChange = state.onConfirmPasswordChange, onSignUpClick = state.onSignUpClick, onGoToSignIn = state.onGoToSignIn, - onNavigateBack = onCancel + onNavigateBack = onCancel, + isEmailLocked = state.isEmailLocked, ) } @@ -455,6 +488,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..3e687ca9f 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 @@ -67,6 +67,7 @@ fun ResetPasswordUI( onSendResetLink: () -> Unit, onGoToSignIn: () -> Unit, onNavigateBack: (() -> Unit)? = null, + isEmailLocked: Boolean = false, ) { val context = LocalContext.current @@ -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..fdbad6696 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 @@ -74,6 +74,7 @@ fun SignInEmailLinkUI( onGoToSignIn: () -> Unit, onGoToResetPassword: () -> Unit, onNavigateBack: (() -> Unit)? = null, + isEmailLocked: Boolean = false, ) { val provider = configuration.providers.filterIsInstance().first() val stringProvider = LocalAuthUIStringProvider.current @@ -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..6214f0a9d 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,22 @@ fun SignInUI( } } + val isSignUpOffered = provider.isNewAccountsAllowed && + configuration.isNewEmailAccountsAllowed && + !configuration.isReauthenticationMode + + // Both routes leave this screen for an out-of-band email step, which a reauthentication sheet + // cannot observe — and an email link reopens the app with no pending operation left to resume. + val isPasswordRecoveryOffered = !configuration.isReauthenticationMode + val isEmailLinkSignInOffered = + provider.isEmailLinkSignInEnabled && !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 +169,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 +194,7 @@ fun SignInUI( value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, @@ -199,29 +216,31 @@ fun SignInUI( } ) Spacer(modifier = Modifier.height(8.dp)) - TextButton( - modifier = Modifier - .align(Alignment.Start), - onClick = { - onGoToResetPassword() - }, - enabled = !isLoading, - contentPadding = PaddingValues.Zero - ) { - Text( - modifier = modifier, - text = stringProvider.troubleSigningIn, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - textDecoration = TextDecoration.Underline - ) + if (isPasswordRecoveryOffered) { + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = stringProvider.troubleSigningIn, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(8.dp)) } - Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier .align(Alignment.End), ) { - if (provider.isNewAccountsAllowed) { + if (isSignUpOffered) { Button( onClick = { onGoToSignUp() @@ -250,7 +269,7 @@ fun SignInUI( } // Show toggle to email link sign-in - if (provider.isEmailLinkSignInEnabled) { + if (isEmailLinkSignInOffered) { Spacer(modifier = Modifier.height(64.dp)) Row( modifier = Modifier.fillMaxWidth(), diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt index 7b6ba03c5..611ed0dcc 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt @@ -69,6 +69,7 @@ fun SignUpUI( onGoToSignIn: () -> Unit, onSignUpClick: () -> Unit, onNavigateBack: (() -> Unit)? = null, + isEmailLocked: Boolean = false, ) { val provider = configuration.providers.filterIsInstance().first() val context = LocalContext.current @@ -147,6 +148,7 @@ fun SignUpUI( value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 52cd8b87d..9ae250af6 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -178,6 +178,11 @@ Sending... That email address doesn\'t match an existing account + + None of the available sign-in methods is linked to your account. + That did not confirm your identity for this account. Please try again. + 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..65b8d2171 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 @@ -27,6 +27,7 @@ import com.firebase.ui.auth.util.EmailLinkPersistenceManager import com.firebase.ui.auth.util.MockPersistenceManager import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.auth.ActionCodeSettings @@ -267,6 +268,82 @@ 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" + ) + assertWithMessage("expected reauthentication mode to reject account creation").fail() + } 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" + ) + assertWithMessage("expected isNewEmailAccountsAllowed=false to veto account creation") + .fail() + } 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 +765,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) + assertWithMessage("expected a null currentUser after reauth to throw").fail() + } 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..e0be75edc --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt @@ -0,0 +1,978 @@ +/* + * 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.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.R +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.ui.components.ERROR_DIALOG_ACTION_TEST_TAG +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.AuthResult +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 both `onRecover` and `onRetry` are withheld, so the + * dialog has no action to offer and must not render an action button that silently dismisses + * instead of recovering. 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 offers no action 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() + + // Ungated, onRecover would navigate the outer NavHost to the non-reauth email screen. + // With both callbacks withheld the button has nothing to do, so it must not render. + composeTestRule.onNodeWithTag(ERROR_DIALOG_ACTION_TEST_TAG).assertDoesNotExist() + composeTestRule.onNodeWithText(stringProvider.dismissAction).assertExists() + composeTestRule.onAllNodesWithText(stringProvider.passwordHint).assertCountEquals(0) + } + + /** + * The control for the test above: outside reauthentication the same error still offers its + * recovery action, and it still navigates to the email screen. + */ + @Test + fun `a recoverable error still offers its recovery action outside reauthentication`() { + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + ) + } + + 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.onNodeWithTag(ERROR_DIALOG_ACTION_TEST_TAG).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(stringProvider.passwordHint).assertExists() + } + + /** + * 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() + } + + /** + * Arming a second sensitive operation while the first is still pending must replace it. Value + * equality on [AuthState.ReauthenticationRequired] made the second write equal to the current + * one, which [kotlinx.coroutines.flow.MutableStateFlow] silently drops — so the screen kept the + * *first* lambda and ran the wrong sensitive operation after reauthentication. + */ + @Test + fun `arming a second operation for the same user replaces the first`() { + val user = passwordOnlyUser("linked@example.com") + val ran = mutableListOf() + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + // Same user, same (absent) reason: the two states differ only in the attached operation. + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { ran.add("first") }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { ran.add("second") }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + 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) { ran.isNotEmpty() } + composeTestRule.waitForIdle() + + assertThat(ran).containsExactly("second") + } + + /** + * Reauthentication is not a sign-in. With no operation attached the library still has to consume + * the matched stamp and stop there — falling through published the reauthentication's + * [com.google.firebase.auth.AuthResult] to `onSignInSuccess`, which federated providers stamp + * and the email provider does not, so the same public callback behaved differently by provider. + */ + @Test + fun `a matched reauthentication with no pending operation does not report a sign-in`() { + val user = passwordOnlyUser("linked@example.com") + val authResult = mock(AuthResult::class.java) + `when`(authResult.user).thenReturn(user) + var signInSuccessCount = 0 + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = { signInSuccessCount++ }, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + }, + authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.ReauthenticationRequired(user, retryOperation = null)) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + // The federated stamp shape: a non-null AuthResult alongside the reauthenticated uid. + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Success( + result = authResult, + user = user, + reauthenticatedUid = user.uid, + ) + ) + } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(signInSuccessCount).isEqualTo(0) + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() + composeTestRule.onNodeWithText("AUTHENTICATED").assertExists() + } + + /** + * The uid comparison is the whole guarantee: a stamped success for *another* account is not + * evidence that the armed user re-proved anything, so the operation must not run and the slot + * must stay up. Without this the comparison could be weakened to a null check unnoticed. + */ + @Test + fun `a stamped Success for a different uid does not run the pending operation`() { + val armedUser = passwordOnlyUser("armed@example.com") + val otherUser = userLinkedTo("google.com", "other@example.com") + var retryRan = false + var captured: ReauthContentState? = null + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { state -> + captured = state + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(armedUser, retryOperation = { retryRan = true }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + assertThat(armedUser.uid).isNotEqualTo(otherUser.uid) + + composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Loading()) } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Success( + result = null, + user = otherUser, + reauthenticatedUid = otherUser.uid, + ) + ) + } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(retryRan).isFalse() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + assertThat(requireNotNull(captured).error) + .isEqualTo(context.getString(R.string.fui_error_reauth_incomplete)) + } + + /** + * A wrong password for an unverified account ends up here: the consumed Error resets to Idle, + * the combine falls back to the live session, and that yields RequiresEmailVerification. It + * navigates with `popUpTo(inclusive = true)`, which would wipe the stack under the armed slot. + */ + @Test + fun `RequiresEmailVerification does not navigate while reauthentication is armed`() { + val user = passwordOnlyUser("linked@example.com") + var retryRan = false + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + }, + authenticatedContent = { _, _ -> + Text(text = "AUTHENTICATED", modifier = Modifier.testTag("authenticated")) + }, + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.RequiresEmailVerification(user = user, email = "linked@example.com") + ) + } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("authenticated").assertDoesNotExist() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + assertThat(retryRan).isFalse() + } + + /** + * Dismissing abandons the operation for good, and `withReauth` has already returned normally — + * so the host has no other way to learn its sensitive operation will never run. + */ + @Test + fun `dismissing the reauth slot reports the flow as cancelled exactly once`() { + val user = passwordOnlyUser("linked@example.com") + var cancelledCount = 0 + var retryRan = false + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = { cancelledCount++ }, + reauthContent = { state -> + Button( + onClick = state.onDismiss, + modifier = Modifier.testTag("dismiss_reauth") + ) { + Text("Cancel") + } + } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + ) + } + composeTestRule.waitForIdle() + assertThat(cancelledCount).isEqualTo(0) + + composeTestRule.onNodeWithTag("dismiss_reauth").performClick() + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(cancelledCount).isEqualTo(1) + assertThat(retryRan).isFalse() + composeTestRule.onNodeWithTag("dismiss_reauth").assertDoesNotExist() + } +} 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..7efa8b786 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt @@ -0,0 +1,418 @@ +/* + * 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.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 = {}, + ) + } + } + + /** + * Resetting the text fields (the mode switches all do it) must put the locked address back, + * not leave the user on an empty read-only field. + */ + @Test + fun `the locked email is restored when the text fields are reset`() { + var email: String? = null + var isEmailLocked: Boolean? = 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 -> + email = state.email + isEmailLocked = state.isEmailLocked + goToSignIn = state.onGoToSignIn + }, + ) + } + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { requireNotNull(goToSignIn).invoke() } + composeTestRule.waitForIdle() + + assertThat(email).isEqualTo(prefillEmail) + assertThat(isEmailLocked).isTrue() + } + + /** + * The lock is wired into every mode that shows the address, not only SignIn — reauthentication + * cannot reach those modes any more, but a custom `emailContent` slot and a future route can. + */ + @Test + fun `ResetPasswordUI renders a locked email read-only`() { + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + ResetPasswordUI( + configuration = reauthConfiguration(), + isLoading = false, + email = prefillEmail, + resetLinkSent = false, + onEmailChange = {}, + onSendResetLink = {}, + onGoToSignIn = {}, + isEmailLocked = true, + ) + } + } + + composeTestRule.onNodeWithText(stringProvider.recoverPasswordPageTitle).assertExists() + composeTestRule.onNodeWithText(prefillEmail) + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText)) + } + + /** The same for the email-link route, the other mode that shows the address. */ + @Test + fun `SignInEmailLinkUI renders a locked email read-only`() { + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + SignInEmailLinkUI( + configuration = reauthConfigurationWithEmailLink(), + isLoading = false, + emailSignInLinkSent = false, + email = prefillEmail, + onEmailChange = {}, + onSignInWithEmailLink = {}, + onGoToSignIn = {}, + onGoToResetPassword = {}, + isEmailLocked = true, + ) + } + } + + composeTestRule.onNodeWithText(stringProvider.passwordHint).assertDoesNotExist() + composeTestRule.onNodeWithText(prefillEmail) + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.SetText)) + } + + /** + * Both routes hand off to an out-of-band email step the reauthentication sheet cannot observe, + * and an email link reopens the app with no pending operation left to resume. + */ + @Test + fun `neither password recovery nor email-link sign-in is offered while reauthenticating`() { + composeTestRule.setContent { + EmailAuthScreenUnderTest(reauthConfigurationWithEmailLink(), prefill = prefillEmail) + } + + // The password field proves this is the reauth SignIn screen, still usable as intended. + composeTestRule.onNodeWithText(stringProvider.passwordHint).assertExists() + composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).assertDoesNotExist() + composeTestRule.onNodeWithText(stringProvider.signInWithEmailLink, ignoreCase = true) + .assertDoesNotExist() + } + + /** Defence in depth: a custom `emailContent` slot cannot reach those modes either. */ + @Test + fun `the reauth mode switches to ResetPassword and EmailLink are inert`() { + val observed = mutableListOf() + var goToResetPassword: (() -> Unit)? = null + var goToEmailLinkSignIn: (() -> Unit)? = null + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + EmailAuthScreen( + context = applicationContext, + configuration = reauthConfigurationWithEmailLink(), + authUI = authUI, + prefillEmail = prefillEmail, + onSuccess = {}, + onError = {}, + onCancel = {}, + content = { state -> + observed.add(state.mode) + goToResetPassword = state.onGoToResetPassword + goToEmailLinkSignIn = state.onGoToEmailLinkSignIn + }, + ) + } + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { requireNotNull(goToResetPassword).invoke() } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { requireNotNull(goToEmailLinkSignIn).invoke() } + composeTestRule.waitForIdle() + + assertThat(observed.toSet()).containsExactly(EmailAuthMode.SignIn) + } + + /** + * 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 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) + goToSignIn = state.onGoToSignIn + }, + ) + } + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { requireNotNull(goToSignIn).invoke() } + composeTestRule.waitForIdle() + + 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) From 90074dc7cbfc512ffc386e98eb0772597ab41423 Mon Sep 17 00:00:00 2001 From: demolaf Date: Fri, 28 Aug 2026 14:59:36 +0100 Subject: [PATCH 2/7] fix(auth): address reauth review findings and retain state across recreation --- auth/README.md | 7 +- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 3 +- .../auth/configuration/AuthUIConfiguration.kt | 2 + .../auth_provider/AuthProvider.kt | 3 + .../EmailAuthProvider+FirebaseAuthUI.kt | 5 + .../ui/auth/ui/components/AuthTextField.kt | 16 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 224 +++++++++----- .../ui/auth/ui/screens/ReauthContentState.kt | 6 +- .../auth/ui/screens/email/EmailAuthScreen.kt | 36 ++- .../ui/auth/ui/screens/email/SignInUI.kt | 16 + auth/src/main/res/values/strings.xml | 4 + .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 37 +++ .../EmailAuthProviderFirebaseAuthUITest.kt | 146 +++++++++ .../OAuthProviderFirebaseAuthUITest.kt | 118 ++++++++ ...irebaseAuthScreenReauthContentStateTest.kt | 281 +++++++++++++++++- .../EmailAuthScreenReauthEmailLockTest.kt | 38 +++ .../ui/auth/ui/screens/email/SignInUITest.kt | 50 +++- 17 files changed, 892 insertions(+), 100 deletions(-) diff --git a/auth/README.md b/auth/README.md index 65ece1df7..86eda98e8 100644 --- a/auth/README.md +++ b/auth/README.md @@ -1021,7 +1021,9 @@ reauthContent = { state -> } ``` -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 and calls `onSignInCancelled`, since the pending operation will never run; backing out of a single provider attempt returns to the slot with the operation still pending and does *not* call `onSignInCancelled`. Render the slot so it blocks interaction with the content behind it — that content stays composed, and the library only makes its own affordances inert. +While this slot is shown the library suppresses its own loading and error dialogs, so render `state.isLoading` and `state.error` yourself. `state.error` is the same message the library's own error dialog would have shown, and `state.exception` carries the exception behind it when you need to branch on the failure type. On success the library resumes the operation that required reauthentication — there is nothing to retry. `state.onDismiss` abandons reauthentication and calls `onSignInCancelled`, so any pending operation will never run; backing out of a single provider attempt returns to the slot with the operation still pending and does *not* call `onSignInCancelled`. Render the slot so it blocks interaction with the content behind it — that content stays composed, and the library only makes its own affordances inert. + +An armed reauthentication survives Activity recreation: rotating keeps the pending operation, the latched `state.error`, and any active email/phone sub-flow. `state.exception` is not saveable, so after a recreation `state.error` still carries the message while `state.exception` is `null` — branch on the type only for a failure your own composition observed. The pending operation cannot survive process death, and if it is lost the flow emits an `AuthState.Error` explaining that identity confirmation was interrupted rather than dropping the operation silently. 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. @@ -1049,11 +1051,12 @@ lifecycleScope.launch { 3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers. 4. On successful reauthentication, retries the operation automatically and emits `AuthState.Success` or `AuthState.Error`. +The armed reauthentication lives on the process-cached `FirebaseAuthUI`, so it survives Activity recreation; it does not survive process death, and a lost operation is reported as an `AuthState.Error` rather than silently dropped. + **Activity-based alternative:** use `createReauthFlow` to start a standalone reauthentication activity scoped to the current user's linked providers, returning an `AuthFlowController`. ```kotlin val reauth = authUI.createReauthFlow( - context = context, configuration = authUIConfiguration { // Providers are automatically filtered to those linked to the current user }, 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 c6c592d62..84d24f1f2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -320,7 +320,8 @@ class FirebaseAuthUI private constructor( val current = _authStateFlow.value if (current is AuthState.Success || current is AuthState.RequiresEmailVerification || - current is AuthState.RequiresProfileCompletion + current is AuthState.RequiresProfileCompletion || + current is AuthState.ReauthenticationRequired ) { _authStateFlow.value = AuthState.Idle } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index 7eb92114e..a424bfed9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt @@ -248,6 +248,7 @@ class AuthUIConfiguration( isCredentialManagerEnabled = this.isCredentialManagerEnabled, isMfaEnabled = this.isMfaEnabled, isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled, + isCredentialLinkingEnabled = this.isCredentialLinkingEnabled, tosUrl = this.tosUrl, privacyPolicyUrl = this.privacyPolicyUrl, logo = this.logo, @@ -255,6 +256,7 @@ class AuthUIConfiguration( isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, isDisplayNameRequired = this.isDisplayNameRequired, isProviderChoiceAlwaysShown = this.isProviderChoiceAlwaysShown, + legacyFetchSignInWithEmail = this.legacyFetchSignInWithEmail, transitions = this.transitions, isReauthenticationMode = isReauthenticationMode, ) diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt index 53ca93660..6725929b8 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt @@ -1005,6 +1005,9 @@ abstract class AuthProvider(open val providerId: String, open val providerName: internal fun canLinkCredential(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { val currentUser = auth.currentUser return config.isCredentialLinkingEnabled + // Linking is not a proof of identity: diverting a reauthentication to + // linkWithCredential would yield an unstamped Success the guard must reject. + && !config.isReauthenticationMode && currentUser != null && !currentUser.isAnonymous } 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 8f76ff0bb..8fab40ede 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 @@ -1091,6 +1091,11 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink( } // Clear DataStore after success persistenceManager.clear(context) + // In reauth mode the stamped Success is already published and there is no AuthResult, so + // updateAuthStateWithResult would overwrite the stamp with Idle and orphan the operation. + if (result == null && config.isReauthenticationMode) { + return null + } updateAuthStateWithResult(result) return result } catch (e: CancellationException) { 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 b404e62a6..38f7f10f3 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 @@ -42,10 +42,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.R import com.firebase.ui.auth.configuration.PasswordRule import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.validators.EmailValidator @@ -107,6 +110,7 @@ fun AuthTextField( readOnly: Boolean = false, ) { var passwordVisible by remember { mutableStateOf(false) } + val localContext = LocalContext.current // Automatically set the correct keyboard type based on validator or field type val resolvedKeyboardOptions = remember(validator, isSecureTextField, keyboardOptions) { @@ -126,7 +130,17 @@ fun AuthTextField( TextField( modifier = modifier - .fillMaxWidth(), + .fillMaxWidth() + // A read-only field looks identical to an editable one, so state it semantically. + .then( + if (readOnly) { + Modifier.semantics { + stateDescription = localContext.getString(R.string.fui_text_field_read_only) + } + } else { + Modifier + } + ), value = value, onValueChange = { newValue -> onValueChange(newValue) 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 3b1e89598..b1b77bfcc 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 @@ -50,6 +50,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -80,6 +82,7 @@ import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringPro import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController +import com.firebase.ui.auth.ui.components.getRecoveryMessage import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController import com.firebase.ui.auth.mfa.MfaChallengeContentState import com.firebase.ui.auth.mfa.MfaEnrollmentContentState @@ -117,7 +120,9 @@ import kotlinx.coroutines.tasks.await * 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. + * receiving a [ReauthContentState]. The library owns the credential exchange. An armed + * reauthentication survives Activity recreation (rotation) but not process death; if it is lost + * the flow surfaces an error rather than dropping the pending operation silently. * * @since 10.0.0 */ @@ -160,8 +165,36 @@ 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) } + // Saved so a re-derived arming (rotation) can be told apart from a genuinely new one, and so a + // recreation that cannot re-derive it is reported rather than dropped silently. + val reauthArmedUid = rememberSaveable { mutableStateOf(null) } + // The exception is not saveable, its already-localized message is. Written only together, so + // the slot's `error` and `exception` can never disagree. + val reauthFailure = remember { mutableStateOf(null) } + val reauthErrorMessage = rememberSaveable { mutableStateOf(null) } + val reauthSubRoute = + rememberSaveable(stateSaver = ReauthSubRouteSaver) { mutableStateOf(null) } + val setReauthFailure: (AuthException?) -> Unit = remember(stringProvider) { + { failure -> + reauthFailure.value = failure + reauthErrorMessage.value = failure?.let { getRecoveryMessage(it, stringProvider) } + } + } + val clearPendingReauth: () -> Unit = remember(setReauthFailure) { + { + pendingReauthOperation.value = null + pendingReauthConfig.value = null + pendingReauthState.value = null + reauthArmedUid.value = null + reauthSubRoute.value = null + setReauthFailure(null) + } + } + // Idle would drop the arming from the process-cached FirebaseAuthUI, so an Activity recreation + // could no longer re-derive the pending operation. Re-emit the arming instead. + val resetTransientAuthState: () -> Unit = remember(authUI) { + { authUI.updateAuthState(pendingReauthState.value ?: AuthState.Idle) } + } val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } val prefillEmail = remember { mutableStateOf(null) } val reauthPrefillEmail = remember(authUI, configuration.isReauthenticationMode) { @@ -169,9 +202,9 @@ fun FirebaseAuthScreen( } val lastSignInPreference = remember { mutableStateOf(null) } - // Last-processed AuthState, so the Idle branch below can tell a genuine reset apart from - // Idle-as-a-side-effect of consuming a notification (see AuthState.isNotification). - val previousAuthState = remember { mutableStateOf(AuthState.Idle) } + // Lets the Idle branch below tell a genuine reset apart from consuming a notification + // (AuthState.isNotification) and from collectAsState's placeholder Idle (null == none yet). + val previousAuthState = remember { mutableStateOf(null) } val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) { getStartRoute(configuration) } @@ -201,9 +234,14 @@ fun FirebaseAuthScreen( }, onSignInFailure = onSignInFailure, ) - val onProviderSelected: (AuthProvider) -> Unit = { provider -> - if (pendingReauthState.value == null) { - onOuterProviderSelected(provider) + // Remembered so the method picker is not recomposed on every parent recomposition; + // rememberOnProviderSelected returns a fresh lambda each time, so read it through a holder. + val currentOuterProviderSelected = rememberUpdatedState(onOuterProviderSelected) + val onProviderSelected: (AuthProvider) -> Unit = remember { + { provider -> + if (pendingReauthState.value == null) { + currentOuterProviderSelected.value(provider) + } } } val continueWithProvider: (String) -> Unit = { providerId -> @@ -488,6 +526,22 @@ fun FirebaseAuthScreen( val previous = previousAuthState.value previousAuthState.value = state val currentRoute = navController.currentBackStackEntry?.destination?.route + // Armed before this composition existed, but the attempt in flight died with it: + // the operation can no longer run, so report it instead of dropping it silently. + if (reauthArmedUid.value != null && + pendingReauthState.value == null && + state is AuthState.Loading + ) { + clearPendingReauth() + authUI.updateAuthState( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_interrupted) + ) + ) + ) + return@LaunchedEffect + } when (state) { is AuthState.Success -> { pendingResolver.value = null @@ -497,11 +551,7 @@ fun FirebaseAuthScreen( if (expectedReauthUid != null) { if (state.reauthenticatedUid == expectedReauthUid) { val retry = pendingReauthOperation.value - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null - reauthSubRoute.value = null - reauthError.value = null + clearPendingReauth() if (retry != null) { authUI.updateAuthState(AuthState.Loading()) coroutineScope.launch { @@ -532,8 +582,11 @@ fun FirebaseAuthScreen( if (state.reauthenticatedUid != null || authUI.auth.currentUser?.uid != expectedReauthUid ) { - reauthError.value = - context.getString(R.string.fui_error_reauth_incomplete) + setReauthFailure( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_incomplete) + ) + ) } return@LaunchedEffect } @@ -563,11 +616,7 @@ fun FirebaseAuthScreen( is AuthState.ReauthenticationRequired -> { 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 + clearPendingReauth() authUI.updateAuthState( AuthState.Error( AuthException.UnknownException( @@ -577,9 +626,17 @@ fun FirebaseAuthScreen( ) return@LaunchedEffect } + // The durability re-emit and a post-recreation re-derivation are the same + // arming: keep the latched error and sub-flow. A new one clears both. + val sameArming = pendingReauthState.value === state || + (pendingReauthState.value == null && + reauthArmedUid.value == state.user.uid) + if (!sameArming) { + reauthSubRoute.value = null + setReauthFailure(null) + } + reauthArmedUid.value = state.user.uid pendingReauthOperation.value = state.retryOperation - reauthSubRoute.value = null - reauthError.value = null pendingReauthState.value = state pendingReauthConfig.value = configuration.copy( providers = linked, @@ -605,6 +662,18 @@ fun FirebaseAuthScreen( } is AuthState.RequiresMfa -> { + // An MFA-enrolled account cannot complete reauthentication today; pushing + // the challenge under the slot would leave a dead UI with no explanation. + if (pendingReauthState.value != null) { + authUI.updateAuthState( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_mfa_unsupported) + ) + ) + ) + return@LaunchedEffect + } pendingResolver.value = state.resolver if (currentRoute != AuthRoute.MfaChallenge.route) { navController.navigate(AuthRoute.MfaChallenge.route) { @@ -615,14 +684,10 @@ fun FirebaseAuthScreen( is AuthState.Cancelled -> { if (pendingReauthState.value != null) { - authUI.updateAuthState(AuthState.Idle) + resetTransientAuthState() return@LaunchedEffect } - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null - reauthSubRoute.value = null - reauthError.value = null + clearPendingReauth() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -640,11 +705,7 @@ fun FirebaseAuthScreen( // Hosted by FirebaseAuthActivity: its own authStateFlow collector // independently finishes the activity and resets state on Aborted. if (activity !is FirebaseAuthActivity) { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null - reauthSubRoute.value = null - reauthError.value = null + clearPendingReauth() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -655,12 +716,8 @@ fun FirebaseAuthScreen( is AuthState.Idle -> { // A notification resets to Idle purely to avoid leaking to a freshly // created screen — that's not a request to leave the current one. - if (!previous.isNotification) { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null - reauthSubRoute.value = null - reauthError.value = null + if (previous != null && !previous.isNotification) { + clearPendingReauth() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -692,9 +749,9 @@ fun FirebaseAuthScreen( if (reauthSlotActive) { if (exception !is AuthException.AuthCancelledException) { - reauthError.value = exception.message + setReauthFailure(exception) } - authUI.updateAuthState(AuthState.Idle) + resetTransientAuthState() return@LaunchedEffect } @@ -759,7 +816,7 @@ fun FirebaseAuthScreen( } ) // Consumed immediately so this doesn't leak to a freshly created screen. - authUI.updateAuthState(AuthState.Idle) + resetTransientAuthState() } } @@ -771,20 +828,20 @@ fun FirebaseAuthScreen( LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading) } - val onReauthDismiss: () -> Unit = remember(authUI, onSignInCancelled) { + // Keyed on authUI only: onSignInCancelled is a caller lambda that is typically not + // remembered, so keying on it would defeat the remember entirely. + val currentOnSignInCancelled = rememberUpdatedState(onSignInCancelled) + val onReauthDismiss: () -> Unit = remember(authUI, clearPendingReauth) { { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null - reauthSubRoute.value = null - reauthError.value = null + clearPendingReauth() authUI.updateAuthState(AuthState.Idle) // Abandoning reauthentication drops the pending operation for good, so the // host has to learn it will never run. A cancelled provider attempt does not. - onSignInCancelled() + currentOnSignInCancelled.value() } } - val onReauthAttemptStarted: () -> Unit = remember { { reauthError.value = null } } + val onReauthAttemptStarted: () -> Unit = + remember(setReauthFailure) { { setReauthFailure(null) } } val onReauthSubRouteChange: (AuthRoute?) -> Unit = remember { { route -> reauthSubRoute.value = route } } @@ -801,7 +858,9 @@ fun FirebaseAuthScreen( emailContent = emailContent, phoneContent = phoneContent, isLoading = loadingState != null, - error = reauthError.value, + // The same string ErrorRecoveryDialog would have shown for this failure. + error = reauthErrorMessage.value, + exception = reauthFailure.value, activeSubRoute = reauthSubRoute.value, onActiveSubRouteChange = onReauthSubRouteChange, onAttemptStarted = onReauthAttemptStarted, @@ -831,6 +890,19 @@ fun FirebaseAuthScreen( } } +// Saved as its route String — AuthRoute is not Parcelable, and Email/Phone are the only reauth +// sub-flows, so any other saved route restores as "no sub-flow" rather than a dead branch. +private val ReauthSubRouteSaver: Saver = Saver( + save = { it?.route }, + restore = { route -> + when (route) { + AuthRoute.Email.route -> AuthRoute.Email + AuthRoute.Phone.route -> AuthRoute.Phone + else -> null + } + }, +) + sealed class AuthRoute(val route: String) { object MethodPicker : AuthRoute("auth_method_picker") object Email : AuthRoute("auth_email") @@ -1134,7 +1206,7 @@ private fun ReauthSheetContent( * * @param activeSubRoute Which sub-flow, if any, currently replaces [content]. * @param onActiveSubRouteChange Invoked when the active sub-flow opens or closes. - * @param onAttemptStarted Invoked just before a provider attempt begins. + * @param onAttemptStarted Invoked just before an in-place credential attempt begins. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -1148,6 +1220,7 @@ private fun CustomReauthContent( phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, isLoading: Boolean, error: String?, + exception: Exception?, activeSubRoute: AuthRoute?, onActiveSubRouteChange: (AuthRoute?) -> Unit, onAttemptStarted: () -> Unit, @@ -1168,25 +1241,30 @@ private fun CustomReauthContent( val currentOnProviderSelected = rememberUpdatedState(onProviderSelected) val onProviderSelectedFromSlot: (AuthProvider) -> Unit = remember(onAttemptStarted) { { provider -> - onAttemptStarted() + // Email and Phone only open a sub-flow; clearing the latched error there would wipe a + // real failure on a mis-tap, and `error` is documented to survive backing out. + if (provider !is AuthProvider.Email && provider !is AuthProvider.Phone) { + onAttemptStarted() + } currentOnProviderSelected.value(provider) } } val closeSubFlow: () -> Unit = remember(onActiveSubRouteChange) { { onActiveSubRouteChange(null) } } + val slotState = ReauthContentState( + user = reauthState.user, + reason = reauthState.reason, + providers = reauthConfig.providers, + onProviderSelected = onProviderSelectedFromSlot, + isLoading = isLoading, + error = error, + onDismiss = onDismiss, + exception = exception, + ) + when (val subRoute = activeSubRoute) { - null -> content( - ReauthContentState( - user = reauthState.user, - reason = reauthState.reason, - providers = reauthConfig.providers, - onProviderSelected = onProviderSelectedFromSlot, - isLoading = isLoading, - error = error, - onDismiss = onDismiss, - ) - ) + null -> content(slotState) AuthRoute.Email -> ModalBottomSheet( onDismissRequest = closeSubFlow, @@ -1219,10 +1297,18 @@ private fun CustomReauthContent( ) } - 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." - ) + else -> { + // No sub-flow for this route: keep the caller's slot rather than crashing + // composition. Add a branch when a new provider gains its own screen. + LaunchedEffect(subRoute) { + Log.w( + "FirebaseAuthScreen", + "No reauth sub-flow for ${subRoute?.route}; staying on the slot" + ) + onActiveSubRouteChange(null) + } + content(slotState) + } } } 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 index 09c2bf2be..87a16a0c9 100644 --- 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 @@ -64,8 +64,9 @@ import com.google.firebase.auth.FirebaseUser * @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 error A localized error message for the last failed attempt, or `null` if it did not fail. Persists until the next credential attempt starts, so it can be rendered inline. Backing out of an attempt is not a failure and leaves this unchanged. Survives Activity recreation. * @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. + * @property exception The exception behind [error], or `null` if the last attempt did not fail. Branch on its type when a message alone is not enough. Not retained across Activity recreation, which leaves [error] set with this `null`. * * @since 10.0.0 */ @@ -90,4 +91,7 @@ data class ReauthContentState( /** Callback to abandon reauthentication and drop the pending operation. */ val onDismiss: () -> Unit = {}, + + /** The exception behind [error], if the last attempt failed. Dropped on recreation. */ + val exception: Exception? = null, ) 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 b09be9643..155f60fa8 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 @@ -215,25 +215,31 @@ fun EmailAuthScreen( dialogController?.showErrorDialog( exception = exception, errorState = state, - onRetry = { ex -> - when (ex) { - is AuthException.UserNotFoundException -> { - if (isSignUpOffered) { - // User not found, but new accounts are allowed, switch to sign-up - mode.value = EmailAuthMode.SignUp + // Every branch below is inert while reauthenticating, so an action button + // would only dismiss the dialog — leave it without one. + onRetry = if (configuration.isReauthenticationMode) { + null + } else { + { ex: AuthException -> + when (ex) { + is AuthException.UserNotFoundException -> { + if (isSignUpOffered) { + // User not found, but new accounts are allowed, switch to sign-up + mode.value = EmailAuthMode.SignUp + } } - } - is AuthException.InvalidCredentialsException -> { - // User can retry sign in with corrected credentials - } + is AuthException.InvalidCredentialsException -> { + // User can retry sign in with corrected credentials + } - is AuthException.EmailAlreadyInUseException -> { - // Switch to sign-in mode - mode.value = EmailAuthMode.SignIn - } + is AuthException.EmailAlreadyInUseException -> { + // Switch to sign-in mode + mode.value = EmailAuthMode.SignIn + } - else -> Unit + else -> Unit + } } }, onRecover = if (exception is AuthException.DifferentSignInMethodRequiredException) { 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 6214f0a9d..4c18d21de 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 @@ -55,6 +55,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.R import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider @@ -71,6 +72,9 @@ import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +/** Test tag on the notice explaining that reauthentication here needs the account's password. */ +internal const val REAUTH_PASSWORD_NOTICE_TEST_TAG = "ReauthPasswordRequiredNotice" + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SignInUI( @@ -236,6 +240,18 @@ fun SignInUI( } Spacer(modifier = Modifier.height(8.dp)) } + if (configuration.isReauthenticationMode) { + // Firebase reports "password" for passwordless email-link accounts too, so such a + // user is offered a password field they can never fill. Say so instead of stalling. + Text( + modifier = Modifier + .align(Alignment.Start) + .testTag(REAUTH_PASSWORD_NOTICE_TEST_TAG), + text = context.getString(R.string.fui_reauth_password_required_notice), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + } Row( modifier = Modifier .align(Alignment.End), diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 9ae250af6..20191fd41 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -181,7 +181,11 @@ None of the available sign-in methods is linked to your account. That did not confirm your identity for this account. Please try again. + Confirming your identity was interrupted. Please try that action again. You cannot create a new account while confirming your identity. + This account uses two-step verification, which cannot be used to confirm your identity here. + Confirming your identity here needs this account\'s password. If you sign in with an email link instead of a password, this account cannot be confirmed with a password. + Read-only An unknown error occurred. diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt index 78e7f0dd3..d025157a7 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -261,6 +261,43 @@ class FirebaseAuthUIAuthStateTest { assertThat(states[2]).isEqualTo(AuthState.Idle) // After sign-out } + /** + * A host calling raw `auth.signOut()` while a reauthentication is armed used to leave the + * internal state at ReauthenticationRequired: the combine keeps preferring it, so the reauth UI + * stays up over a signed-out session and every provider fails with an untranslated "no user". + */ + @Test + fun `authStateFlow() clears an armed ReauthenticationRequired when the user signs out`() = + runBlocking { + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + `when`(mockFirebaseUser.isEmailVerified).thenReturn(true) + `when`(mockFirebaseUser.providerData).thenReturn(emptyList()) + + val listenerCaptor = ArgumentCaptor.forClass(AuthStateListener::class.java) + val states = mutableListOf() + // Collected open-endedly and cancelled below: a fixed `take` would hang rather than + // fail when the sign-out emission never arrives. + val job = launch { authUI.authStateFlow().toList(states) } + + delay(100) + verify(mockFirebaseAuth).addAuthStateListener(listenerCaptor.capture()) + + authUI.updateAuthState( + AuthState.ReauthenticationRequired(mockFirebaseUser, reason = "Confirm it is you") + ) + delay(100) + assertThat(states.last()) + .isInstanceOf(AuthState.ReauthenticationRequired::class.java) + + // The host signs out behind the library's back, e.g. authUI.auth.signOut(). + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + listenerCaptor.value.onAuthStateChanged(mockFirebaseAuth) + delay(200) + job.cancel() + + assertThat(states.last()).isEqualTo(AuthState.Idle) + } + @Test fun `authStateFlow() removes listener when flow is cancelled`() = runBlocking { // Given auth state flow 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 65b8d2171..b1c03621d 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 @@ -765,6 +765,85 @@ class EmailAuthProviderFirebaseAuthUITest { verify(mockFirebaseAuth).signInWithCredential(credential) } + /** + * Only the null-`currentUser` failure was covered, so the *value* of the stamp was free: a + * `reauthenticatedUid = null` would still have published a Success, which the screen accepts + * as a completed sign-in while refusing to resume the operation it was armed for. + */ + @Test + fun `signInAndLinkWithCredential - reauth success stamps the reauthenticated uid`() = runTest { + val user = mock(FirebaseUser::class.java) + `when`(user.uid).thenReturn("existing-uid") + `when`(user.isAnonymous).thenReturn(false) + `when`(user.isEmailVerified).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(user) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val reauthTask = TaskCompletionSource() + reauthTask.setResult(null) + `when`(user.reauthenticate(credential)).thenReturn(reauthTask.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) + + val result = instance.signInAndLinkWithCredential(config = config, credential = credential) + + assertThat(result).isNull() + verify(user).reauthenticate(credential) + verify(mockFirebaseAuth, never()).signInWithCredential(any()) + val state = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(state).isInstanceOf(AuthState.Success::class.java) + val success = state as AuthState.Success + assertThat(success.reauthenticatedUid).isEqualTo("existing-uid") + assertThat(success.result).isNull() + assertThat(success.user).isSameInstanceAs(user) + } + + /** + * With `isCredentialLinkingEnabled` forwarded by `copy()`, a reauthentication would otherwise + * divert to `linkWithCredential` — which proves no identity and yields an unstamped Success. + */ + @Test + fun `signInAndLinkWithCredential - credential linking never diverts a reauthentication`() = + runTest { + val user = mock(FirebaseUser::class.java) + `when`(user.uid).thenReturn("existing-uid") + `when`(user.isAnonymous).thenReturn(false) + `when`(user.isEmailVerified).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(user) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val reauthTask = TaskCompletionSource() + reauthTask.setResult(null) + `when`(user.reauthenticate(credential)).thenReturn(reauthTask.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + isCredentialLinkingEnabled = true + providers { provider(emailProvider) } + }.copy(isReauthenticationMode = true) + assertThat(config.isCredentialLinkingEnabled).isTrue() + + instance.signInAndLinkWithCredential(config = config, credential = credential) + + verify(user).reauthenticate(credential) + verify(user, never()).linkWithCredential(any()) + val state = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat((state as AuthState.Success).reauthenticatedUid).isEqualTo("existing-uid") + } + /** * 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 @@ -1865,6 +1944,73 @@ class EmailAuthProviderFirebaseAuthUITest { assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = true)) } + /** + * In reauthentication mode the email-link path has no [AuthResult] — `signInOrReauth` returns + * null after publishing the stamped Success itself. Falling through to + * `updateAuthStateWithResult(null)` publishes [AuthState.Idle] over that stamp in the same + * coroutine, so a conflated collector can see only Idle: the proof of identity is lost and the + * pending sensitive operation is orphaned with no error anywhere. + */ + @Test + fun `signInWithEmailLink - reauth keeps the stamped Success instead of resetting to Idle`() = + runTest { + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.uid).thenReturn("reauth-uid") + `when`(mockUser.email).thenReturn("test@example.com") + `when`(mockUser.isAnonymous).thenReturn(false) + `when`(mockUser.isEmailVerified).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + `when`(mockFirebaseAuth.isSignInWithEmailLink(anyString())).thenReturn(true) + + val reauthTask = TaskCompletionSource() + reauthTask.setResult(null) + `when`(mockUser.reauthenticate(any())).thenReturn(reauthTask.task) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .build(), + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + }.copy(isReauthenticationMode = true) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + + val mockPersistence = MockPersistenceManager() + mockPersistence.setSessionRecord( + EmailLinkPersistenceManager.SessionRecord( + sessionId = "session123", + email = "test@example.com", + anonymousUserId = null, + credentialForLinking = null + ) + ) + + val emailLink = + "https://example.com/__/auth/action?apiKey=key&mode=signIn&oobCode=code" + + "&continueUrl=https://example.com?ui_sid=session123" + + val result = instance.signInWithEmailLink( + context = applicationContext, + config = config, + provider = provider, + email = "test@example.com", + emailLink = emailLink, + persistenceManager = mockPersistence + ) + + assertThat(result).isNull() + verify(mockUser).reauthenticate(any()) + val state = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(state).isInstanceOf(AuthState.Success::class.java) + assertThat((state as AuthState.Success).reauthenticatedUid).isEqualTo("reauth-uid") + } + @Test fun `signInWithEmailLink - emits AuthState Success with non-null result`() = runTest { val mockUser = mock(FirebaseUser::class.java) diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt index 893f34540..054c75245 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt @@ -25,6 +25,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.auth.AuthCredential @@ -154,6 +155,123 @@ class OAuthProviderFirebaseAuthUITest { assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) } + // ============================================================================================= + // signInWithProvider - Reauthentication + // ============================================================================================= + + /** + * The stamp is the *only* proof `FirebaseAuthScreen` accepts before resuming a pending + * sensitive operation, and this is where it is applied for Apple, GitHub, Microsoft, Yahoo, + * Twitter and generic OAuth. Publishing a plain success here (or a null uid) would make every + * federated reauthentication fail closed with an "incomplete" error and strand the operation. + */ + @Test + fun `Reauthenticating with an OAuth provider stamps the reauthenticated uid`() = runTest { + val mockOAuthCredential = mock(OAuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.isAnonymous).thenReturn(false) + `when`(mockUser.uid).thenReturn("reauth-uid") + `when`(mockUser.email).thenReturn(null) + + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + `when`(mockAuthResult.credential).thenReturn(mockOAuthCredential) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + + `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + `when`( + mockUser.startActivityForReauthenticateWithProvider( + any(), + any() + ) + ).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val appleProvider = AuthProvider.Apple(locale = null, customParameters = emptyMap()) + val config = authUIConfiguration { + context = applicationContext + providers { provider(appleProvider) } + }.copy(isReauthenticationMode = true) + + instance.signInWithProvider( + applicationContext, + config = config, + activity = mockActivity, + provider = appleProvider, + ) + + verify(mockUser).startActivityForReauthenticateWithProvider( + eq(mockActivity), + any() + ) + verify(mockFirebaseAuth, never()) + .startActivityForSignInWithProvider(any(), any()) + + val finalState = instance.authStateFlow().first { it !is AuthState.Loading } + assertThat(finalState).isInstanceOf(AuthState.Success::class.java) + val success = finalState as AuthState.Success + assertThat(success.reauthenticatedUid).isEqualTo("reauth-uid") + assertThat(success.user).isSameInstanceAs(mockUser) + assertThat(success.isNewUser).isFalse() + } + + /** + * A successful reauthenticate whose `currentUser` has since gone must surface an error rather + * than an unstamped success: the reauth UI would otherwise sit on Loading with nothing to act + * on, or worse accept a success that proves nothing. + */ + @Test + fun `Reauthenticating with an OAuth provider errors when the user is gone`() = runTest { + val mockOAuthCredential = mock(OAuthCredential::class.java) + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.isAnonymous).thenReturn(false) + `when`(mockUser.uid).thenReturn("reauth-uid") + + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockUser) + `when`(mockAuthResult.credential).thenReturn(mockOAuthCredential) + + // Non-null while reauthenticating, then gone by the time the success is built. + var currentUser: FirebaseUser? = mockUser + `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenAnswer { currentUser } + `when`( + mockUser.startActivityForReauthenticateWithProvider( + any(), + any() + ) + ).thenAnswer { + currentUser = null + val source = TaskCompletionSource() + source.setResult(mockAuthResult) + source.task + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val githubProvider = AuthProvider.Github(customParameters = emptyMap()) + val config = authUIConfiguration { + context = applicationContext + providers { provider(githubProvider) } + }.copy(isReauthenticationMode = true) + + try { + instance.signInWithProvider( + applicationContext, + config = config, + activity = mockActivity, + provider = githubProvider, + ) + assertWithMessage("expected a null currentUser after reauth to throw").fail() + } catch (e: Exception) { + assertThat(e).isInstanceOf(AuthException.UserNotFoundException::class.java) + } + + assertThat(instance.authStateFlow().first()).isInstanceOf(AuthState.Error::class.java) + } + // ============================================================================================= // signInWithProvider - Anonymous Upgrade // ============================================================================================= 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 index e0be75edc..8129e0731 100644 --- 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 @@ -22,6 +22,7 @@ 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.StateRestorationTester import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription @@ -45,6 +46,7 @@ import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthInvalidUserException import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorResolver import com.google.firebase.auth.UserInfo import org.junit.After import org.junit.Before @@ -180,11 +182,14 @@ class FirebaseAuthScreenReauthContentStateTest { @Test fun `selecting email from the reauth slot presents the library email sub-flow prefilled`() { val user = passwordOnlyUser("linked@example.com") + // The sub-flow starts its own authStateFlow() collector, and a fresh AuthStateListener + // fires immediately: over a signed-out session that legitimately disarms the reauth. + val signedInAuthUI = signedInAuthUI(user) composeTestRule.setContent { FirebaseAuthScreen( configuration = emailAndPhoneConfiguration(), - authUI = authUI, + authUI = signedInAuthUI, onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, @@ -206,7 +211,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) } composeTestRule.waitForIdle() @@ -220,11 +225,12 @@ class FirebaseAuthScreenReauthContentStateTest { @Test fun `cancelling the email sub-flow returns to the reauth slot`() { val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) composeTestRule.setContent { FirebaseAuthScreen( configuration = emailAndPhoneConfiguration(), - authUI = authUI, + authUI = signedInAuthUI, onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, @@ -240,7 +246,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) } composeTestRule.waitForIdle() @@ -360,8 +366,9 @@ class FirebaseAuthScreenReauthContentStateTest { * 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`() { + fun `a failed attempt latches a localized error and exception into the slot`() { val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) var captured: ReauthContentState? = null val rawMessage = "RAW-BACKEND-CODE-17" val thrown = FirebaseAuthInvalidUserException("ERROR_USER_DISABLED", rawMessage) @@ -370,7 +377,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.setContent { FirebaseAuthScreen( configuration = emailAndPhoneConfiguration(), - authUI = authUI, + authUI = signedInAuthUI, onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, @@ -387,16 +394,19 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) } composeTestRule.waitForIdle() assertThat(requireNotNull(captured).error).isNull() - composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Error(thrown)) } + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) } composeTestRule.waitForIdle() assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) assertThat(requireNotNull(captured).error).doesNotContain(rawMessage) + assertThat(requireNotNull(captured).exception) + .isInstanceOf(AuthException.InvalidCredentialsException::class.java) + assertThat(requireNotNull(captured).exception?.cause).isSameInstanceAs(thrown) composeTestRule.waitForIdle() composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertIsDisplayed() @@ -406,12 +416,15 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onAllNodesWithText(expectedMessage).fetchSemanticsNodes() ).isEmpty() + // Opening and backing out of the email sub-flow is not an attempt, so the latched + // failure survives it — otherwise a mis-tap would silently erase a real error. composeTestRule.onNodeWithTag("pick_provider").performClick() composeTestRule.waitForIdle() composeTestRule.onNodeWithContentDescription(stringProvider.backAction).performClick() composeTestRule.waitForIdle() - assertThat(requireNotNull(captured).error).isNull() + assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) + assertThat(requireNotNull(captured).exception).isNotNull() } /** @@ -931,6 +944,56 @@ class FirebaseAuthScreenReauthContentStateTest { assertThat(retryRan).isFalse() } + /** + * An MFA-enrolled account cannot complete reauthentication at all (a known, separate defect). + * Unguarded, RequiresMfa pushed AuthRoute.MfaChallenge *beneath* the armed slot, which then + * showed neither loading nor an error — a dead UI with the operation still pending. + */ + @Test + fun `RequiresMfa does not navigate while reauthentication is armed and latches an error`() { + val user = passwordOnlyUser("linked@example.com") + var retryRan = false + var captured: ReauthContentState? = null + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { state -> + captured = state + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + }, + authenticatedContent = { _, _ -> + Text(text = "AUTHENTICATED", modifier = Modifier.testTag("authenticated")) + }, + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.RequiresMfa(mock(MultiFactorResolver::class.java))) + } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + assertThat(retryRan).isFalse() + val state = requireNotNull(captured) + assertThat(state.error) + .isEqualTo(context.getString(R.string.fui_error_reauth_mfa_unsupported)) + assertThat(state.exception).isInstanceOf(AuthException.UnknownException::class.java) + } + /** * Dismissing abandons the operation for good, and `withReauth` has already returned normally — * so the host has no other way to learn its sensitive operation will never run. @@ -975,4 +1038,204 @@ class FirebaseAuthScreenReauthContentStateTest { assertThat(retryRan).isFalse() composeTestRule.onNodeWithTag("dismiss_reauth").assertDoesNotExist() } + + /** + * Rotating while the slot shows a latched failure must not silently erase it. The message is + * saveable and survives; the [AuthException] behind it is not, so `exception` comes back null + * (documented on [ReauthContentState.exception]) rather than disagreeing with `error`. + */ + @Test + fun `a latched slot error survives Activity recreation`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + var captured: ReauthContentState? = null + val thrown = FirebaseAuthInvalidUserException("ERROR_USER_DISABLED", "RAW-BACKEND-CODE-17") + val expectedMessage = requireNotNull(AuthException.from(thrown, stringProvider).message) + val restorationTester = StateRestorationTester(composeTestRule) + + restorationTester.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { state -> + captured = state + Text(text = "SLOT_ERROR=${state.error}", modifier = Modifier.testTag("slot")) + } + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) } + composeTestRule.waitForIdle() + assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) + + captured = null + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertIsDisplayed() + assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) + assertThat(requireNotNull(captured).exception).isNull() + } + + /** + * Rotating part-way through the library's own email sub-flow must not bounce the user back to + * the provider chooser: the active sub-route is saved alongside the arming. + */ + @Test + fun `an active email sub-flow survives Activity recreation`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + val restorationTester = StateRestorationTester(composeTestRule) + + restorationTester.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + 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 { + signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("pick_provider").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("email_subflow").assertIsDisplayed() + + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("email_subflow").assertIsDisplayed() + composeTestRule.onNodeWithTag("pick_provider").assertDoesNotExist() + } + + /** + * The likeliest moment to rotate is right after a cancelled or failed attempt. Resetting the + * flow to [AuthState.Idle] there would drop the arming from the process-cached [FirebaseAuthUI] + * and lose the pending operation silently; the arming is re-emitted instead, so a recreation + * re-derives both it and the operation, and a later genuine reauthentication still runs it. + */ + @Test + fun `the pending operation survives recreation after a cancelled attempt`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + var retryCount = 0 + val restorationTester = StateRestorationTester(composeTestRule) + + restorationTester.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Cancelled()) } + composeTestRule.waitForIdle() + + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + 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 } + + assertThat(retryCount).isEqualTo(1) + } + + /** + * The one arming a recreation genuinely cannot re-derive: the credential exchange in flight + * died with the composition, so the flow still reads [AuthState.Loading] and the suspend + * operation is gone. That must be reported, not dropped silently, and must not later be + * consumed by an unrelated success. + */ + @Test + fun `an attempt interrupted by recreation is reported rather than dropped`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + var retryCount = 0 + val restorationTester = StateRestorationTester(composeTestRule) + + restorationTester.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.ReauthenticationRequired(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) } + composeTestRule.waitForIdle() + + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() + composeTestRule + .onNodeWithText(context.getString(R.string.fui_error_reauth_interrupted)) + .assertExists() + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) + ) + } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(retryCount).isEqualTo(0) + } } 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 index 7efa8b786..65196c744 100644 --- 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 @@ -20,9 +20,12 @@ 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.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 @@ -31,6 +34,9 @@ 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.firebase.ui.auth.ui.components.ERROR_DIALOG_ACTION_TEST_TAG +import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController +import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.auth.ActionCodeSettings @@ -164,6 +170,38 @@ class EmailAuthScreenReauthEmailLockTest { } } + /** + * Every branch of this screen's `onRetry` is inert in reauthentication mode (sign-up and + * mode switches are all vetoed), so an action button on the error dialog could only dismiss — + * and it raced the outer screen's `onRetry = null` for the same error. + */ + @Test + fun `the reauth sub-flow error dialog offers no action button`() { + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + val controller = rememberTopLevelDialogController( + stringProvider = stringProvider, + authState = { AuthState.Idle }, + ) + CompositionLocalProvider(LocalTopLevelDialogController provides controller) { + EmailAuthScreenUnderTest(reauthConfiguration(), prefillEmail) + controller.CurrentDialog() + } + } + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Error(AuthException.UserNotFoundException(message = "nope")) + ) + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(stringProvider.dismissAction).assertExists() + composeTestRule.onNodeWithTag(ERROR_DIALOG_ACTION_TEST_TAG).assertDoesNotExist() + } + /** * Resetting the text fields (the mode switches all do it) must put the locked address back, * not leave the user on an empty read-only field. 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 46c1580fa..49faaa135 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,6 +16,7 @@ package com.firebase.ui.auth.ui.screens.email import android.content.Context import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,6 +28,7 @@ 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.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput import androidx.credentials.CredentialManager @@ -34,6 +36,7 @@ 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.R import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider @@ -291,6 +294,7 @@ class SignInUITest { initialEmail: String, isEmailLocked: Boolean = false, onSignInClicked: () -> Unit = {}, + onCredentialProbeDone: (() -> Unit)? = null, ) { composeTestRule.setContent { CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { @@ -311,6 +315,14 @@ class SignInUITest { onGoToEmailLinkSignIn = { }, isEmailLocked = isEmailLocked, ) + if (onCredentialProbeDone != null) { + // Mirrors the suspend read SignInUI's own autofill effect makes first, and is + // launched after it, so completing here means that effect already decided. + LaunchedEffect(Unit) { + PasswordCredentialHandler.hasSavedCredentials(applicationContext) + onCredentialProbeDone() + } + } } } } @@ -407,12 +419,14 @@ class SignInUITest { PasswordCredentialHandler.testCredentialManagerProvider = FakeCredentialManagerProvider var signInClicks = 0 + var probeDone = false setStatefulSignInUIContent( createReauthFlowConfiguration(), initialEmail = "linked@example.com", onSignInClicked = { signInClicks++ }, + onCredentialProbeDone = { probeDone = true }, ) - awaitOrTimeout { FakeCredentialManagerProvider.wasQueried } + awaitOrTimeout { probeDone || FakeCredentialManagerProvider.wasQueried } assertThat(FakeCredentialManagerProvider.wasQueried).isFalse() composeTestRule.onNodeWithText(SAVED_CREDENTIAL_USERNAME).assertDoesNotExist() @@ -420,7 +434,10 @@ class SignInUITest { assertThat(signInClicks).isEqualTo(0) } - /** Polls [condition] for up to two seconds, idling composition in between. */ + /** + * Polls [condition] and returns as soon as it holds, idling composition in between. The two + * second cap is only a safety net — the caller supplies a condition that really does settle. + */ private fun awaitOrTimeout(condition: () -> Boolean) { val deadline = System.currentTimeMillis() + 2_000 while (System.currentTimeMillis() < deadline && !condition()) { @@ -429,6 +446,35 @@ class SignInUITest { } } + /** + * Firebase reports the provider id `"password"` for passwordless email-link accounts too, so + * such a user is offered the Email method and lands on a password field they can never fill. + * Reauthentication mode has also removed the email-link toggle and "trouble signing in?", so + * without this notice the screen is a silent dead end. The provider cannot be filtered out + * instead: `providerData` cannot tell a password account from an email-link one. + */ + @Test + fun `a password requirement notice is shown while reauthenticating`() { + setStatefulSignInUIContent( + createReauthFlowConfiguration(), + initialEmail = "linked@example.com", + isEmailLocked = true, + ) + + composeTestRule.onNodeWithTag(REAUTH_PASSWORD_NOTICE_TEST_TAG).assertExists() + composeTestRule + .onNodeWithText(applicationContext.getString(R.string.fui_reauth_password_required_notice)) + .assertExists() + } + + /** The notice is specific to reauthentication and must not appear in a normal sign-in. */ + @Test + fun `no password requirement notice outside reauthentication`() { + setSignInUIContent(isNewAccountsAllowed = true) + + composeTestRule.onNodeWithTag(REAUTH_PASSWORD_NOTICE_TEST_TAG).assertDoesNotExist() + } + /** Control for the test above: outside reauthentication mode the autofill still happens. */ @Test fun `credential manager autofill still happens outside reauthentication mode`() { From 0469224bafc4a6d81ffa2b8c2a68e44c591fb7cd Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 31 Aug 2026 12:14:34 +0100 Subject: [PATCH 3/7] refactor(auth): make reauthentication a request-scoped state machine --- .../demo/auth/HighLevelApiDemoActivity.kt | 4 +- auth/README.md | 14 +- .../firebase/ui/auth/AuthFlowController.kt | 2 + .../java/com/firebase/ui/auth/AuthState.kt | 236 ++++++- .../com/firebase/ui/auth/FirebaseAuthUI.kt | 154 ++++- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 635 +++++++---------- .../auth/ui/screens/email/EmailAuthScreen.kt | 20 +- .../screens/{ => mfa}/MfaChallengeDefaults.kt | 2 +- .../screens/{ => mfa}/MfaChallengeScreen.kt | 2 +- .../{ => mfa}/MfaEnrollmentDefaults.kt | 2 +- .../screens/{ => mfa}/MfaEnrollmentScreen.kt | 2 +- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 62 +- .../ui/screens/reauth/CustomReauthContent.kt | 188 +++++ .../{ => reauth}/ReauthContentState.kt | 9 +- .../screens/reauth/ReauthPresentationState.kt | 43 ++ .../ui/screens/reauth/ReauthSheetContent.kt | 169 +++++ auth/src/main/res/values/strings.xml | 1 - .../ui/auth/FirebaseAuthUIAuthStateTest.kt | 239 ++++++- ...irebaseAuthScreenReauthContentStateTest.kt | 645 ++++++++++++++++-- .../FirebaseAuthScreenReauthIdleResetTest.kt | 22 +- .../ui/screens/FirebaseAuthScreenSlotsTest.kt | 3 +- .../auth/ui/screens/MfaChallengeScreenTest.kt | 1 + .../ui/screens/MfaEnrollmentScreenTest.kt | 1 + .../auth/ui/screens/MfaChallengeScreenTest.kt | 1 + .../ui/screens/MfaEnrollmentScreenTest.kt | 1 + .../ui/auth/ui/screens/ReauthFlowTest.kt | 17 +- okf-bundle/modules/auth.md | 3 + 27 files changed, 1950 insertions(+), 528 deletions(-) rename auth/src/main/java/com/firebase/ui/auth/ui/screens/{ => mfa}/MfaChallengeDefaults.kt (99%) rename auth/src/main/java/com/firebase/ui/auth/ui/screens/{ => mfa}/MfaChallengeScreen.kt (99%) rename auth/src/main/java/com/firebase/ui/auth/ui/screens/{ => mfa}/MfaEnrollmentDefaults.kt (99%) rename auth/src/main/java/com/firebase/ui/auth/ui/screens/{ => mfa}/MfaEnrollmentScreen.kt (99%) create mode 100644 auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/CustomReauthContent.kt rename auth/src/main/java/com/firebase/ui/auth/ui/screens/{ => reauth}/ReauthContentState.kt (94%) create mode 100644 auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthPresentationState.kt create mode 100644 auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSheetContent.kt 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 6ad3e1abe..7b4f732aa 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 @@ -60,7 +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.ui.screens.reauth.ReauthContentState import com.firebase.ui.auth.util.EmailLinkConstants import com.firebase.ui.auth.util.displayIdentifier import com.firebase.ui.auth.util.getDisplayEmail @@ -330,7 +330,7 @@ private fun AppAuthenticatedContent( try { uiContext.authUI.delete(context) } catch (e: AuthException.InvalidCredentialsException) { - // ReauthenticationRequired state was emitted — + // Reauthentication.Required state was emitted — // FirebaseAuthScreen navigates to the reauth flow automatically. Log.d("HighLevelApiDemoActivity", "Reauth required before delete") } catch (e: AuthException) { diff --git a/auth/README.md b/auth/README.md index 86eda98e8..ee30e41b3 100644 --- a/auth/README.md +++ b/auth/README.md @@ -996,6 +996,8 @@ Replaces the default reauthentication bottom sheet shown when a sensitive operat 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. +If the account has multi-factor authentication enrolled, Firebase needs the second factor to complete the reauthentication too. The library presents the MFA challenge as another sub-flow over this slot, honouring your `mfaChallengeContent` slot; resolving it completes the reauthentication and the pending operation resumes. Backing out of the challenge returns to this slot with the operation still pending, and a failed challenge latches into `state.error` like any other failed attempt. + ```kotlin reauthContent = { state -> AlertDialog( @@ -1023,7 +1025,7 @@ reauthContent = { state -> While this slot is shown the library suppresses its own loading and error dialogs, so render `state.isLoading` and `state.error` yourself. `state.error` is the same message the library's own error dialog would have shown, and `state.exception` carries the exception behind it when you need to branch on the failure type. On success the library resumes the operation that required reauthentication — there is nothing to retry. `state.onDismiss` abandons reauthentication and calls `onSignInCancelled`, so any pending operation will never run; backing out of a single provider attempt returns to the slot with the operation still pending and does *not* call `onSignInCancelled`. Render the slot so it blocks interaction with the content behind it — that content stays composed, and the library only makes its own affordances inert. -An armed reauthentication survives Activity recreation: rotating keeps the pending operation, the latched `state.error`, and any active email/phone sub-flow. `state.exception` is not saveable, so after a recreation `state.error` still carries the message while `state.exception` is `null` — branch on the type only for a failure your own composition observed. The pending operation cannot survive process death, and if it is lost the flow emits an `AuthState.Error` explaining that identity confirmation was interrupted rather than dropping the operation silently. +An armed reauthentication survives Activity recreation: rotating keeps the pending operation, the latched `state.error`, its `state.exception`, and any active email/phone sub-flow. The pending operation cannot survive process death, and if it is lost the flow emits an `AuthState.Error` explaining that identity confirmation was interrupted rather than dropping the operation silently. 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. @@ -1031,7 +1033,7 @@ For most cases, use [`withReauth`](#reauthentication) instead — it handles the Firebase requires the user to have signed in recently before performing sensitive operations like deleting their account or changing their password. If the session is too old, Firebase throws `FirebaseAuthRecentLoginRequiredException`. -`withReauth` wraps any sensitive operation. If the exception is thrown, it automatically emits `AuthState.ReauthenticationRequired` and — once the user reauthenticates via the default bottom sheet or your `reauthContent` slot — retries the original operation. +`withReauth` wraps any sensitive operation. If the exception is thrown, it automatically emits `AuthState.Reauthentication.Required` and — once the user reauthenticates via the default bottom sheet or your `reauthContent` slot — retries the original operation. ```kotlin lifecycleScope.launch { @@ -1047,11 +1049,13 @@ lifecycleScope.launch { `withReauth` handles the full cycle: 1. Runs the operation. -2. If `FirebaseAuthRecentLoginRequiredException` is thrown, emits `AuthState.ReauthenticationRequired` with the retry attached. -3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers. +2. If `FirebaseAuthRecentLoginRequiredException` is thrown, emits `AuthState.Reauthentication.Required` with the retry attached. +3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers, including the MFA challenge when the account has a second factor enrolled. 4. On successful reauthentication, retries the operation automatically and emits `AuthState.Success` or `AuthState.Error`. -The armed reauthentication lives on the process-cached `FirebaseAuthUI`, so it survives Activity recreation; it does not survive process death, and a lost operation is reported as an `AuthState.Error` rather than silently dropped. +The armed reauthentication lives on the process-cached `FirebaseAuthUI`, so it survives Activity recreation; it does not survive process death, and a lost operation is reported as an `AuthState.Error` rather than silently dropped. The operation runs at most once: if a recreation interrupts it mid-flight the flow reports the interruption instead of starting it again, because the first attempt may already have committed. + +**What `authStateFlow()` emits while this is running.** From the moment `FirebaseAuthScreen` picks the request up until it ends, every state is published as an `AuthState.Reauthentication` — the phases of that one request, each carrying its `requestId` and `userUid`. The ordinary `AuthState.Loading` / `AuthState.Error` / `AuthState.Cancelled` of the credential exchange are folded into those phases, so `is AuthState.Error` and `is AuthState.Loading` do **not** match for the duration and app-side error dialogs and spinners stay quiet: the library owns the UI for that window. Match `is AuthState.Reauthentication` if you need to know it is happening. The final outcome — `AuthState.Success`, `AuthState.Error` or `AuthState.Idle` — is published as an ordinary state once the request ends. Arming a request with no `FirebaseAuthScreen` composed (catching `withReauth`/`delete`'s exception and showing your own UI) folds nothing: states are published normally, and the next one simply replaces the arming. **Activity-based alternative:** use `createReauthFlow` to start a standalone reauthentication activity scoped to the current user's linked providers, returning an `AuthFlowController`. diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt index f5c0bd6e3..87f1ae1bd 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt @@ -129,6 +129,8 @@ class AuthFlowController internal constructor( * - [AuthState.Aborted] - The whole flow was ended via [cancel] * - [AuthState.RequiresMfa] - Multi-factor authentication required * - [AuthState.RequiresEmailVerification] - Email verification required + * - [AuthState.Reauthentication] - A reauthentication [FirebaseAuthScreen] is driving; the + * states above are reported as its library-owned phases until it ends */ val authStateFlow: Flow get() { 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 707f977ea..2dccce6ad 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver import com.google.firebase.auth.PhoneAuthCredential import com.google.firebase.auth.PhoneAuthProvider +import java.util.UUID /** * Represents the authentication state in Firebase Auth UI. @@ -255,27 +256,232 @@ abstract class AuthState private constructor() { } /** - * Reauthentication is required before a sensitive operation (e.g. delete account, change email) - * can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch the reauthentication flow. + * A state in the lifecycle of one reauthentication request. * - * @property user The [FirebaseUser] that needs to reauthenticate - * @property reason Optional human-readable reason to show the user + * Every state carries a stable [requestId], so Activity recreation can distinguish a + * continuation of the same sensitive operation from a new operation for the same user. The + * request itself is process-local because its retry callback cannot be serialized. */ - class ReauthenticationRequired( - val user: FirebaseUser, - val reason: String? = null, - val retryOperation: (suspend (android.content.Context) -> Unit)? = null, - ) : AuthState() { + sealed class Reauthentication : AuthState() { + abstract val requestId: String + abstract val userUid: String + internal abstract val request: Request? override val isNotification: Boolean = false - // Identity, not value: arming a second sensitive operation for the same user must replace - // the first, and MutableStateFlow silently drops a write equal to the current value. - override fun equals(other: Any?): Boolean = this === other + /** Process-local data shared by every resumable state of one reauthentication request. */ + internal class Request( + val requestId: String, + val user: FirebaseUser, + val reason: String?, + retryOperation: (suspend (android.content.Context) -> Unit)?, + ) { + /** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */ + var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation + private set + + /** Whether this request ever carried an operation, even after it was claimed. */ + val hasRetryOperation: Boolean = retryOperation != null + + /** + * Hands the operation out exactly once. A second claim means the first run was lost, + * which must be reported rather than retried: the operation may have committed already. + */ + fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? = + retryOperation.also { retryOperation = null } + } - override fun hashCode(): Int = System.identityHashCode(this) + /** + * Reauthentication is required before a sensitive operation (e.g. delete account, change + * email) can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch a standalone + * reauthentication flow. + * + * @property requestId Stable identifier for this sensitive operation + * @property user The [FirebaseUser] that needs to reauthenticate + * @property reason Optional human-readable reason to show the user + */ + class Required internal constructor( + override val request: Request, + ) : Reauthentication() { + constructor( + user: FirebaseUser, + reason: String? = null, + retryOperation: (suspend (android.content.Context) -> Unit)? = null, + ) : this( + Request( + requestId = UUID.randomUUID().toString(), + user = user, + reason = reason, + retryOperation = retryOperation, + ) + ) + + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + val user: FirebaseUser get() = request.user + val reason: String? get() = request.reason + val retryOperation: (suspend (android.content.Context) -> Unit)? + get() = request.retryOperation + + override fun equals(other: Any?): Boolean = + other is Required && requestId == other.requestId + + override fun hashCode(): Int = requestId.hashCode() + + override fun toString(): String = + "AuthState.Reauthentication.Required(requestId=$requestId, " + + "user=$user, reason=$reason)" + } - override fun toString(): String = - "AuthState.ReauthenticationRequired(user=$user, reason=$reason)" + /** The user has selected a provider and the library is exchanging credentials. */ + internal class Authenticating( + override val request: Request, + val message: String? = null, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** The most recent credential attempt failed, but the request remains armed. */ + internal class AttemptFailed( + override val request: Request, + val exception: Exception, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** A credential attempt requires MFA, which reauthentication UI does not yet support. */ + internal class RequiresMfa( + override val request: Request, + val resolver: MultiFactorResolver, + val hint: String? = null, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** Phone verification sent a code and is waiting for the user to enter it. */ + internal class PhoneNumberVerificationRequired( + override val request: Request, + val verificationId: String, + val forceResendingToken: PhoneAuthProvider.ForceResendingToken, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** Phone verification obtained a credential automatically. */ + internal class SmsAutoVerified( + override val request: Request, + val credential: PhoneAuthCredential, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** A password-reset email was sent from the reauthentication email sub-flow. */ + internal class PasswordResetLinkSent( + override val request: Request, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** A sign-in link was sent from the reauthentication email sub-flow. */ + internal class EmailSignInLinkSent( + override val request: Request, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** Credentials were accepted for the request's user. */ + internal class Succeeded( + override val request: Request, + val success: Success, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** The sensitive operation is being retried after credentials were accepted. */ + internal class RetryingOperation( + override val request: Request, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** The retry completed and [outcome] is ready to become the ordinary auth state. */ + internal class OperationFinished( + override val request: Request, + val outcome: AuthState, + ) : Reauthentication() { + override val requestId: String get() = request.requestId + override val userUid: String get() = request.user.uid + } + + /** + * Saved UI state proved a request existed, but its process-local retry callback was lost. + */ + internal class Interrupted( + override val requestId: String, + override val userUid: String, + ) : Reauthentication() { + override val request: Request? = null + } + + /** + * A provider attempt is about to run, clearing any previously surfaced failure. Null once + * credentials were accepted, so a late attempt cannot rewind a running operation. + */ + internal fun attemptStarted(): AuthState? = when (this) { + is Required, + is Authenticating, + is AttemptFailed, + is RequiresMfa, + is PhoneNumberVerificationRequired, + is SmsAutoVerified, + is PasswordResetLinkSent, + is EmailSignInLinkSent, + -> request?.let { Authenticating(it) } + + else -> null + } + + /** + * The active sub-flow was consumed, so the request returns to provider selection. Null from + * a surfaced failure: only [attemptStarted] clears one, when a real attempt replaces it. + */ + internal fun returnedToProviderSelection(): AuthState? = when (this) { + is Authenticating, + is PhoneNumberVerificationRequired, + is SmsAutoVerified, + is PasswordResetLinkSent, + is EmailSignInLinkSent, + -> request?.let { Required(it) } + + else -> null + } + + /** + * The user backed out of an in-flight provider sub-flow. Null in every other phase, so a + * surfaced failure or a finished request is never rewound to provider selection. + */ + internal fun attemptCancelled(): AuthState? = when (this) { + is Authenticating, + is RequiresMfa, + is PhoneNumberVerificationRequired, + is SmsAutoVerified, + -> request?.let { Required(it) } + + else -> null + } + + /** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */ + internal fun operationFinished(outcome: AuthState): AuthState? = + (this as? RetryingOperation) + ?.let { OperationFinished(it.request, outcome) } } /** 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 84d24f1f2..60033e536 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -80,6 +80,9 @@ class FirebaseAuthUI private constructor( private val _authStateFlow = MutableStateFlow(AuthState.Idle) + /** How many composed [FirebaseAuthScreen]s can currently drive a reauthentication request. */ + private var reauthenticationDrainers = 0 + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null @@ -265,6 +268,8 @@ class FirebaseAuthUI private constructor( * - [AuthState.Cancelled] when authentication is cancelled * - [AuthState.RequiresMfa] when multi-factor authentication is needed * - [AuthState.RequiresEmailVerification] when email verification is needed + * - [AuthState.Reauthentication] for the whole of a reauthentication [FirebaseAuthScreen] is + * driving: the states above are then reported as its library-owned phases instead * * The flow automatically emits [AuthState.Success] or [AuthState.Idle] based on * the current authentication state when collection starts. @@ -321,7 +326,7 @@ class FirebaseAuthUI private constructor( if (current is AuthState.Success || current is AuthState.RequiresEmailVerification || current is AuthState.RequiresProfileCompletion || - current is AuthState.ReauthenticationRequired + current is AuthState.Reauthentication ) { _authStateFlow.value = AuthState.Idle } @@ -364,9 +369,147 @@ class FirebaseAuthUI private constructor( */ @MainThread fun updateAuthState(state: AuthState) { + _authStateFlow.value = contextualizeReauthenticationState(state) + } + + /** Ends the current reauthentication session without preserving its request context. */ + @MainThread + internal fun finishReauthentication(state: AuthState) { _authStateFlow.value = state } + /** + * Registers a screen that can drive an armed reauthentication request to completion. + * Call [removeReauthenticationDrainer] when it leaves the composition. + */ + @MainThread + internal fun addReauthenticationDrainer() { + reauthenticationDrainers++ + } + + /** Unregisters a drainer added by [addReauthenticationDrainer]. */ + @MainThread + internal fun removeReauthenticationDrainer() { + if (reauthenticationDrainers > 0) reauthenticationDrainers-- + } + + /** + * Applies a reauthentication [transition] only while [requestId] is still the armed request. + * A null transition result is a no-op, which is how phases reject a transition they disallow. + */ + @MainThread + internal fun updateReauthentication( + requestId: String, + transition: (AuthState.Reauthentication) -> AuthState?, + ) { + val current = _authStateFlow.value as? AuthState.Reauthentication ?: return + if (current.requestId != requestId) return + transition(current)?.let { updateAuthState(it) } + } + + /** + * Publishes the [AuthState.Success] that proves a genuine reauthentication of the signed-in + * user, for the one exchange no provider owns: a resolved second factor. Call only on success. + */ + @MainThread + internal fun publishReauthenticationSuccess() { + // Matches the provider stamp sites: no current user means nothing was re-proved, so the + // attempt is reported as a failure rather than published as an unstamped Success. + val reauthenticatedUser = auth.currentUser + if (reauthenticatedUser == null) { + updateAuthState( + AuthState.Error( + AuthException.UserNotFoundException( + message = "No user is currently signed in for reauthentication" + ) + ) + ) + return + } + updateAuthState( + AuthState.Success( + result = null, + user = reauthenticatedUser, + reauthenticatedUid = reauthenticatedUser.uid, + ) + ) + } + + /** + * Keeps one reauthentication request attached while provider code publishes ordinary auth + * states. Provider implementations therefore do not need their own parallel session storage. + * + * Scoped to a registered drainer: with no screen to end a request, an arming from public API + * alone stays inert rather than swallowing every later state and capturing [authStateFlow]. + */ + private fun contextualizeReauthenticationState(state: AuthState): AuthState { + if (state is AuthState.Reauthentication) return state + if (reauthenticationDrainers == 0) return state + + val current = _authStateFlow.value as? AuthState.Reauthentication ?: return state + val request = current.request ?: return state + + if (current is AuthState.Reauthentication.RetryingOperation) { + return when (state) { + // Sensitive operations such as delete() publish their own Loading before the + // final result. Keep the retry phase and its callback attached in the meantime. + is AuthState.Loading -> current + else -> AuthState.Reauthentication.OperationFinished(request, state) + } + } + + return when (state) { + is AuthState.Loading -> + AuthState.Reauthentication.Authenticating(request, state.message) + + is AuthState.Error -> { + if (state.exception is AuthException.AuthCancelledException) { + AuthState.Reauthentication.Required(request) + } else { + AuthState.Reauthentication.AttemptFailed(request, state.exception) + } + } + + is AuthState.Cancelled -> AuthState.Reauthentication.Required(request) + + is AuthState.RequiresMfa -> + AuthState.Reauthentication.RequiresMfa(request, state.resolver, state.hint) + + is AuthState.PhoneNumberVerificationRequired -> + AuthState.Reauthentication.PhoneNumberVerificationRequired( + request = request, + verificationId = state.verificationId, + forceResendingToken = state.forceResendingToken, + ) + + is AuthState.SMSAutoVerified -> + AuthState.Reauthentication.SmsAutoVerified(request, state.credential) + + is AuthState.PasswordResetLinkSent -> + AuthState.Reauthentication.PasswordResetLinkSent(request) + + is AuthState.EmailSignInLinkSent -> + AuthState.Reauthentication.EmailSignInLinkSent(request) + + is AuthState.Success -> { + if (state.reauthenticatedUid != null) { + AuthState.Reauthentication.Succeeded(request, state) + } else { + current + } + } + + // These states can be ambient FirebaseAuth emissions or notification cleanup while a + // request is armed. They must not detach the process-local retry callback. + is AuthState.Idle, + is AuthState.RequiresEmailVerification, + is AuthState.RequiresProfileCompletion, + -> current + + else -> state + } + } + internal fun updateAuthStateWithResult(result: AuthResult?, defaultIsNewUser: Boolean = false) { val user = result?.user if (user != null) { @@ -495,7 +638,8 @@ class FirebaseAuthUI private constructor( * Executes a sensitive operation, automatically handling reauthentication if required. * * If the [operation] throws [FirebaseAuthRecentLoginRequiredException], this method emits - * [AuthState.ReauthenticationRequired] with the operation attached as [AuthState.ReauthenticationRequired.retryOperation]. + * [AuthState.Reauthentication.Required] with the operation attached as its + * [AuthState.Reauthentication.Required.retryOperation]. * [FirebaseAuthScreen] observes this state and presents a reauthentication sheet; on success * the operation is retried automatically without any further action from the caller. * @@ -526,7 +670,7 @@ class FirebaseAuthUI private constructor( val user = auth.currentUser ?: throw AuthException.UserNotFoundException(message = "No user is currently signed in") updateAuthState( - AuthState.ReauthenticationRequired( + AuthState.Reauthentication.Required( user = user, reason = reason, retryOperation = { @@ -536,7 +680,7 @@ class FirebaseAuthUI private constructor( throw e } catch (e: Exception) { updateAuthState(AuthState.Error(e)) - return@ReauthenticationRequired + return@Required } val currentUser = auth.currentUser if (currentUser != null) { @@ -569,7 +713,7 @@ class FirebaseAuthUI private constructor( } catch (e: FirebaseAuthRecentLoginRequiredException) { auth.currentUser?.let { updateAuthState( - AuthState.ReauthenticationRequired( + AuthState.Reauthentication.Required( user = it, retryOperation = { ctx -> delete(ctx) }, ) 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 b1b77bfcc..56518a840 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 @@ -43,6 +43,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -50,7 +51,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -90,8 +90,15 @@ import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen +import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen +import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.ui.screens.reauth.CustomReauthContent +import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState +import com.firebase.ui.auth.ui.screens.reauth.ReauthPresentationState +import com.firebase.ui.auth.ui.screens.reauth.ReauthPresentationStateSaver +import com.firebase.ui.auth.ui.screens.reauth.ReauthSheetContent import com.firebase.ui.auth.util.EmailLinkPersistenceManager import com.firebase.ui.auth.util.SignInPreferenceManager import com.firebase.ui.auth.util.displayIdentifier @@ -122,7 +129,8 @@ import kotlinx.coroutines.tasks.await * @param reauthContent Optional slot that replaces the default reauthentication bottom sheet, * receiving a [ReauthContentState]. The library owns the credential exchange. An armed * reauthentication survives Activity recreation (rotation) but not process death; if it is lost - * the flow surfaces an error rather than dropping the pending operation silently. + * the flow surfaces an error rather than dropping the pending operation silently. An enrolled + * second factor is challenged over the slot, honouring [mfaChallengeContent]. * * @since 10.0.0 */ @@ -157,44 +165,51 @@ fun FirebaseAuthScreen( val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) } val navController = rememberNavController() - val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) + val observedAuthState by remember(authUI) { authUI.authStateFlow() } + .collectAsState(initial = null as AuthState?) + val authState = observedAuthState ?: AuthState.Idle val dialogController = rememberTopLevelDialogController(stringProvider) { authState } val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } - val pendingReauthConfig = remember { mutableStateOf(null) } - val pendingReauthState = remember { mutableStateOf(null) } - val pendingReauthOperation = remember { mutableStateOf<(suspend (android.content.Context) -> Unit)?>(null) } - // Saved so a re-derived arming (rotation) can be told apart from a genuinely new one, and so a - // recreation that cannot re-derive it is reported rather than dropped silently. - val reauthArmedUid = rememberSaveable { mutableStateOf(null) } - // The exception is not saveable, its already-localized message is. Written only together, so - // the slot's `error` and `exception` can never disagree. - val reauthFailure = remember { mutableStateOf(null) } - val reauthErrorMessage = rememberSaveable { mutableStateOf(null) } - val reauthSubRoute = - rememberSaveable(stateSaver = ReauthSubRouteSaver) { mutableStateOf(null) } - val setReauthFailure: (AuthException?) -> Unit = remember(stringProvider) { - { failure -> - reauthFailure.value = failure - reauthErrorMessage.value = failure?.let { getRecoveryMessage(it, stringProvider) } - } + // This screen is the only thing that can drive a reauthentication request to completion, so + // FirebaseAuthUI only folds ordinary states into an armed request while one is registered. + DisposableEffect(authUI) { + authUI.addReauthenticationDrainer() + onDispose { authUI.removeReauthenticationDrainer() } } - val clearPendingReauth: () -> Unit = remember(setReauthFailure) { - { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null - reauthArmedUid.value = null - reauthSubRoute.value = null - setReauthFailure(null) - } + val reauthPresentation = rememberSaveable(stateSaver = ReauthPresentationStateSaver) { + mutableStateOf(null) + } + val clearReauthPresentation: () -> Unit = remember { + { reauthPresentation.value = null } } - // Idle would drop the arming from the process-cached FirebaseAuthUI, so an Activity recreation - // could no longer re-derive the pending operation. Re-emit the arming instead. - val resetTransientAuthState: () -> Unit = remember(authUI) { - { authUI.updateAuthState(pendingReauthState.value ?: AuthState.Idle) } + val reauthState = authState as? AuthState.Reauthentication + val reauthRequest = reauthState?.request + val reauthRequired = reauthRequest?.let { AuthState.Reauthentication.Required(it) } + val reauthConfig = reauthRequest?.let { request -> + configuration.providers.filterToLinkedProviders(request.user) + .takeIf { it.isNotEmpty() } + ?.let { linkedProviders -> + configuration.copy( + providers = linkedProviders, + isNewEmailAccountsAllowed = false, + isReauthenticationMode = true, + ) + } } + val reauthException = (reauthState as? AuthState.Reauthentication.AttemptFailed) + ?.exception + ?.let { throwable -> + when (throwable) { + is AuthException -> throwable + else -> AuthException.from(throwable, stringProvider) + } + } + val reauthErrorMessage = reauthException?.let { getRecoveryMessage(it, stringProvider) } + // Firebase requires the second factor to complete the reauthentication too, so the challenge + // is presented as a reauth sub-flow instead of on the outer NavHost under the modal. + val reauthMfa = reauthState as? AuthState.Reauthentication.RequiresMfa val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } val prefillEmail = remember { mutableStateOf(null) } val reauthPrefillEmail = remember(authUI, configuration.isReauthenticationMode) { @@ -202,8 +217,9 @@ fun FirebaseAuthScreen( } val lastSignInPreference = remember { mutableStateOf(null) } - // Lets the Idle branch below tell a genuine reset apart from consuming a notification - // (AuthState.isNotification) and from collectAsState's placeholder Idle (null == none yet). + // Lets the Idle branch below tell a genuine reset apart from consuming a notification. + // collectAsState uses null until the first real flow emission, so process restoration can + // distinguish that placeholder from FirebaseAuthUI's actual Idle/Success state. val previousAuthState = remember { mutableStateOf(null) } val startRoute = remember(configuration.providers, configuration.isProviderChoiceAlwaysShown) { getStartRoute(configuration) @@ -237,9 +253,10 @@ fun FirebaseAuthScreen( // Remembered so the method picker is not recomposed on every parent recomposition; // rememberOnProviderSelected returns a fresh lambda each time, so read it through a holder. val currentOuterProviderSelected = rememberUpdatedState(onOuterProviderSelected) + val currentReauthState = rememberUpdatedState(reauthState) val onProviderSelected: (AuthProvider) -> Unit = remember { { provider -> - if (pendingReauthState.value == null) { + if (currentReauthState.value == null) { currentOuterProviderSelected.value(provider) } } @@ -374,7 +391,7 @@ fun FirebaseAuthScreen( }, onManageMfa = { // Inert while armed: this content stays composed beneath the slot. - if (pendingReauthState.value == null) { + if (reauthState == null) { if (configuration.isMfaEnabled) { navController.navigate(AuthRoute.MfaEnrollment.route) } else { @@ -417,7 +434,7 @@ fun FirebaseAuthScreen( }, onNavigate = { route -> // Inert while armed: this content stays composed beneath the slot. - if (pendingReauthState.value == null) { + if (reauthState == null) { navController.navigate(route.route) } } @@ -487,7 +504,7 @@ fun FirebaseAuthScreen( LaunchedEffect(emailLink) { // A link arriving while armed would sign in on the non-reauth configuration, and // could sign in a different user the armed operation can then never match. - if (emailLink != null && emailProvider != null && pendingReauthState.value == null) { + if (emailLink != null && emailProvider != null && reauthState == null) { try { // Try to retrieve saved email from DataStore (same-device flow) val savedEmail = @@ -521,77 +538,35 @@ fun FirebaseAuthScreen( } // Synchronise auth state changes with navigation stack. - LaunchedEffect(authState) { - val state = authState + LaunchedEffect(observedAuthState) { + val state = observedAuthState ?: return@LaunchedEffect val previous = previousAuthState.value previousAuthState.value = state val currentRoute = navController.currentBackStackEntry?.destination?.route - // Armed before this composition existed, but the attempt in flight died with it: - // the operation can no longer run, so report it instead of dropping it silently. - if (reauthArmedUid.value != null && - pendingReauthState.value == null && - state is AuthState.Loading + val savedPresentation = reauthPresentation.value + + // A saveable marker without its matching process-local AuthState means process + // death discarded the retry callback. Report that loss for every real first + // emission, not only Loading; FirebaseAuth commonly restores as Success. + if (savedPresentation != null && + state !is AuthState.Reauthentication && + state !is AuthState.Aborted ) { - clearPendingReauth() + clearReauthPresentation() authUI.updateAuthState( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) - ) + AuthState.Reauthentication.Interrupted( + requestId = savedPresentation.requestId, + userUid = savedPresentation.userUid, ) ) return@LaunchedEffect } + when (state) { is AuthState.Success -> { pendingResolver.value = null pendingLinkingCredential.value = null - val expectedReauthUid = pendingReauthState.value?.user?.uid - if (expectedReauthUid != null) { - if (state.reauthenticatedUid == expectedReauthUid) { - val retry = pendingReauthOperation.value - clearPendingReauth() - 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)) - } - } - } else if (currentRoute != AuthRoute.Success.route) { - // Nothing to resume, but the slot is gone: land on Success - // rather than whatever route it was covering. - navController.navigate(AuthRoute.Success.route) { - popUpTo(navController.graph.findStartDestination().id) { - inclusive = true - } - launchSingleTop = true - } - } - // A reauthentication is never a sign-in: onSignInSuccess must not - // fire for it, whether or not an operation was attached. - return@LaunchedEffect - } else { - // Only the ambient re-emission for the armed user is benign; a - // stamp for another account leaves the slot inert, unexplained. - if (state.reauthenticatedUid != null || - authUI.auth.currentUser?.uid != expectedReauthUid - ) { - setReauthFailure( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_incomplete) - ) - ) - } - return@LaunchedEffect - } - } - state.result?.let { result -> if (state.user.uid != lastSuccessfulUserId.value) { onSignInSuccess(result) @@ -613,11 +588,11 @@ fun FirebaseAuthScreen( } } - is AuthState.ReauthenticationRequired -> { + is AuthState.Reauthentication.Required -> { val linked = configuration.providers.filterToLinkedProviders(state.user) if (linked.isEmpty()) { - clearPendingReauth() - authUI.updateAuthState( + clearReauthPresentation() + authUI.finishReauthentication( AuthState.Error( AuthException.UnknownException( context.getString(R.string.fui_error_reauth_no_linked_providers) @@ -626,31 +601,108 @@ fun FirebaseAuthScreen( ) return@LaunchedEffect } - // The durability re-emit and a post-recreation re-derivation are the same - // arming: keep the latched error and sub-flow. A new one clears both. - val sameArming = pendingReauthState.value === state || - (pendingReauthState.value == null && - reauthArmedUid.value == state.user.uid) - if (!sameArming) { - reauthSubRoute.value = null - setReauthFailure(null) + val currentPresentation = reauthPresentation.value + if (currentPresentation?.requestId != state.requestId) { + reauthPresentation.value = ReauthPresentationState( + requestId = state.requestId, + userUid = state.userUid, + ) + } + } + + is AuthState.Reauthentication.Succeeded -> { + val success = state.success + if (success.reauthenticatedUid != state.userUid || + success.user.uid != state.userUid + ) { + authUI.updateAuthState( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_incomplete) + ) + ) + ) + } else { + authUI.updateAuthState( + AuthState.Reauthentication.RetryingOperation(state.request) + ) + } + } + + is AuthState.Reauthentication.RetryingOperation -> { + val request = state.request + if (!request.hasRetryOperation) { + clearReauthPresentation() + authUI.finishReauthentication( + AuthState.Success( + result = null, + user = request.user, + ) + ) + return@LaunchedEffect + } + // Claimed before the first suspension point, so a recreation that resumes + // this phase reports the interruption instead of running the operation again. + val retry = request.claimRetryOperation() + if (retry == null) { + clearReauthPresentation() + authUI.finishReauthentication( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_interrupted) + ) + ) + ) + return@LaunchedEffect + } + try { + retry(context) + val currentUser = authUI.auth.currentUser + val outcome = if (currentUser != null) { + AuthState.Success(result = null, user = currentUser) + } else { + AuthState.Idle + } + authUI.updateReauthentication(state.requestId) { + it.operationFinished(outcome) + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + authUI.updateAuthState(AuthState.Error(e)) } - reauthArmedUid.value = state.user.uid - pendingReauthOperation.value = state.retryOperation - pendingReauthState.value = state - pendingReauthConfig.value = configuration.copy( - providers = linked, - isNewEmailAccountsAllowed = false, - isReauthenticationMode = true, + } + + is AuthState.Reauthentication.OperationFinished -> { + clearReauthPresentation() + authUI.finishReauthentication(state.outcome) + } + + is AuthState.Reauthentication.Interrupted -> { + clearReauthPresentation() + authUI.finishReauthentication( + AuthState.Error( + AuthException.UnknownException( + context.getString(R.string.fui_error_reauth_interrupted) + ) + ) ) } + is AuthState.Reauthentication -> { + // Activity recreation may resume in any in-flight reauthentication phase. + val currentPresentation = reauthPresentation.value + if (currentPresentation?.requestId != state.requestId) { + reauthPresentation.value = ReauthPresentationState( + requestId = state.requestId, + userUid = state.userUid, + ) + } + } + is AuthState.RequiresEmailVerification, is AuthState.RequiresProfileCompletion, -> { - // Reachable while armed (a wrong password in the sub-flow falls back to - // this): navigating would wipe the back stack out from under the slot. - if (pendingReauthState.value != null) return@LaunchedEffect pendingResolver.value = null pendingLinkingCredential.value = null if (currentRoute != AuthRoute.Success.route) { @@ -662,18 +714,6 @@ fun FirebaseAuthScreen( } is AuthState.RequiresMfa -> { - // An MFA-enrolled account cannot complete reauthentication today; pushing - // the challenge under the slot would leave a dead UI with no explanation. - if (pendingReauthState.value != null) { - authUI.updateAuthState( - AuthState.Error( - AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_mfa_unsupported) - ) - ) - ) - return@LaunchedEffect - } pendingResolver.value = state.resolver if (currentRoute != AuthRoute.MfaChallenge.route) { navController.navigate(AuthRoute.MfaChallenge.route) { @@ -683,11 +723,7 @@ fun FirebaseAuthScreen( } is AuthState.Cancelled -> { - if (pendingReauthState.value != null) { - resetTransientAuthState() - return@LaunchedEffect - } - clearPendingReauth() + clearReauthPresentation() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -705,7 +741,7 @@ fun FirebaseAuthScreen( // Hosted by FirebaseAuthActivity: its own authStateFlow collector // independently finishes the activity and resets state on Aborted. if (activity !is FirebaseAuthActivity) { - clearPendingReauth() + clearReauthPresentation() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -717,7 +753,7 @@ fun FirebaseAuthScreen( // A notification resets to Idle purely to avoid leaking to a freshly // created screen — that's not a request to leave the current one. if (previous != null && !previous.isNotification) { - clearPendingReauth() + clearReauthPresentation() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -734,9 +770,45 @@ fun FirebaseAuthScreen( } } + val reauthUiVisible = when (reauthState) { + is AuthState.Reauthentication.Required, + is AuthState.Reauthentication.Authenticating, + is AuthState.Reauthentication.AttemptFailed, + is AuthState.Reauthentication.RequiresMfa, + is AuthState.Reauthentication.PhoneNumberVerificationRequired, + is AuthState.Reauthentication.SmsAutoVerified, + is AuthState.Reauthentication.PasswordResetLinkSent, + is AuthState.Reauthentication.EmailSignInLinkSent, + -> true + + else -> false + } + // Derived from the state rather than the saveable marker, because the resolver lives + // only in the state: this sub-route can never outlive the challenge it presents. + val reauthSubRoute = if (reauthMfa != null) { + AuthRoute.MfaChallenge + } else { + reauthPresentation.value?.subRoute + } val reauthSlotActive = reauthContent != null && - pendingReauthState.value != null && - reauthSubRoute.value == null + reauthUiVisible && + reauthSubRoute == null + + val reauthAttemptFailure = + reauthState as? AuthState.Reauthentication.AttemptFailed + if (reauthAttemptFailure != null && !reauthSlotActive) { + LaunchedEffect(reauthAttemptFailure) { + val exception = reauthException ?: return@LaunchedEffect + dialogController.showErrorDialog( + exception = exception, + // The latched failure is never the live Error, so without an explicit key + // reopening a sub-flow re-adds this effect and re-shows a stale dialog. + errorState = AuthState.Error(reauthAttemptFailure.exception), + onRetry = null, + onRecover = null, + ) + } + } // Handle errors using top-level dialog controller val errorState = authState as? AuthState.Error @@ -747,20 +819,12 @@ fun FirebaseAuthScreen( else -> AuthException.from(throwable, stringProvider) } - if (reauthSlotActive) { - if (exception !is AuthException.AuthCancelledException) { - setReauthFailure(exception) - } - resetTransientAuthState() - return@LaunchedEffect - } - dialogController.showErrorDialog( exception = exception, errorState = errorState, // Child screens own their retry logic, so there is nothing to retry here. onRetry = null, - onRecover = if (pendingReauthState.value != null) null else when (exception) { + onRecover = when (exception) { is AuthException.EmailAlreadyInUseException -> { { navController.navigate(AuthRoute.Email.route) { @@ -816,54 +880,80 @@ fun FirebaseAuthScreen( } ) // Consumed immediately so this doesn't leak to a freshly created screen. - resetTransientAuthState() + authUI.updateAuthState(AuthState.Idle) } } // Render the top-level dialog (only one instance) dialogController.CurrentDialog() - val loadingState = authState as? AuthState.Loading - if (loadingState != null && !reauthSlotActive) { - LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading) + val loadingMessage = when (val state = authState) { + is AuthState.Loading -> state.message + is AuthState.Reauthentication.Authenticating -> state.message + is AuthState.Reauthentication.RetryingOperation -> null + else -> null + } + val isLoading = authState is AuthState.Loading || + authState is AuthState.Reauthentication.Authenticating || + authState is AuthState.Reauthentication.RetryingOperation + if (isLoading && !reauthSlotActive) { + LoadingDialog(loadingMessage ?: stringProvider.progressDialogLoading) } // Keyed on authUI only: onSignInCancelled is a caller lambda that is typically not // remembered, so keying on it would defeat the remember entirely. val currentOnSignInCancelled = rememberUpdatedState(onSignInCancelled) - val onReauthDismiss: () -> Unit = remember(authUI, clearPendingReauth) { + val onReauthDismiss: () -> Unit = remember(authUI, clearReauthPresentation) { { - clearPendingReauth() - authUI.updateAuthState(AuthState.Idle) + clearReauthPresentation() + authUI.finishReauthentication(AuthState.Idle) // Abandoning reauthentication drops the pending operation for good, so the // host has to learn it will never run. A cancelled provider attempt does not. currentOnSignInCancelled.value() } } - val onReauthAttemptStarted: () -> Unit = - remember(setReauthFailure) { { setReauthFailure(null) } } + val onReauthAttemptStarted: () -> Unit = remember(authUI, reauthState?.requestId) { + { + reauthState?.requestId?.let { requestId -> + authUI.updateReauthentication(requestId) { it.attemptStarted() } + } + } + } val onReauthSubRouteChange: (AuthRoute?) -> Unit = - remember { { route -> reauthSubRoute.value = route } } + remember { + { route -> + reauthPresentation.value = reauthPresentation.value?.copy(subRoute = route) + } + } + val onReauthMfaError: (Exception) -> Unit = remember(authUI) { + { exception -> + // Clear the sub-flow that triggered the challenge so the failure lands on the + // slot, where the caller renders `error`, rather than back inside that sub-flow. + reauthPresentation.value = reauthPresentation.value?.copy(subRoute = null) + authUI.updateAuthState(AuthState.Error(exception)) + } + } - val reauthConfig = pendingReauthConfig.value - val pendingReauth = pendingReauthState.value - if (reauthConfig != null && pendingReauth != null) { + if (reauthConfig != null && reauthRequired != null && reauthUiVisible) { if (reauthContent != null) { CustomReauthContent( authUI = authUI, reauthConfig = reauthConfig, - reauthState = pendingReauth, + reauthState = reauthRequired, activity = activity, context = context, emailContent = emailContent, phoneContent = phoneContent, - isLoading = loadingState != null, + mfaChallengeContent = mfaChallengeContent, + mfaResolver = reauthMfa?.resolver, + isLoading = authState is AuthState.Reauthentication.Authenticating, // The same string ErrorRecoveryDialog would have shown for this failure. - error = reauthErrorMessage.value, - exception = reauthFailure.value, - activeSubRoute = reauthSubRoute.value, + error = reauthErrorMessage, + exception = reauthException, + activeSubRoute = reauthSubRoute, onActiveSubRouteChange = onReauthSubRouteChange, onAttemptStarted = onReauthAttemptStarted, + onMfaError = onReauthMfaError, onDismiss = onReauthDismiss, content = reauthContent, ) @@ -875,11 +965,14 @@ fun FirebaseAuthScreen( ReauthSheetContent( authUI = authUI, reauthConfig = reauthConfig, + requestId = reauthRequired.requestId, activity = activity, context = context, - prefillEmail = pendingReauth.user.email, + prefillEmail = reauthRequired.user.email, emailContent = emailContent, phoneContent = phoneContent, + mfaChallengeContent = mfaChallengeContent, + mfaResolver = reauthMfa?.resolver, customMethodPickerLayout = customMethodPickerLayout, onDismiss = onReauthDismiss, ) @@ -890,19 +983,6 @@ fun FirebaseAuthScreen( } } -// Saved as its route String — AuthRoute is not Parcelable, and Email/Phone are the only reauth -// sub-flows, so any other saved route restores as "no sub-flow" rather than a dead branch. -private val ReauthSubRouteSaver: Saver = Saver( - save = { it?.route }, - restore = { route -> - when (route) { - AuthRoute.Email.route -> AuthRoute.Email - AuthRoute.Phone.route -> AuthRoute.Phone - else -> null - } - }, -) - sealed class AuthRoute(val route: String) { object MethodPicker : AuthRoute("auth_method_picker") object Email : AuthRoute("auth_email") @@ -1116,204 +1196,9 @@ private fun LoadingDialog(message: String) { } ) } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ReauthSheetContent( - authUI: FirebaseAuthUI, - 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)?, - onDismiss: () -> Unit, -) { - val sheetNavController = rememberNavController() - val startRoute = remember(reauthConfig) { getStartRoute(reauthConfig) } - val skipsMethodPicker = startRoute != AuthRoute.MethodPicker - val onProviderSelected = authUI.rememberOnProviderSelected( - context = context, - activity = activity, - config = reauthConfig, - onNavigate = { route -> sheetNavController.navigate(route.route) }, - ) - - NavHost( - navController = sheetNavController, - startDestination = startRoute.route, - enterTransition = { fadeIn(animationSpec = tween(700)) }, - exitTransition = { fadeOut(animationSpec = tween(700)) }, - popEnterTransition = { fadeIn(animationSpec = tween(700)) }, - popExitTransition = { fadeOut(animationSpec = tween(700)) }, - ) { - composable(AuthRoute.MethodPicker.route) { - if (customMethodPickerLayout != null) { - Box(modifier = Modifier.fillMaxSize()) { - customMethodPickerLayout(reauthConfig.providers, onProviderSelected) - } - } else { - Scaffold { innerPadding -> - AuthMethodPicker( - modifier = Modifier.padding(innerPadding), - providers = reauthConfig.providers, - onProviderSelected = onProviderSelected, - ) - } - } - } - - composable(AuthRoute.Email.route) { - com.firebase.ui.auth.ui.screens.email.EmailAuthScreen( - context = context, - configuration = reauthConfig, - authUI = authUI, - prefillEmail = prefillEmail, - content = emailContent, - onSuccess = {}, - onError = {}, - onCancel = { - if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss() - } - ) - } - - composable(AuthRoute.Phone.route) { - com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen( - context = context, - configuration = reauthConfig, - authUI = authUI, - content = phoneContent, - onSuccess = {}, - onError = {}, - onCancel = { - if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss() - } - ) - } - } -} - -/** - * 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 onActiveSubRouteChange Invoked when the active sub-flow opens or closes. - * @param onAttemptStarted Invoked just before an in-place credential 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?, - exception: Exception?, - activeSubRoute: AuthRoute?, - onActiveSubRouteChange: (AuthRoute?) -> Unit, - onAttemptStarted: () -> Unit, - onDismiss: () -> Unit, - content: @Composable (ReauthContentState) -> Unit, -) { - val openSubFlow: (AuthRoute) -> Unit = remember(onActiveSubRouteChange) { - { route -> onActiveSubRouteChange(route) } - } - val onProviderSelected = authUI.rememberOnProviderSelected( - context = context, - activity = activity, - config = reauthConfig, - onNavigate = openSubFlow, - ) - // rememberOnProviderSelected returns a fresh lambda per recomposition, so read it through a - // holder rather than keying on it — otherwise this remember would never hit. - val currentOnProviderSelected = rememberUpdatedState(onProviderSelected) - val onProviderSelectedFromSlot: (AuthProvider) -> Unit = remember(onAttemptStarted) { - { provider -> - // Email and Phone only open a sub-flow; clearing the latched error there would wipe a - // real failure on a mis-tap, and `error` is documented to survive backing out. - if (provider !is AuthProvider.Email && provider !is AuthProvider.Phone) { - onAttemptStarted() - } - currentOnProviderSelected.value(provider) - } - } - val closeSubFlow: () -> Unit = - remember(onActiveSubRouteChange) { { onActiveSubRouteChange(null) } } - - val slotState = ReauthContentState( - user = reauthState.user, - reason = reauthState.reason, - providers = reauthConfig.providers, - onProviderSelected = onProviderSelectedFromSlot, - isLoading = isLoading, - error = error, - onDismiss = onDismiss, - exception = exception, - ) - - when (val subRoute = activeSubRoute) { - null -> content(slotState) - - 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 -> { - // No sub-flow for this route: keep the caller's slot rather than crashing - // composition. Add a branch when a new provider gains its own screen. - LaunchedEffect(subRoute) { - Log.w( - "FirebaseAuthScreen", - "No reauth sub-flow for ${subRoute?.route}; staying on the slot" - ) - onActiveSubRouteChange(null) - } - content(slotState) - } - } -} @Composable -private fun FirebaseAuthUI.rememberOnProviderSelected( +internal fun FirebaseAuthUI.rememberOnProviderSelected( context: android.content.Context, activity: android.app.Activity?, config: AuthUIConfiguration, 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 155f60fa8..7ebfcd263 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 @@ -187,10 +187,14 @@ fun EmailAuthScreen( } val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) - val isLoading = authState is AuthState.Loading + val isLoading = authState is AuthState.Loading || + authState is AuthState.Reauthentication.Authenticating val authCredentialForLinking = remember { credentialForLinking } - val errorMessage = - if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null + val errorMessage = when (val state = authState) { + is AuthState.Error -> state.exception.message + is AuthState.Reauthentication.AttemptFailed -> state.exception.message + else -> null + } // Latched locally since these get consumed (reset to Idle) below — deriving directly from // authState would close ResetPasswordUI/SignInEmailLinkUI's dialogs as soon as it resets. @@ -275,11 +279,21 @@ fun EmailAuthScreen( authUI.updateAuthState(AuthState.Idle) } + is AuthState.Reauthentication.PasswordResetLinkSent -> { + resetLinkSentLocal = true + authUI.updateReauthentication(state.requestId) { it.returnedToProviderSelection() } + } + is AuthState.EmailSignInLinkSent -> { emailSignInLinkSentLocal = true authUI.updateAuthState(AuthState.Idle) } + is AuthState.Reauthentication.EmailSignInLinkSent -> { + emailSignInLinkSentLocal = true + authUI.updateReauthentication(state.requestId) { it.returnedToProviderSelection() } + } + else -> Unit } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeDefaults.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeDefaults.kt index 0780348ee..7cc1561e3 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeDefaults.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.ui.screens +package com.firebase.ui.auth.ui.screens.mfa import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeScreen.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeScreen.kt index 2dab06adb..7cc20534c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaChallengeScreen.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.ui.screens +package com.firebase.ui.auth.ui.screens.mfa import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt index bb0e1dbf6..4d5cf66af 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.ui.screens +package com.firebase.ui.auth.ui.screens.mfa import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt similarity index 99% rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt index a9db40f1e..9031d4b0d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt @@ -12,7 +12,7 @@ * limitations under the License. */ -package com.firebase.ui.auth.ui.screens +package com.firebase.ui.auth.ui.screens.mfa import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt index 7cef228a1..13671b32e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt @@ -181,10 +181,16 @@ fun PhoneAuthScreen( val currentAuthState = remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) val authState by currentAuthState - val isLoading = authState is AuthState.Loading + val isLoading = authState is AuthState.Loading || + authState is AuthState.Reauthentication.Authenticating // A cancelled Loading outlives this composition on the process-scoped FirebaseAuthUI, and // currentAuthState is re-remembered per authUI, so onDispose reads the right instance. + // + // Only an ordinary Loading is retracted here. Under a reauthentication request the pending + // Loading is published as Reauthentication.Authenticating, which the reauth flow's own teardown + // owns; and were this to write anyway, updateAuthState folds Idle back into the armed request + // rather than dropping it. DisposableEffect(authUI) { onDispose { if (currentAuthState.value is AuthState.Loading) { @@ -192,8 +198,11 @@ fun PhoneAuthScreen( } } } - val errorMessage = - if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null + val errorMessage = when (val state = authState) { + is AuthState.Error -> state.exception.message + is AuthState.Reauthentication.AttemptFailed -> state.exception.message + else -> null + } // Handle resend timer countdown LaunchedEffect(resendTimerSeconds.intValue) { @@ -215,14 +224,33 @@ fun PhoneAuthScreen( } } - is AuthState.PhoneNumberVerificationRequired -> { - verificationId.value = state.verificationId - forceResendingToken.value = state.forceResendingToken + is AuthState.PhoneNumberVerificationRequired, + is AuthState.Reauthentication.PhoneNumberVerificationRequired -> { + verificationId.value = when (state) { + is AuthState.PhoneNumberVerificationRequired -> state.verificationId + is AuthState.Reauthentication.PhoneNumberVerificationRequired -> { + state.verificationId + } + else -> error("Unreachable phone verification state") + } + forceResendingToken.value = when (state) { + is AuthState.PhoneNumberVerificationRequired -> state.forceResendingToken + is AuthState.Reauthentication.PhoneNumberVerificationRequired -> { + state.forceResendingToken + } + else -> error("Unreachable phone verification state") + } step.value = PhoneAuthStep.EnterVerificationCode resendTimerSeconds.intValue = provider.timeout.toInt() // Start 60-second countdown } - is AuthState.SMSAutoVerified -> { + is AuthState.SMSAutoVerified, + is AuthState.Reauthentication.SmsAutoVerified -> { + val credential = when (state) { + is AuthState.SMSAutoVerified -> state.credential + is AuthState.Reauthentication.SmsAutoVerified -> state.credential + else -> error("Unreachable SMS verification state") + } // Auto-verification succeeded, sign in with the credential // and clear pending verification tracking pendingVerificationPhoneNumber.value = null @@ -240,13 +268,17 @@ fun PhoneAuthScreen( } else { // Consumed before the async sign-in call so it can't be clobbered by that // call's own state. - authUI.updateAuthState(AuthState.Idle) + if (state is AuthState.Reauthentication.SmsAutoVerified) { + authUI.updateReauthentication(state.requestId) { it.attemptStarted() } + } else { + authUI.updateAuthState(AuthState.Idle) + } coroutineScope.launch { try { authUI.signInWithPhoneAuthCredential( context = context, config = configuration, - credential = state.credential + credential = credential ) } catch (e: Exception) { // Error will be handled by authState flow @@ -411,8 +443,16 @@ fun PhoneAuthScreen( resendTimer = resendTimerSeconds.intValue, onChangeNumberClick = { cancelVerification("changing phone number") - // Nothing replaces the cancelled attempt here, so this handler retracts its Loading. - authUI.updateAuthState(AuthState.Idle) + // Nothing replaces the cancelled attempt here, so this handler retracts its Loading - + // as the armed request's provider-selection phase when one is running, Idle otherwise. + val currentReauthentication = authState as? AuthState.Reauthentication + if (currentReauthentication != null) { + authUI.updateReauthentication(currentReauthentication.requestId) { + it.returnedToProviderSelection() + } + } else { + authUI.updateAuthState(AuthState.Idle) + } verificationJob.value = null isSubmittingCode.value = false step.value = PhoneAuthStep.EnterPhoneNumber diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/CustomReauthContent.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/CustomReauthContent.kt new file mode 100644 index 000000000..dcf29f193 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/CustomReauthContent.kt @@ -0,0 +1,188 @@ +/* + * 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.reauth + +import android.util.Log +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +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.auth_provider.AuthProvider +import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState +import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen +import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.ui.screens.rememberOnProviderSelected +import com.google.firebase.auth.MultiFactorResolver + +/** + * 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 onActiveSubRouteChange Invoked when the active sub-flow opens or closes. + * @param onAttemptStarted Invoked just before an in-place credential attempt begins. + * @param mfaResolver Non-null while the reauthentication needs a second factor resolved. + * @param onMfaError Invoked when resolving the second factor fails. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CustomReauthContent( + authUI: FirebaseAuthUI, + reauthConfig: AuthUIConfiguration, + reauthState: AuthState.Reauthentication.Required, + activity: android.app.Activity?, + context: android.content.Context, + emailContent: (@Composable (EmailAuthContentState) -> Unit)?, + phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, + mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)?, + mfaResolver: MultiFactorResolver?, + isLoading: Boolean, + error: String?, + exception: Exception?, + activeSubRoute: AuthRoute?, + onActiveSubRouteChange: (AuthRoute?) -> Unit, + onAttemptStarted: () -> Unit, + onMfaError: (Exception) -> Unit, + onDismiss: () -> Unit, + content: @Composable (ReauthContentState) -> Unit, +) { + val openSubFlow: (AuthRoute) -> Unit = remember(onActiveSubRouteChange) { + { route -> onActiveSubRouteChange(route) } + } + val onProviderSelected = authUI.rememberOnProviderSelected( + context = context, + activity = activity, + config = reauthConfig, + onNavigate = openSubFlow, + ) + // rememberOnProviderSelected returns a fresh lambda per recomposition, so read it through a + // holder rather than keying on it — otherwise this remember would never hit. + val currentOnProviderSelected = rememberUpdatedState(onProviderSelected) + val onProviderSelectedFromSlot: (AuthProvider) -> Unit = remember(onAttemptStarted) { + { provider -> + // Email and Phone only open a sub-flow; clearing the latched error there would wipe a + // real failure on a mis-tap, and `error` is documented to survive backing out. + if (provider !is AuthProvider.Email && provider !is AuthProvider.Phone) { + onAttemptStarted() + } + currentOnProviderSelected.value(provider) + } + } + val closeSubFlow: () -> Unit = remember( + authUI, + reauthState.requestId, + onActiveSubRouteChange, + ) { + { + authUI.updateReauthentication(reauthState.requestId) { it.attemptCancelled() } + onActiveSubRouteChange(null) + } + } + + val slotState = ReauthContentState( + user = reauthState.user, + reason = reauthState.reason, + providers = reauthConfig.providers, + onProviderSelected = onProviderSelectedFromSlot, + isLoading = isLoading, + error = error, + onDismiss = onDismiss, + exception = exception, + ) + + when (val subRoute = activeSubRoute) { + null -> content(slotState) + + 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, + ) + } + + AuthRoute.MfaChallenge -> if (mfaResolver != null) { + ModalBottomSheet( + onDismissRequest = closeSubFlow, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + MfaChallengeScreen( + resolver = mfaResolver, + auth = authUI.auth, + content = mfaChallengeContent, + // Resolving the challenge is what completed the reauthentication, so this is + // where the stamped Success for it is published. + onSuccess = { authUI.publishReauthenticationSuccess() }, + onCancel = closeSubFlow, + onError = onMfaError, + ) + } + } else { + content(slotState) + } + + else -> { + // No sub-flow for this route: keep the caller's slot rather than crashing + // composition. Add a branch when a new provider gains its own screen. + LaunchedEffect(subRoute) { + Log.w( + "FirebaseAuthScreen", + "No reauth sub-flow for ${subRoute.route}; staying on the slot" + ) + onActiveSubRouteChange(null) + } + content(slotState) + } + } +} 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/reauth/ReauthContentState.kt similarity index 94% rename from auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt rename to auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt index 87a16a0c9..cdbf54cd4 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/ReauthContentState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt @@ -12,9 +12,10 @@ * limitations under the License. */ -package com.firebase.ui.auth.ui.screens +package com.firebase.ui.auth.ui.screens.reauth import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen import com.google.firebase.auth.FirebaseUser /** @@ -66,7 +67,9 @@ import com.google.firebase.auth.FirebaseUser * @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 credential attempt starts, so it can be rendered inline. Backing out of an attempt is not a failure and leaves this unchanged. Survives Activity recreation. * @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. - * @property exception The exception behind [error], or `null` if the last attempt did not fail. Branch on its type when a message alone is not enough. Not retained across Activity recreation, which leaves [error] set with this `null`. + * @property exception The exception behind [error], or `null` if the last attempt did not fail. + * Branch on its type when a message alone is not enough. Survives Activity recreation with the + * active reauthentication request. * * @since 10.0.0 */ @@ -92,6 +95,6 @@ data class ReauthContentState( /** Callback to abandon reauthentication and drop the pending operation. */ val onDismiss: () -> Unit = {}, - /** The exception behind [error], if the last attempt failed. Dropped on recreation. */ + /** The exception behind [error], if the last attempt failed. */ val exception: Exception? = null, ) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthPresentationState.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthPresentationState.kt new file mode 100644 index 000000000..8f31bdd1a --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthPresentationState.kt @@ -0,0 +1,43 @@ +/* + * 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.reauth + +import androidx.compose.runtime.saveable.Saver +import com.firebase.ui.auth.ui.screens.AuthRoute + +internal data class ReauthPresentationState( + val requestId: String, + val userUid: String, + val subRoute: AuthRoute? = null, +) + +// The process-local request and retry callback live in AuthState.Reauthentication. Only the marker +// and presentation route needed for Activity/process restoration are saveable here. +internal val ReauthPresentationStateSaver: Saver> = Saver( + save = { state -> + state?.let { listOf(it.requestId, it.userUid, it.subRoute?.route) } + }, + restore = { saved -> + ReauthPresentationState( + requestId = requireNotNull(saved[0]), + userUid = requireNotNull(saved[1]), + subRoute = when (saved.getOrNull(2)) { + AuthRoute.Email.route -> AuthRoute.Email + AuthRoute.Phone.route -> AuthRoute.Phone + else -> null + }, + ) + }, +) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSheetContent.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSheetContent.kt new file mode 100644 index 000000000..615ea38db --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthSheetContent.kt @@ -0,0 +1,169 @@ +/* + * 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.reauth + +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +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.auth_provider.AuthProvider +import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState +import com.firebase.ui.auth.ui.screens.getStartRoute +import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState +import com.firebase.ui.auth.ui.screens.rememberOnProviderSelected +import com.google.firebase.auth.MultiFactorResolver + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ReauthSheetContent( + authUI: FirebaseAuthUI, + reauthConfig: AuthUIConfiguration, + requestId: String, + activity: android.app.Activity?, + context: android.content.Context, + prefillEmail: String?, + emailContent: (@Composable (EmailAuthContentState) -> Unit)?, + phoneContent: (@Composable (PhoneAuthContentState) -> Unit)?, + mfaChallengeContent: (@Composable (MfaChallengeContentState) -> Unit)?, + mfaResolver: MultiFactorResolver?, + customMethodPickerLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)?, + onDismiss: () -> Unit, +) { + val sheetNavController = rememberNavController() + val startRoute = remember(reauthConfig) { getStartRoute(reauthConfig) } + val skipsMethodPicker = startRoute != AuthRoute.MethodPicker + val onProviderSelected = authUI.rememberOnProviderSelected( + context = context, + activity = activity, + config = reauthConfig, + onNavigate = { route -> sheetNavController.navigate(route.route) }, + ) + // Provider selection for this sheet, which is where a consumed challenge returns to. With a + // single provider that is its own screen, so the credential attempt can simply be repeated. + val returnToProviderSelection: () -> Unit = { + sheetNavController.navigate(startRoute.route) { + popUpTo(startRoute.route) { inclusive = true } + launchSingleTop = true + } + } + // Inside the sheet's own NavHost: on the outer one the challenge would render underneath + // this modal, where the user cannot reach it. + LaunchedEffect(mfaResolver) { + if (mfaResolver != null) { + sheetNavController.navigate(AuthRoute.MfaChallenge.route) { launchSingleTop = true } + } + } + + NavHost( + navController = sheetNavController, + startDestination = startRoute.route, + enterTransition = { fadeIn(animationSpec = tween(700)) }, + exitTransition = { fadeOut(animationSpec = tween(700)) }, + popEnterTransition = { fadeIn(animationSpec = tween(700)) }, + popExitTransition = { fadeOut(animationSpec = tween(700)) }, + ) { + composable(AuthRoute.MethodPicker.route) { + if (customMethodPickerLayout != null) { + Box(modifier = Modifier.fillMaxSize()) { + customMethodPickerLayout(reauthConfig.providers, onProviderSelected) + } + } else { + Scaffold { innerPadding -> + AuthMethodPicker( + modifier = Modifier.padding(innerPadding), + providers = reauthConfig.providers, + onProviderSelected = onProviderSelected, + ) + } + } + } + + composable(AuthRoute.Email.route) { + com.firebase.ui.auth.ui.screens.email.EmailAuthScreen( + context = context, + configuration = reauthConfig, + authUI = authUI, + prefillEmail = prefillEmail, + content = emailContent, + onSuccess = {}, + onError = {}, + onCancel = { + if (skipsMethodPicker || !sheetNavController.popBackStack()) { + onDismiss() + } else { + authUI.updateReauthentication(requestId) { it.attemptCancelled() } + } + } + ) + } + + composable(AuthRoute.Phone.route) { + com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen( + context = context, + configuration = reauthConfig, + authUI = authUI, + content = phoneContent, + onSuccess = {}, + onError = {}, + onCancel = { + if (skipsMethodPicker || !sheetNavController.popBackStack()) { + onDismiss() + } else { + authUI.updateReauthentication(requestId) { it.attemptCancelled() } + } + } + ) + } + + composable(AuthRoute.MfaChallenge.route) { + if (mfaResolver != null) { + MfaChallengeScreen( + resolver = mfaResolver, + auth = authUI.auth, + content = mfaChallengeContent, + // Resolving the challenge is what completed the reauthentication, so this is + // where the stamped Success for it is published. + onSuccess = { authUI.publishReauthenticationSuccess() }, + onCancel = { + returnToProviderSelection() + authUI.updateReauthentication(requestId) { it.attemptCancelled() } + }, + onError = { exception -> + returnToProviderSelection() + authUI.updateAuthState(AuthState.Error(exception)) + }, + ) + } + } + } +} diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 20191fd41..5b2ef2917 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -183,7 +183,6 @@ That did not confirm your identity for this account. Please try again. Confirming your identity was interrupted. Please try that action again. You cannot create a new account while confirming your identity. - This account uses two-step verification, which cannot be used to confirm your identity here. Confirming your identity here needs this account\'s password. If you sign in with an email link instead of a password, this account cannot be confirmed with a password. Read-only diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt index d025157a7..22738bd28 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUIAuthStateTest.kt @@ -263,11 +263,11 @@ class FirebaseAuthUIAuthStateTest { /** * A host calling raw `auth.signOut()` while a reauthentication is armed used to leave the - * internal state at ReauthenticationRequired: the combine keeps preferring it, so the reauth UI + * internal state at Reauthentication.Required: the combine keeps preferring it, so the reauth UI * stays up over a signed-out session and every provider fails with an untranslated "no user". */ @Test - fun `authStateFlow() clears an armed ReauthenticationRequired when the user signs out`() = + fun `authStateFlow() clears an armed Reauthentication Required when the user signs out`() = runBlocking { `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) `when`(mockFirebaseUser.isEmailVerified).thenReturn(true) @@ -283,11 +283,11 @@ class FirebaseAuthUIAuthStateTest { verify(mockFirebaseAuth).addAuthStateListener(listenerCaptor.capture()) authUI.updateAuthState( - AuthState.ReauthenticationRequired(mockFirebaseUser, reason = "Confirm it is you") + AuthState.Reauthentication.Required(mockFirebaseUser, reason = "Confirm it is you") ) delay(100) assertThat(states.last()) - .isInstanceOf(AuthState.ReauthenticationRequired::class.java) + .isInstanceOf(AuthState.Reauthentication.Required::class.java) // The host signs out behind the library's back, e.g. authUI.auth.signOut(). `when`(mockFirebaseAuth.currentUser).thenReturn(null) @@ -472,11 +472,11 @@ class FirebaseAuthUIAuthStateTest { } // ============================================================================================= - // delete() ReauthenticationRequired state Tests + // delete() Reauthentication.Required state Tests // ============================================================================================= @Test - fun `delete() emits ReauthenticationRequired state when recent login required`() = runTest { + fun `delete() emits Reauthentication Required state when recent login required`() = runTest { val mockUser = mock(FirebaseUser::class.java) val tcs = TaskCompletionSource() tcs.setException( @@ -495,13 +495,13 @@ class FirebaseAuthUIAuthStateTest { // expected — existing contract preserved } - assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.ReauthenticationRequired::class.java) - val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Reauthentication.Required::class.java) + val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required assertThat(state.user).isEqualTo(mockUser) } @Test - fun `delete() attaches retryOperation to ReauthenticationRequired state`() = runTest { + fun `delete() attaches retryOperation to Reauthentication Required state`() = runTest { val mockUser = mock(FirebaseUser::class.java) val tcs = TaskCompletionSource() tcs.setException( @@ -515,11 +515,177 @@ class FirebaseAuthUIAuthStateTest { val context = ApplicationProvider.getApplicationContext() try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {} - val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required // Fails until delete() passes retryOperation into the state assertThat(state.retryOperation).isNotNull() } + @Test + fun `reauthentication provider states retain the same request`() = runTest { + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") + // Stands in for the composed FirebaseAuthScreen that folding is scoped to. + authUI.addReauthenticationDrainer() + val required = AuthState.Reauthentication.Required(mockFirebaseUser) + authUI.updateAuthState(required) + + authUI.updateAuthState(AuthState.Loading("Signing in")) + val authenticating = authUI.authStateFlow().first() + assertThat(authenticating) + .isInstanceOf(AuthState.Reauthentication.Authenticating::class.java) + assertThat((authenticating as AuthState.Reauthentication).requestId) + .isEqualTo(required.requestId) + + authUI.updateAuthState(AuthState.Error(IllegalArgumentException("wrong password"))) + val failed = authUI.authStateFlow().first() + assertThat(failed) + .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + assertThat((failed as AuthState.Reauthentication).requestId) + .isEqualTo(required.requestId) + + authUI.updateAuthState(AuthState.Cancelled) + val resumed = authUI.authStateFlow().first() + assertThat(resumed).isInstanceOf(AuthState.Reauthentication.Required::class.java) + assertThat((resumed as AuthState.Reauthentication.Required).requestId) + .isEqualTo(required.requestId) + } + + @Test + fun `reauthentication email notifications retain the request until consumed`() = runTest { + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") + authUI.addReauthenticationDrainer() + val required = AuthState.Reauthentication.Required(mockFirebaseUser) + authUI.updateAuthState(required) + + authUI.updateAuthState(AuthState.PasswordResetLinkSent()) + val notification = authUI.authStateFlow().first() + assertThat(notification) + .isInstanceOf(AuthState.Reauthentication.PasswordResetLinkSent::class.java) + assertThat((notification as AuthState.Reauthentication).requestId) + .isEqualTo(required.requestId) + + authUI.updateReauthentication(required.requestId) { it.returnedToProviderSelection() } + val resumed = authUI.authStateFlow().first() + assertThat(resumed).isInstanceOf(AuthState.Reauthentication.Required::class.java) + assertThat((resumed as AuthState.Reauthentication.Required).requestId) + .isEqualTo(required.requestId) + } + + @Test + fun `updateReauthentication ignores a stale requestId`() = runTest { + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") + val required = AuthState.Reauthentication.Required(mockFirebaseUser) + authUI.updateAuthState(required) + + authUI.updateReauthentication("stale-request-id") { it.attemptStarted() } + + val unchanged = authUI.authStateFlow().first() + assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.Required::class.java) + assertThat((unchanged as AuthState.Reauthentication.Required).requestId) + .isEqualTo(required.requestId) + } + + @Test + fun `attemptCancelled does not rewind a surfaced attempt failure`() = runTest { + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") + authUI.addReauthenticationDrainer() + val required = AuthState.Reauthentication.Required(mockFirebaseUser) + authUI.updateAuthState(required) + authUI.updateAuthState(AuthState.Error(IllegalArgumentException("wrong password"))) + assertThat(authUI.authStateFlow().first()) + .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + + authUI.updateReauthentication(required.requestId) { it.attemptCancelled() } + + val unchanged = authUI.authStateFlow().first() + assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + assertThat((unchanged as AuthState.Reauthentication).requestId) + .isEqualTo(required.requestId) + } + + /** + * The phone sub-flow's "Change number" returns to provider selection, and by then a wrong SMS + * code has latched a failure. Clearing it there would erase the only report the user gets. + */ + @Test + fun `returnedToProviderSelection does not wipe a surfaced attempt failure`() = runTest { + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") + authUI.addReauthenticationDrainer() + val required = AuthState.Reauthentication.Required(mockFirebaseUser) + authUI.updateAuthState(required) + authUI.updateAuthState(AuthState.Error(IllegalArgumentException("wrong sms code"))) + assertThat(authUI.authStateFlow().first()) + .isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + + authUI.updateReauthentication(required.requestId) { it.returnedToProviderSelection() } + + val unchanged = authUI.authStateFlow().first() + assertThat(unchanged).isInstanceOf(AuthState.Reauthentication.AttemptFailed::class.java) + assertThat((unchanged as AuthState.Reauthentication).requestId) + .isEqualTo(required.requestId) + } + + /** Credentials were already accepted, so a stray attempt must not rewind the retry phase. */ + @Test + fun `attemptStarted does not rewind a retry in flight`() = runTest { + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") + authUI.addReauthenticationDrainer() + val required = AuthState.Reauthentication.Required(mockFirebaseUser) + authUI.updateAuthState(required) + authUI.updateAuthState(AuthState.Reauthentication.RetryingOperation(required.request)) + + authUI.updateReauthentication(required.requestId) { it.attemptStarted() } + + assertThat(authUI.authStateFlow().first()) + .isInstanceOf(AuthState.Reauthentication.RetryingOperation::class.java) + } + + /** + * `withReauth`/`delete` are public and arm a request with no [FirebaseAuthScreen] composed — + * the caller catches the exception and shows its own UI. Nothing can then drain the request, + * so folding must not apply: the app's own collector has to keep seeing ordinary states. + */ + @Test + fun `a Success reaches collectors while an undrainable request is armed`() = runTest { + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + authUI.updateAuthState(AuthState.Reauthentication.Required(mockFirebaseUser)) + assertThat(authUI.authStateFlow().first()) + .isInstanceOf(AuthState.Reauthentication.Required::class.java) + + authUI.updateAuthState(AuthState.Success(result = null, user = mockFirebaseUser)) + + val observed = authUI.authStateFlow().first() + assertThat(observed).isInstanceOf(AuthState.Success::class.java) + assertThat(observed).isNotInstanceOf(AuthState.Reauthentication::class.java) + } + + /** The same escape for Idle: an undrainable arming is replaced, not made permanent. */ + @Test + fun `an Idle write clears an undrainable armed request`() = runTest { + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") + `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + authUI.updateAuthState(AuthState.Reauthentication.Required(mockFirebaseUser)) + + authUI.updateAuthState(AuthState.Idle) + + assertThat(authUI.authStateFlow().first()) + .isNotInstanceOf(AuthState.Reauthentication::class.java) + } + + @Test + fun `operationFinished only applies while a retry is in flight`() = runTest { + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") + val required = AuthState.Reauthentication.Required(mockFirebaseUser) + authUI.updateAuthState(required) + + authUI.updateReauthentication(required.requestId) { + it.operationFinished(AuthState.Success(result = null, user = mockFirebaseUser)) + } + + assertThat(authUI.authStateFlow().first()) + .isInstanceOf(AuthState.Reauthentication.Required::class.java) + } + // ============================================================================================= // withReauth() Tests // ============================================================================================= @@ -536,7 +702,7 @@ class FirebaseAuthUIAuthStateTest { } @Test - fun `withReauth() emits ReauthenticationRequired when FirebaseAuthRecentLoginRequiredException thrown`() = runTest { + fun `withReauth() emits Reauthentication Required when FirebaseAuthRecentLoginRequiredException thrown`() = runTest { val context = ApplicationProvider.getApplicationContext() `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) @@ -544,13 +710,13 @@ class FirebaseAuthUIAuthStateTest { throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") } - assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.ReauthenticationRequired::class.java) - val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Reauthentication.Required::class.java) + val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required assertThat(state.user).isEqualTo(mockFirebaseUser) } @Test - fun `withReauth() forwards reason to ReauthenticationRequired state`() = runTest { + fun `withReauth() forwards reason to Reauthentication Required state`() = runTest { val context = ApplicationProvider.getApplicationContext() `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) @@ -558,7 +724,7 @@ class FirebaseAuthUIAuthStateTest { throw FirebaseAuthRecentLoginRequiredException("ERROR_REQUIRES_RECENT_LOGIN", "Recent login required") } - val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required assertThat(state.reason).isEqualTo("Verify identity to change email") } @@ -575,7 +741,7 @@ class FirebaseAuthUIAuthStateTest { ) } - val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required assertThat(state.retryOperation).isNotNull() state.retryOperation!!(context) assertThat(callCount).isEqualTo(2) @@ -584,7 +750,9 @@ class FirebaseAuthUIAuthStateTest { @Test fun `withReauth() retryOperation restores auth state after successful retry`() = runTest { val context = ApplicationProvider.getApplicationContext() + `when`(mockFirebaseUser.uid).thenReturn("uid-reauth") `when`(mockFirebaseAuth.currentUser).thenReturn(mockFirebaseUser) + authUI.addReauthenticationDrainer() var callCount = 0 authUI.withReauth(context) { @@ -594,16 +762,37 @@ class FirebaseAuthUIAuthStateTest { ) } - val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required - // Simulate FirebaseAuthScreen: set Loading, then invoke the retry - authUI.updateAuthState(AuthState.Loading()) - state.retryOperation!!(context) + // Reach the retry phase through the uid-gated credential success, not by hand: a Success + // stamped for this request's user is the only thing that may unlock the operation. + authUI.updateAuthState( + AuthState.Success( + result = null, + user = mockFirebaseUser, + reauthenticatedUid = mockFirebaseUser.uid, + ) + ) + val succeeded = authUI.authStateFlow().first() + assertThat(succeeded).isInstanceOf(AuthState.Reauthentication.Succeeded::class.java) + val request = (succeeded as AuthState.Reauthentication.Succeeded).request + assertThat(request.requestId).isEqualTo(state.requestId) + + // What FirebaseAuthScreen does next: claim the operation once, then run it. + authUI.updateAuthState(AuthState.Reauthentication.RetryingOperation(request)) + val retry = requireNotNull(request.claimRetryOperation()) + retry(context) + assertThat(callCount).isEqualTo(2) + // Claimed for good: a second entry into the retry phase has nothing left to run. + assertThat(request.claimRetryOperation()).isNull() - // Auth state must not be stuck on Loading — withReauth owns the state lifecycle + // The retry outcome remains attached to the request until the screen consumes it. val authState = authUI.authStateFlow().first() - assertThat(authState).isNotInstanceOf(AuthState.Loading::class.java) - assertThat(authState).isInstanceOf(AuthState.Success::class.java) + assertThat(authState) + .isInstanceOf(AuthState.Reauthentication.OperationFinished::class.java) + val finished = authState as AuthState.Reauthentication.OperationFinished + assertThat(finished.requestId).isEqualTo(state.requestId) + assertThat(finished.outcome).isInstanceOf(AuthState.Success::class.java) } @Test @@ -654,10 +843,10 @@ class FirebaseAuthUIAuthStateTest { val context = ApplicationProvider.getApplicationContext() try { authUI.delete(context) } catch (_: AuthException.InvalidCredentialsException) {} - val state = authUI.authStateFlow().first() as AuthState.ReauthenticationRequired + val state = authUI.authStateFlow().first() as AuthState.Reauthentication.Required // Fails until delete() passes retryOperation into the state state.retryOperation!!(context) verify(mockUser, times(2)).delete() } -} \ No newline at end of file +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt index 8129e0731..15f1450f2 100644 --- 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 @@ -38,7 +38,11 @@ import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.R import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.mfa.MfaChallengeContentState import com.firebase.ui.auth.ui.components.ERROR_DIALOG_ACTION_TEST_TAG +import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions @@ -46,13 +50,19 @@ import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthInvalidUserException import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorAssertion import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.TotpMultiFactorGenerator +import com.google.firebase.auth.TotpMultiFactorInfo import com.google.firebase.auth.UserInfo +import kotlinx.coroutines.CompletableDeferred +import java.util.concurrent.atomic.AtomicInteger 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.any import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.robolectric.RobolectricTestRunner @@ -163,7 +173,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, reason = "Confirm it is you") + AuthState.Reauthentication.Required(user, reason = "Confirm it is you") ) } composeTestRule.waitForIdle() @@ -211,7 +221,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) } composeTestRule.waitForIdle() @@ -246,7 +256,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) } composeTestRule.waitForIdle() @@ -286,7 +296,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) ) } composeTestRule.waitForIdle() @@ -339,7 +349,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) ) } composeTestRule.waitForIdle() @@ -394,7 +404,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) } composeTestRule.waitForIdle() assertThat(requireNotNull(captured).error).isNull() @@ -454,7 +464,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) ) } composeTestRule.waitForIdle() @@ -493,8 +503,8 @@ class FirebaseAuthScreenReauthContentStateTest { * 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 + * its own [AuthState.Loading] (e.g. a cancelled phone verification retracted on dispose). 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. */ @@ -520,7 +530,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) ) } composeTestRule.waitForIdle() @@ -567,7 +577,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryCount++ }) + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) ) } composeTestRule.waitForIdle() @@ -616,7 +626,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = {}) + AuthState.Reauthentication.Required(user, retryOperation = {}) ) } composeTestRule.waitForIdle() @@ -720,7 +730,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) ) } composeTestRule.waitForIdle() @@ -741,7 +751,7 @@ class FirebaseAuthScreenReauthContentStateTest { /** * Arming a second sensitive operation while the first is still pending must replace it. Value - * equality on [AuthState.ReauthenticationRequired] made the second write equal to the current + * equality on [AuthState.Reauthentication.Required] made the second write equal to the current * one, which [kotlinx.coroutines.flow.MutableStateFlow] silently drops — so the screen kept the * *first* lambda and ran the wrong sensitive operation after reauthentication. */ @@ -766,13 +776,13 @@ class FirebaseAuthScreenReauthContentStateTest { // Same user, same (absent) reason: the two states differ only in the attached operation. composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { ran.add("first") }) + AuthState.Reauthentication.Required(user, retryOperation = { ran.add("first") }) ) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { ran.add("second") }) + AuthState.Reauthentication.Required(user, retryOperation = { ran.add("second") }) ) } composeTestRule.waitForIdle() @@ -819,7 +829,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.ReauthenticationRequired(user, retryOperation = null)) + authUI.updateAuthState(AuthState.Reauthentication.Required(user, retryOperation = null)) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() @@ -870,7 +880,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(armedUser, retryOperation = { retryRan = true }) + AuthState.Reauthentication.Required(armedUser, retryOperation = { retryRan = true }) ) } composeTestRule.waitForIdle() @@ -925,7 +935,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) ) } composeTestRule.waitForIdle() @@ -945,15 +955,261 @@ class FirebaseAuthScreenReauthContentStateTest { } /** - * An MFA-enrolled account cannot complete reauthentication at all (a known, separate defect). - * Unguarded, RequiresMfa pushed AuthRoute.MfaChallenge *beneath* the armed slot, which then - * showed neither loading nor an error — a dead UI with the operation still pending. + * A TOTP resolver, which is the challenge shape that needs no phone verification round trip. + */ + private fun totpResolver(resolveSignIn: Task): MultiFactorResolver { + val hint = mock(TotpMultiFactorInfo::class.java) + `when`(hint.factorId).thenReturn(TotpMultiFactorGenerator.FACTOR_ID) + `when`(hint.uid).thenReturn("enrollment-1") + val resolver = mock(MultiFactorResolver::class.java) + `when`(resolver.hints).thenReturn(listOf(hint)) + `when`(resolver.resolveSignIn(any(MultiFactorAssertion::class.java))) + .thenReturn(resolveSignIn) + return resolver + } + + /** + * Firebase requires the second factor to complete the reauthentication too, so the challenge + * has to be presented *inside* the reauth surface — on the outer NavHost it renders beneath + * the modal, unreachable, and the operation stays pending forever. */ @Test - fun `RequiresMfa does not navigate while reauthentication is armed and latches an error`() { + fun `an MFA challenge inside the reauth slot runs the pending operation exactly once`() { val user = passwordOnlyUser("linked@example.com") - var retryRan = false + val signedInAuthUI = signedInAuthUI(user) + val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) + var retryCount = 0 + var challenge: MfaChallengeContentState? = null + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + mfaChallengeContent = { state -> + challenge = state + Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) + }, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + }, + authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() + + composeTestRule.runOnIdle { + requireNotNull(challenge).onVerificationCodeChange("123456") + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { requireNotNull(challenge).onVerifyClick() } + composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(retryCount).isEqualTo(1) + } + + /** + * The default sheet needs the same sub-flow: its own NavHost, so the challenge replaces the + * provider screen inside the modal instead of rendering under it. + */ + @Test + fun `an MFA challenge inside the default reauth sheet runs the pending operation exactly once`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) + var retryCount = 0 + var challenge: MfaChallengeContentState? = null + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + mfaChallengeContent = { state -> + challenge = state + Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) + }, + authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() + + composeTestRule.runOnIdle { + requireNotNull(challenge).onVerificationCodeChange("123456") + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { requireNotNull(challenge).onVerifyClick() } + composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(retryCount).isEqualTo(1) + } + + /** + * Backing out of the challenge is not abandoning reauthentication: the request stays armed, so + * the host must not be told the flow was cancelled and the operation must still be runnable. + */ + @Test + fun `cancelling the MFA challenge returns to provider selection with the request still armed`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) + var retryCount = 0 + var cancelledCount = 0 + var challenge: MfaChallengeContentState? = null + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = { cancelledCount++ }, + mfaChallengeContent = { state -> + challenge = state + Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) + }, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + }, + authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() + + composeTestRule.runOnIdle { requireNotNull(challenge).onCancelClick() } + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("mfa_challenge").assertDoesNotExist() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + assertThat(cancelledCount).isEqualTo(0) + assertThat(retryCount).isEqualTo(0) + + // Still armed: a later genuine reauthentication of the same user still runs the operation. + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) + ) + } + composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount > 0 } + assertThat(retryCount).isEqualTo(1) + assertThat(cancelledCount).isEqualTo(0) + } + + /** A failed challenge is an ordinary failed attempt: it latches into the slot's error. */ + @Test + fun `an MFA challenge failure surfaces as an attempt failure in the reauth slot`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + val resolver = totpResolver(Tasks.forException(RuntimeException("wrong code"))) + var retryCount = 0 + var cancelledCount = 0 var captured: ReauthContentState? = null + var challenge: MfaChallengeContentState? = null + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = { cancelledCount++ }, + mfaChallengeContent = { state -> + challenge = state + Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) + }, + reauthContent = { state -> + captured = state + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + }, + authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() + + composeTestRule.runOnIdle { + requireNotNull(challenge).onVerificationCodeChange("123456") + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { requireNotNull(challenge).onVerifyClick() } + composeTestRule.waitUntil(timeoutMillis = 5_000) { captured?.error != null } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + val state = requireNotNull(captured) + assertThat(state.error).isNotNull() + assertThat(state.exception).isInstanceOf(AuthException::class.java) + assertThat(retryCount).isEqualTo(0) + assertThat(cancelledCount).isEqualTo(0) + } + + /** + * The stamp is what proves the reauthentication, and it needs a user to name. With no current + * user there is nothing to stamp, so the attempt must fail rather than publish a bare Success. + */ + @Test + fun `an MFA challenge resolved with no current user does not run the pending operation`() { + val user = passwordOnlyUser("linked@example.com") + val resolver = totpResolver(Tasks.forResult(mock(AuthResult::class.java))) + var retryCount = 0 + var captured: ReauthContentState? = null + var challenge: MfaChallengeContentState? = null composeTestRule.setContent { FirebaseAuthScreen( @@ -962,36 +1218,41 @@ class FirebaseAuthScreenReauthContentStateTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, + mfaChallengeContent = { state -> + challenge = state + Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge")) + }, reauthContent = { state -> captured = state Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) }, - authenticatedContent = { _, _ -> - Text(text = "AUTHENTICATED", modifier = Modifier.testTag("authenticated")) - }, + authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") }, ) } composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) ) } composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.RequiresMfa(resolver, "authenticator")) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("mfa_challenge").assertIsDisplayed() composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.RequiresMfa(mock(MultiFactorResolver::class.java))) + requireNotNull(challenge).onVerificationCodeChange("123456") } composeTestRule.waitForIdle() + composeTestRule.runOnIdle { requireNotNull(challenge).onVerifyClick() } + composeTestRule.waitUntil(timeoutMillis = 5_000) { captured?.error != null } composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() - assertThat(retryRan).isFalse() - val state = requireNotNull(captured) - assertThat(state.error) - .isEqualTo(context.getString(R.string.fui_error_reauth_mfa_unsupported)) - assertThat(state.exception).isInstanceOf(AuthException.UnknownException::class.java) + assertThat(retryCount).isEqualTo(0) + assertThat(requireNotNull(captured).exception) + .isInstanceOf(AuthException.UserNotFoundException::class.java) } /** @@ -1024,7 +1285,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { authUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryRan = true }) + AuthState.Reauthentication.Required(user, retryOperation = { retryRan = true }) ) } composeTestRule.waitForIdle() @@ -1039,11 +1300,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithTag("dismiss_reauth").assertDoesNotExist() } - /** - * Rotating while the slot shows a latched failure must not silently erase it. The message is - * saveable and survives; the [AuthException] behind it is not, so `exception` comes back null - * (documented on [ReauthContentState.exception]) rather than disagreeing with `error`. - */ + /** Rotating preserves the request-owned failure, including its typed exception. */ @Test fun `a latched slot error survives Activity recreation`() { val user = passwordOnlyUser("linked@example.com") @@ -1068,7 +1325,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) } composeTestRule.waitForIdle() composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) } @@ -1081,7 +1338,8 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.onNodeWithText("SLOT_ERROR=$expectedMessage").assertIsDisplayed() assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) - assertThat(requireNotNull(captured).exception).isNull() + assertThat(requireNotNull(captured).exception) + .isInstanceOf(AuthException.InvalidCredentialsException::class.java) } /** @@ -1119,7 +1377,7 @@ class FirebaseAuthScreenReauthContentStateTest { } composeTestRule.runOnIdle { - signedInAuthUI.updateAuthState(AuthState.ReauthenticationRequired(user)) + signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("pick_provider").performClick() @@ -1161,7 +1419,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryCount++ }) + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) ) } composeTestRule.waitForIdle() @@ -1184,14 +1442,9 @@ class FirebaseAuthScreenReauthContentStateTest { assertThat(retryCount).isEqualTo(1) } - /** - * The one arming a recreation genuinely cannot re-derive: the credential exchange in flight - * died with the composition, so the flow still reads [AuthState.Loading] and the suspend - * operation is gone. That must be reported, not dropped silently, and must not later be - * consumed by an unrelated success. - */ + /** Activity recreation keeps the request and retry callback in the process-owned AuthState. */ @Test - fun `an attempt interrupted by recreation is reported rather than dropped`() { + fun `an attempt survives Activity recreation and completes the same request`() { val user = passwordOnlyUser("linked@example.com") val signedInAuthUI = signedInAuthUI(user) var retryCount = 0 @@ -1212,7 +1465,7 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( - AuthState.ReauthenticationRequired(user, retryOperation = { retryCount++ }) + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) ) } composeTestRule.waitForIdle() @@ -1223,19 +1476,299 @@ class FirebaseAuthScreenReauthContentStateTest { composeTestRule.waitForIdle() composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() composeTestRule .onNodeWithText(context.getString(R.string.fui_error_reauth_interrupted)) - .assertExists() + .assertDoesNotExist() composeTestRule.runOnIdle { signedInAuthUI.updateAuthState( AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) ) } + composeTestRule.waitUntil(timeoutMillis = 5_000) { retryCount == 1 } + + assertThat(retryCount).isEqualTo(1) + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() + } + + /** + * Process death, unlike rotation, also takes the process-cached [FirebaseAuthUI] holding the + * arming: the restored screen's first state comes from the persisted session, so it is an + * [AuthState.Success] and no [AuthState.Reauthentication.Required] is ever available to + * re-derive from. The pending operation is gone and must still be reported, not dropped. + */ + @Test + fun `an arming lost to process death is reported rather than dropped`() { + val user = passwordOnlyUser("linked@example.com") + var retryCount = 0 + // Read on every composition, so the restore below observes the replacement instance. + var currentAuthUI = signedInAuthUI(user) + val restorationTester = StateRestorationTester(composeTestRule) + + restorationTester.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = currentAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + currentAuthUI.updateAuthState( + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + + // The instance cache dies with the process; what comes back knows only the session. + composeTestRule.runOnIdle { + FirebaseAuthUI.clearInstanceCache() + currentAuthUI = signedInAuthUI(user) + } + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule + .onAllNodesWithText(context.getString(R.string.fui_error_reauth_interrupted)) + .fetchSemanticsNodes().isNotEmpty() + } + + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() + + composeTestRule.runOnIdle { + currentAuthUI.updateAuthState( + AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) + ) + } composeTestRule.waitForIdle() composeTestRule.waitForIdle() assertThat(retryCount).isEqualTo(0) } + + /** + * The mirror image, and the regression the broadened guard risks: rotation keeps the cached + * [FirebaseAuthUI], so the arming re-derives and must not be reported as interrupted. + */ + @Test + fun `recreation that can re-derive the arming reports no interruption`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + var retryCount = 0 + val restorationTester = StateRestorationTester(composeTestRule) + + restorationTester.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("reauth_slot").assertIsDisplayed() + composeTestRule + .onNodeWithText(context.getString(R.string.fui_error_reauth_interrupted)) + .assertDoesNotExist() + + 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 } + + assertThat(retryCount).isEqualTo(1) + } + + /** A real restored Idle is distinguishable from collectAsState's null placeholder. */ + @Test + fun `process death that restores a signed-out Idle reports interruption`() { + val user = passwordOnlyUser("linked@example.com") + var currentAuthUI = signedInAuthUI(user) + val restorationTester = StateRestorationTester(composeTestRule) + + restorationTester.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = currentAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + currentAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + } + composeTestRule.waitForIdle() + + // Signed out, so the replacement process emits a real Idle after the null UI placeholder. + composeTestRule.runOnIdle { + FirebaseAuthUI.clearInstanceCache() + currentAuthUI = signedOutAuthUI() + } + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule + .onAllNodesWithText(context.getString(R.string.fui_error_reauth_interrupted)) + .fetchSemanticsNodes().isNotEmpty() + } + + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() + } + + /** + * The sensitive operation must run at most once. Its retry is composition-scoped, so a + * recreation while it is suspended on the network kills it without any outcome being published + * — leaving [AuthState.Reauthentication.RetryingOperation] as the restored screen's first state. + * Firebase `Task`s are not cancellable, so the killed attempt may well have committed already: + * re-running it is the one outcome worse than losing it, which is reported instead. + */ + @Test + fun `recreation during the retry never runs the operation twice`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + val runs = AtomicInteger(0) + val hangForever = CompletableDeferred() + val restorationTester = StateRestorationTester(composeTestRule) + + restorationTester.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot")) + } + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.Reauthentication.Required( + user, + retryOperation = { + runs.incrementAndGet() + hangForever.await() + }, + ) + ) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState( + AuthState.Success(result = null, user = user, reauthenticatedUid = user.uid) + ) + } + composeTestRule.waitUntil(timeoutMillis = 5_000) { runs.get() == 1 } + + // Rotate while the operation is still in flight. + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + composeTestRule.waitForIdle() + + assertThat(runs.get()).isEqualTo(1) + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule + .onAllNodesWithText(context.getString(R.string.fui_error_reauth_interrupted)) + .fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithTag("reauth_slot").assertDoesNotExist() + + hangForever.complete(Unit) + composeTestRule.waitForIdle() + assertThat(runs.get()).isEqualTo(1) + } + + /** + * The latched failure is never the live [AuthState.Error], so the dialog needs its own dedupe + * key: leaving and re-entering a sub-flow re-adds the effect that shows it, and the dialog the + * user already dismissed would reappear over the freshly reopened sub-flow. + */ + @Test + fun `a dismissed attempt-failure dialog does not reappear on reopening a sub-flow`() { + val user = passwordOnlyUser("linked@example.com") + val signedInAuthUI = signedInAuthUI(user) + val thrown = FirebaseAuthInvalidUserException("ERROR_USER_DISABLED", "RAW-BACKEND-CODE-17") + val expectedMessage = requireNotNull(AuthException.from(thrown, stringProvider).message) + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { state -> + Button( + onClick = { state.onProviderSelected(state.providers.first()) }, + modifier = Modifier.testTag("pick_provider") + ) { + Text("Continue") + } + } + ) + } + + composeTestRule.runOnIdle { + signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Error(thrown)) } + composeTestRule.waitForIdle() + + // Opening the email sub-flow replaces the slot, so the failure is surfaced as a dialog. + composeTestRule.onNodeWithTag("pick_provider").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(expectedMessage).assertIsDisplayed() + + composeTestRule.onNodeWithText(stringProvider.dismissAction).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithContentDescription(stringProvider.backAction).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("pick_provider").performClick() + composeTestRule.waitForIdle() + + assertThat( + composeTestRule.onAllNodesWithText(expectedMessage).fetchSemanticsNodes() + ).isEmpty() + } + + /** A [FirebaseAuthUI] over a mocked, *signed-out* [FirebaseAuth]: `authStateFlow()` is Idle. */ + private fun signedOutAuthUI(): FirebaseAuthUI { + val auth = mock(FirebaseAuth::class.java) + `when`(auth.currentUser).thenReturn(null) + `when`(auth.app).thenReturn(FirebaseApp.getInstance()) + return FirebaseAuthUI.create(FirebaseApp.getInstance(), auth) + } } 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 2da188a6a..4ce911a4f 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 @@ -20,14 +20,11 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule 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.AuthState import com.firebase.ui.auth.FirebaseAuthUI 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.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth @@ -56,7 +53,6 @@ class FirebaseAuthScreenReauthIdleResetTest { private lateinit var mockFirebaseAuth: FirebaseAuth private lateinit var authUI: FirebaseAuthUI - private lateinit var stringProvider: DefaultAuthUIStringProvider @Before fun setUp() { @@ -79,7 +75,6 @@ class FirebaseAuthScreenReauthIdleResetTest { `when`(mockFirebaseAuth.app).thenReturn(defaultApp) authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - stringProvider = DefaultAuthUIStringProvider(context) } @After @@ -95,6 +90,7 @@ class FirebaseAuthScreenReauthIdleResetTest { val mockProviderInfo = mock(UserInfo::class.java) `when`(mockProviderInfo.providerId).thenReturn("password") val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.uid).thenReturn("uid-password") `when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo)) val configuration = authUIConfiguration { @@ -109,6 +105,7 @@ class FirebaseAuthScreenReauthIdleResetTest { } } + var capturedError: String? = null composeTestRule.setContent { FirebaseAuthScreen( configuration = configuration, @@ -116,7 +113,8 @@ class FirebaseAuthScreenReauthIdleResetTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, - reauthContent = { + reauthContent = { state -> + capturedError = state.error Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) } ) @@ -124,23 +122,19 @@ class FirebaseAuthScreenReauthIdleResetTest { // Enter the reauth flow. composeTestRule.runOnIdle { - authUI.updateAuthState(AuthState.ReauthenticationRequired(mockUser)) + authUI.updateAuthState(AuthState.Reauthentication.Required(mockUser)) } composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed() - // Wrong password entered inside the reauth flow surfaces an Error on the same authUI. + // Wrong password entered inside the reauth flow becomes failure state on the same request. composeTestRule.runOnIdle { authUI.updateAuthState(AuthState.Error(Exception("wrong password"))) } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertIsDisplayed() - // Dismiss the error dialog, which self-consumes the Error back to Idle. - composeTestRule.onNodeWithText(stringProvider.dismissAction).performClick() - composeTestRule.waitForIdle() - - // The reauth sheet must survive the notification-consume Idle. + // Custom reauth content owns the error presentation and the request remains active. + com.google.common.truth.Truth.assertThat(capturedError).isNotNull() composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed() } } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt index 6273b32b4..01c86d73b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt @@ -216,6 +216,7 @@ class FirebaseAuthScreenSlotsTest { val mockProviderInfo = mock(UserInfo::class.java) `when`(mockProviderInfo.providerId).thenReturn("password") val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.uid).thenReturn("uid-custom-picker") `when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo)) val configuration = authUIConfiguration { @@ -242,7 +243,7 @@ class FirebaseAuthScreenSlotsTest { ) } - authUI.updateAuthState(AuthState.ReauthenticationRequired(mockUser)) + authUI.updateAuthState(AuthState.Reauthentication.Required(mockUser)) composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("custom_reauth_picker").assertIsDisplayed() diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt index ffe4ec882..7f0b44255 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.test.junit4.createComposeRule import com.firebase.ui.auth.configuration.MfaFactor import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.MultiFactorResolver diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt index 119ef0572..6d81fa994 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt @@ -22,6 +22,7 @@ import com.firebase.ui.auth.configuration.MfaConfiguration import com.firebase.ui.auth.configuration.MfaFactor import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebase.ui.auth.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt index 62675284b..0d99c527c 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaChallengeScreenTest.kt @@ -36,6 +36,7 @@ import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringPro import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.mfa.MfaChallengeContentState import com.firebase.ui.auth.testutil.ensureTestFirebaseApp +import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen import com.google.common.truth.Truth.assertThat import com.google.firebase.auth.MultiFactorInfo import com.google.firebase.auth.MultiFactorResolver diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt index 7ea2d8e4a..af6a8966e 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt @@ -38,6 +38,7 @@ import com.firebase.ui.auth.mfa.MfaEnrollmentStep import com.firebase.ui.auth.mfa.getHelperText import com.firebase.ui.auth.mfa.getTitle import com.firebase.ui.auth.testutil.ensureTestFirebaseApp +import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen import com.google.common.truth.Truth.assertThat import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactor 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 86af8ea24..73526c0d5 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 @@ -30,6 +30,7 @@ import com.firebase.ui.auth.testutil.EmulatorAuthApi import com.firebase.ui.auth.testutil.ensureFreshUser import com.firebase.ui.auth.testutil.ensureTestFirebaseApp import com.firebase.ui.auth.testutil.verifyEmailInEmulator +import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState import com.google.common.truth.Truth.assertThat import org.junit.After import org.junit.Assume @@ -79,7 +80,7 @@ class ReauthFlowTest { } /** - * Full cycle: sign in via the main flow, then emit ReauthenticationRequired to simulate a + * Full cycle: sign in via the main flow, then emit Reauthentication.Required to simulate a * sensitive operation. Verifies the default ModalBottomSheet reauth UI appears, completing * reauthentication triggers the pending retry operation. * @@ -168,9 +169,9 @@ class ReauthFlowTest { val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } - // Step 2: Emit ReauthenticationRequired to simulate a sensitive operation requiring reauth. + // Step 2: Emit Reauthentication.Required to simulate an operation requiring reauth. authUI.updateAuthState( - AuthState.ReauthenticationRequired( + AuthState.Reauthentication.Required( user = signedInUser, reason = "Please verify your identity to continue", retryOperation = { retryOperationCalled = true }, @@ -288,9 +289,9 @@ class ReauthFlowTest { shadowOf(Looper.getMainLooper()).idle() - // Emit ReauthenticationRequired to trigger the custom reauthContent slot. + // Emit Reauthentication.Required to trigger the custom reauthContent slot. authUI.updateAuthState( - AuthState.ReauthenticationRequired( + AuthState.Reauthentication.Required( user = capturedUser, reason = expectedReason, retryOperation = { retryOperationCalled = true }, @@ -400,7 +401,7 @@ class ReauthFlowTest { shadowOf(Looper.getMainLooper()).idle() authUI.updateAuthState( - AuthState.ReauthenticationRequired( + AuthState.Reauthentication.Required( user = signedInUser, reason = "Please verify your identity to continue", retryOperation = { retryOperationCalled = true }, @@ -518,9 +519,9 @@ class ReauthFlowTest { val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } - // Step 2: emit ReauthenticationRequired with a retryOperation. + // Step 2: emit Reauthentication.Required with a retryOperation. authUI.updateAuthState( - AuthState.ReauthenticationRequired( + AuthState.Reauthentication.Required( user = signedInUser, reason = "Please verify your identity to continue", retryOperation = { retryOperationCalled = true }, diff --git a/okf-bundle/modules/auth.md b/okf-bundle/modules/auth.md index e96b686da..f2fc3428f 100644 --- a/okf-bundle/modules/auth.md +++ b/okf-bundle/modules/auth.md @@ -45,6 +45,9 @@ Tier commands and handoff sequence: [validation checklist](../testing/validation - Configuration is Kotlin DSL (`AuthProvider.Email()`, etc.), not 9.x `IdpConfig` builders. - Theming uses `AuthUITheme` / Material 3, not XML Auth themes as the primary path. - State is reactive (`Flow`-oriented Auth state), not only `AuthStateListener` callbacks. +- Reauthentication phases share one request-scoped `AuthState.Reauthentication` hierarchy; + Compose saves only a request marker/sub-route so Activity recreation resumes the same request + and process death reports the lost callback explicitly. - Credential Manager integration lives under `credentialmanager/` — treat password-save/retrieve as Auth-critical surface. - MFA (SMS/TOTP) has dedicated screens and e2e coverage (`MfaEnrollmentScreenTest`, `MfaChallengeScreenTest`, …). From 94383dda1ea3b92dd5ec632535527c7a4e30aad3 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 31 Aug 2026 14:05:17 +0100 Subject: [PATCH 4/7] fix(auth): keep a proved reauthentication alive when its operation signs out --- .../java/com/firebase/ui/auth/AuthState.kt | 7 ++ .../com/firebase/ui/auth/FirebaseAuthUI.kt | 22 +++- .../auth/configuration/AuthUIConfiguration.kt | 6 +- .../auth_provider/AuthProvider.kt | 3 + .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 4 + .../auth/ui/screens/email/EmailAuthScreen.kt | 12 +- .../ui/auth/ui/screens/email/SignInUI.kt | 41 ++++--- .../firebase/ui/auth/FirebaseAuthUITest.kt | 37 ++++++ .../auth_provider/AuthProviderTest.kt | 62 +++++++++++ .../FirebaseAuthScreenReauthIdleResetTest.kt | 105 +++++++++++++++++- .../EmailAuthScreenReauthEmailLockTest.kt | 27 +++-- .../ui/auth/ui/screens/email/SignInUITest.kt | 73 ++++++++++-- 12 files changed, 342 insertions(+), 57 deletions(-) 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 2dccce6ad..cfb6f4634 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt @@ -431,6 +431,13 @@ abstract class AuthState private constructor() { override val request: Request? = null } + /** + * Whether this request's reauthentication already succeeded. A sign-out must not clear such + * a phase, because the pending operation succeeding can be what signed the user out. + */ + internal val isReauthenticated: Boolean + get() = this is Succeeded || this is RetryingOperation || this is OperationFinished + /** * A provider attempt is about to run, clearing any previously surfaced failure. Null once * credentials were accepted, so a late attempt cannot rewind a running operation. 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 60033e536..c7f675a17 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -250,6 +250,10 @@ class FirebaseAuthUI private constructor( } val reauthConfig = configuration.copy( providers = linked, + // Belt and braces with the canLinkCredential/canUpgradeAnonymous guards: a linked + // credential is not a proof of identity, so a reauth config never enables either. + isAnonymousUpgradeEnabled = false, + isCredentialLinkingEnabled = false, isNewEmailAccountsAllowed = false, isReauthenticationMode = true, ) @@ -323,13 +327,19 @@ class FirebaseAuthUI private constructor( // doesn't return Success/RequiresEmailVerification after the user is gone. if (firebaseAuth.currentUser == null) { val current = _authStateFlow.value - if (current is AuthState.Success || - current is AuthState.RequiresEmailVerification || - current is AuthState.RequiresProfileCompletion || - current is AuthState.Reauthentication - ) { - _authStateFlow.value = AuthState.Idle + val isStale = when (current) { + is AuthState.Success, + is AuthState.RequiresEmailVerification, + is AuthState.RequiresProfileCompletion, + -> true + // A sensitive operation such as delete() signs the user out as its own + // success condition, so phases owning that operation must survive this. + is AuthState.Reauthentication -> !current.isReauthenticated + else -> false } + // Via the session helper so a cleared request leaves the state machine outright + // instead of being written past contextualizeReauthenticationState(). + if (isStale) finishReauthentication(AuthState.Idle) } trySend(buildState(firebaseAuth.currentUser)) } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index a424bfed9..d39120915 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt @@ -237,6 +237,8 @@ class AuthUIConfiguration( ) { internal fun copy( providers: List = this.providers, + isAnonymousUpgradeEnabled: Boolean = this.isAnonymousUpgradeEnabled, + isCredentialLinkingEnabled: Boolean = this.isCredentialLinkingEnabled, isNewEmailAccountsAllowed: Boolean = this.isNewEmailAccountsAllowed, isReauthenticationMode: Boolean = this.isReauthenticationMode, ): AuthUIConfiguration = AuthUIConfiguration( @@ -247,8 +249,8 @@ class AuthUIConfiguration( stringProvider = this.stringProvider, isCredentialManagerEnabled = this.isCredentialManagerEnabled, isMfaEnabled = this.isMfaEnabled, - isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled, - isCredentialLinkingEnabled = this.isCredentialLinkingEnabled, + isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, + isCredentialLinkingEnabled = isCredentialLinkingEnabled, tosUrl = this.tosUrl, privacyPolicyUrl = this.privacyPolicyUrl, logo = this.logo, diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt index 6725929b8..956fe95d2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt @@ -998,6 +998,9 @@ abstract class AuthProvider(open val providerId: String, open val providerName: internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { val currentUser = auth.currentUser return config.isAnonymousUpgradeEnabled + // Same reason as canLinkCredential: an upgrade link is not a proof of + // identity, so it must never be stamped as a reauthentication. + && !config.isReauthenticationMode && currentUser != null && currentUser.isAnonymous } 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 56518a840..5cde1e73c 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 @@ -193,6 +193,10 @@ fun FirebaseAuthScreen( ?.let { linkedProviders -> configuration.copy( providers = linkedProviders, + // Belt and braces with the canLinkCredential/canUpgradeAnonymous guards: a + // linked credential is not a proof of identity, so neither is ever enabled. + isAnonymousUpgradeEnabled = false, + isCredentialLinkingEnabled = false, isNewEmailAccountsAllowed = false, isReauthenticationMode = true, ) 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 7ebfcd263..f7671ec9e 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 @@ -415,13 +415,11 @@ fun EmailAuthScreen( emailSignInLinkSentLocal = false }, onGoToResetPassword = { - // Reauthentication is a modal confirmation of the signed-in account: diverting it to - // an out-of-band email step strands the pending operation behind something it can't see. - if (!configuration.isReauthenticationMode) { - resetTextValues() - mode.value = EmailAuthMode.ResetPassword - resetLinkSentLocal = false - } + // Offered during reauthentication too: a reset email leaves the sheet up and the + // request armed, and blocking it strands a user who has forgotten their password. + resetTextValues() + mode.value = EmailAuthMode.ResetPassword + resetLinkSentLocal = false }, onGoToEmailLinkSignIn = { if (!configuration.isReauthenticationMode) { 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 4c18d21de..19b831d28 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 @@ -115,9 +115,8 @@ fun SignInUI( configuration.isNewEmailAccountsAllowed && !configuration.isReauthenticationMode - // Both routes leave this screen for an out-of-band email step, which a reauthentication sheet - // cannot observe — and an email link reopens the app with no pending operation left to resume. - val isPasswordRecoveryOffered = !configuration.isReauthenticationMode + // An email link reopens the app with nothing armed, so completing it reports an interruption + // instead of the operation; a reset email leaves the reauth sheet and its request intact. val isEmailLinkSignInOffered = provider.isEmailLinkSignInEnabled && !configuration.isReauthenticationMode @@ -220,26 +219,24 @@ fun SignInUI( } ) Spacer(modifier = Modifier.height(8.dp)) - if (isPasswordRecoveryOffered) { - TextButton( - modifier = Modifier - .align(Alignment.Start), - onClick = { - onGoToResetPassword() - }, - enabled = !isLoading, - contentPadding = PaddingValues.Zero - ) { - Text( - modifier = modifier, - text = stringProvider.troubleSigningIn, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - textDecoration = TextDecoration.Underline - ) - } - Spacer(modifier = Modifier.height(8.dp)) + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = stringProvider.troubleSigningIn, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) } + Spacer(modifier = Modifier.height(8.dp)) if (configuration.isReauthenticationMode) { // Firebase reports "password" for passwordless email-link accounts too, so such a // user is offered a password field they can never fill. Say so instead of stalling. diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt index 51e9cca91..0bcfdecbd 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt @@ -794,6 +794,43 @@ class FirebaseAuthUITest { assertThat(controller.configuration.isReauthenticationMode).isTrue() } + /** + * Defence in depth alongside the `canLinkCredential` / `canUpgradeAnonymous` guards: forcing + * both flags off makes the reauthentication config self-describing, so nothing reading the + * configuration alone can conclude that linking a credential is allowed here. + */ + @Test + fun `createReauthFlow resulting config forces credential linking and anonymous upgrade off`() { + val mockUser = mock(FirebaseUser::class.java) + val info = mock(UserInfo::class.java) + `when`(info.providerId).thenReturn("password") + `when`(mockUser.providerData).thenReturn(listOf(info)) + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(mockUser) + val authUI = FirebaseAuthUI.create(defaultApp, mockAuth) + + val config = authUIConfiguration { + this.context = ApplicationProvider.getApplicationContext() + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isAnonymousUpgradeEnabled = true + isCredentialLinkingEnabled = true + } + assertThat(config.isAnonymousUpgradeEnabled).isTrue() + assertThat(config.isCredentialLinkingEnabled).isTrue() + + val controller = authUI.createReauthFlow(config) + + assertThat(controller.configuration.isAnonymousUpgradeEnabled).isFalse() + assertThat(controller.configuration.isCredentialLinkingEnabled).isFalse() + } + @Test fun `canHandleIntent returns true when auth validates email link`() { diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt index 747e03ff2..e021b85af 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt @@ -3,7 +3,10 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.UserInfo import com.google.firebase.auth.actionCodeSettings @@ -480,6 +483,65 @@ class AuthProviderTest { assertThat(result.map { it.providerId }).containsExactly("password") } + // ============================================================================================= + // Reauthentication guards + // ============================================================================================= + + private fun anonymousUpgradeAuth(): FirebaseAuth { + val anonymousUser = mock(FirebaseUser::class.java) + `when`(anonymousUser.isAnonymous).thenReturn(true) + return mock(FirebaseAuth::class.java).also { `when`(it.currentUser).thenReturn(anonymousUser) } + } + + private fun upgradeEnabledConfig(): AuthUIConfiguration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isAnonymousUpgradeEnabled = true + isCredentialLinkingEnabled = true + } + + /** + * `canUpgradeAnonymous` decides whether provider code calls `linkWithCredential` instead of + * `reauthenticate`, and OAuth tests it *before* `isReauthenticationMode`. Linking is not a + * proof of identity, so an upgrade taken in reauthentication mode would be stamped with + * `reauthenticatedUid` and forged into a reauthentication proof — the same hole + * `canLinkCredential` already closes for the non-anonymous case. + */ + @Test + fun `canUpgradeAnonymous is false in reauthentication mode`() { + val auth = anonymousUpgradeAuth() + val config = upgradeEnabledConfig() + + // Control: outside reauthentication an enabled upgrade is still taken. + assertThat(AuthProvider.canUpgradeAnonymous(config, auth)).isTrue() + + assertThat( + AuthProvider.canUpgradeAnonymous(config.copy(isReauthenticationMode = true), auth) + ).isFalse() + } + + /** The sibling guard, pinned alongside so the pair cannot drift apart again. */ + @Test + fun `canLinkCredential is false in reauthentication mode`() { + val nonAnonymousUser = mock(FirebaseUser::class.java) + `when`(nonAnonymousUser.isAnonymous).thenReturn(false) + val auth = mock(FirebaseAuth::class.java) + `when`(auth.currentUser).thenReturn(nonAnonymousUser) + val config = upgradeEnabledConfig() + + assertThat(AuthProvider.canLinkCredential(config, auth)).isTrue() + assertThat( + AuthProvider.canLinkCredential(config.copy(isReauthenticationMode = true), auth) + ).isFalse() + } + @Test fun `generic oauth provider with blank button label should throw`() { val provider = AuthProvider.GenericOAuth( 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 4ce911a4f..3fe6426ba 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 @@ -15,6 +15,7 @@ package com.firebase.ui.auth.ui.screens import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed @@ -23,20 +24,27 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthState 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.auth_provider.AuthProvider +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.FirebaseAuth.AuthStateListener import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.UserInfo +import kotlinx.coroutines.yield import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor import org.mockito.Mock +import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.mock +import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner @@ -134,7 +142,102 @@ class FirebaseAuthScreenReauthIdleResetTest { composeTestRule.waitForIdle() // Custom reauth content owns the error presentation and the request remains active. - com.google.common.truth.Truth.assertThat(capturedError).isNotNull() + assertThat(capturedError).isNotNull() composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed() } + + /** + * `FirebaseAuthUI.delete()` signs the user out as its *success* condition, so a successful + * retry fires the AuthStateListener with a null current user while the request is still in + * `RetryingOperation`. The listener's stale-state reset used to force `Idle` from every + * `Reauthentication` phase, which cancelled the coroutine running the operation and left the + * saved presentation to report `fui_error_reauth_interrupted` — over a deleted account. + * + * Screen-level tests mock [FirebaseAuth], so `addAuthStateListener` is inert; the listener is + * captured off the mock and invoked from inside the retry operation itself, which is how this + * test reaches that branch at all. + */ + @Test + fun `an operation that signs the user out is reported as completed, not interrupted`() { + val mockProviderInfo = mock(UserInfo::class.java) + `when`(mockProviderInfo.providerId).thenReturn("password") + val mockUser = mock(FirebaseUser::class.java) + `when`(mockUser.uid).thenReturn("uid-password") + `when`(mockUser.providerData).thenReturn(listOf(mockProviderInfo)) + `when`(mockUser.isEmailVerified).thenReturn(true) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockUser) + + val configuration = authUIConfiguration { + context = ApplicationProvider.getApplicationContext() + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + } + + val observed = mutableListOf() + composeTestRule.setContent { + LaunchedEffect(Unit) { authUI.authStateFlow().collect { observed.add(it) } } + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + reauthContent = { + Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) + } + ) + } + composeTestRule.waitForIdle() + + val listenerCaptor = ArgumentCaptor.forClass(AuthStateListener::class.java) + verify(mockFirebaseAuth, atLeastOnce()).addAuthStateListener(listenerCaptor.capture()) + val listeners = listenerCaptor.allValues.toList() + + var operationStarted = false + var operationCompleted = false + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Reauthentication.Required( + user = mockUser, + retryOperation = { + operationStarted = true + // Exactly what a successful delete() does: FirebaseAuth drops the user and + // notifies its listeners while the operation is still in flight. + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + listeners.forEach { it.onAuthStateChanged(mockFirebaseAuth) } + yield() + operationCompleted = true + }, + ) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("reauth_marker").assertIsDisplayed() + + // Credentials accepted for the same user, which drives the request into its retry phase. + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Success( + result = null, + user = mockUser, + reauthenticatedUid = "uid-password", + ) + ) + } + composeTestRule.waitForIdle() + + val interruptedMessage = ApplicationProvider.getApplicationContext() + .getString(R.string.fui_error_reauth_interrupted) + assertThat(operationStarted).isTrue() + assertThat(operationCompleted).isTrue() + assertThat(observed.filterIsInstance()).isEmpty() + assertThat(observed.filterIsInstance().map { it.exception.message }) + .doesNotContain(interruptedMessage) + } } 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 index 65196c744..51dd6a7a1 100644 --- 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 @@ -241,7 +241,7 @@ class EmailAuthScreenReauthEmailLockTest { /** * The lock is wired into every mode that shows the address, not only SignIn — reauthentication - * cannot reach those modes any more, but a custom `emailContent` slot and a future route can. + * reaches ResetPassword itself, and a custom `emailContent` slot can reach the rest. */ @Test fun `ResetPasswordUI renders a locked email read-only`() { @@ -290,25 +290,31 @@ class EmailAuthScreenReauthEmailLockTest { } /** - * Both routes hand off to an out-of-band email step the reauthentication sheet cannot observe, - * and an email link reopens the app with no pending operation left to resume. + * The two out-of-band email routes are not equivalent during reauthentication. A password reset + * email leaves the sheet up and the request armed, so it stays available — blocking it stranded + * a user who had forgotten their password with no route but dismissal. An email *link* reopens + * the app with nothing armed, so completing it reports an interruption instead of finishing the + * pending operation, and it stays hidden. */ @Test - fun `neither password recovery nor email-link sign-in is offered while reauthenticating`() { + fun `password recovery is offered while reauthenticating but email-link sign-in is not`() { composeTestRule.setContent { EmailAuthScreenUnderTest(reauthConfigurationWithEmailLink(), prefill = prefillEmail) } // The password field proves this is the reauth SignIn screen, still usable as intended. composeTestRule.onNodeWithText(stringProvider.passwordHint).assertExists() - composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).assertDoesNotExist() + composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).assertExists() composeTestRule.onNodeWithText(stringProvider.signInWithEmailLink, ignoreCase = true) .assertDoesNotExist() } - /** Defence in depth: a custom `emailContent` slot cannot reach those modes either. */ + /** + * The callback side of the same asymmetry, which a custom `emailContent` slot reaches directly: + * the ResetPassword switch has to work, the EmailLink switch has to stay inert. + */ @Test - fun `the reauth mode switches to ResetPassword and EmailLink are inert`() { + fun `the reauth ResetPassword mode switch works while the EmailLink one is inert`() { val observed = mutableListOf() var goToResetPassword: (() -> Unit)? = null var goToEmailLinkSignIn: (() -> Unit)? = null @@ -335,10 +341,15 @@ class EmailAuthScreenReauthEmailLockTest { composeTestRule.runOnIdle { requireNotNull(goToResetPassword).invoke() } composeTestRule.waitForIdle() + + assertThat(observed.last()).isEqualTo(EmailAuthMode.ResetPassword) + composeTestRule.runOnIdle { requireNotNull(goToEmailLinkSignIn).invoke() } composeTestRule.waitForIdle() - assertThat(observed.toSet()).containsExactly(EmailAuthMode.SignIn) + assertThat(observed.last()).isEqualTo(EmailAuthMode.ResetPassword) + assertThat(observed.toSet()) + .containsExactly(EmailAuthMode.SignIn, EmailAuthMode.ResetPassword) } /** 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 49faaa135..e392d95d4 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 @@ -52,6 +52,7 @@ import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.UserInfo +import com.google.firebase.auth.actionCodeSettings import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before @@ -267,19 +268,26 @@ class SignInUITest { ) val configuration = authUIConfiguration { context = applicationContext - providers { - provider( - AuthProvider.Email( - emailLinkActionCodeSettings = null, - passwordValidationRules = emptyList() - ) - ) - } + providers { provider(emailProvider(isEmailLinkSignInEnabled = emailLinkEnabled)) } isCredentialManagerEnabled = credentialManagerEnabled } return FirebaseAuthUI.create(app, auth).createReauthFlow(configuration).configuration } + /** Email link needs action code settings to validate, so the two always travel together. */ + private fun emailProvider(isEmailLinkSignInEnabled: Boolean) = AuthProvider.Email( + isEmailLinkSignInEnabled = isEmailLinkSignInEnabled, + emailLinkActionCodeSettings = if (isEmailLinkSignInEnabled) { + actionCodeSettings { + url = "https://example.com/verify" + handleCodeInApp = true + } + } else { + null + }, + passwordValidationRules = emptyList() + ) + /** * Set before [createReauthFlowConfiguration] to build a Credential-Manager-enabled config. * @@ -289,6 +297,10 @@ class SignInUITest { */ private var credentialManagerEnabled = false + /** Set before [createReauthFlowConfiguration] to enable the email-link affordance. Same + * per-instance safety argument as [credentialManagerEnabled]. */ + private var emailLinkEnabled = false + private fun setStatefulSignInUIContent( configuration: AuthUIConfiguration, initialEmail: String, @@ -449,9 +461,9 @@ class SignInUITest { /** * Firebase reports the provider id `"password"` for passwordless email-link accounts too, so * such a user is offered the Email method and lands on a password field they can never fill. - * Reauthentication mode has also removed the email-link toggle and "trouble signing in?", so - * without this notice the screen is a silent dead end. The provider cannot be filtered out - * instead: `providerData` cannot tell a password account from an email-link one. + * Reauthentication mode has also removed the email-link toggle, so without this notice the + * screen is a near-silent dead end. The provider cannot be filtered out instead: + * `providerData` cannot tell a password account from an email-link one. */ @Test fun `a password requirement notice is shown while reauthenticating`() { @@ -467,6 +479,45 @@ class SignInUITest { .assertExists() } + /** + * The asymmetry between the two out-of-band email routes during reauthentication. A password + * reset email leaves the reauth sheet up and the request armed, so blocking it only stranded a + * user who had forgotten their password with no route but dismissal. An email *link* reopens + * the app with nothing armed, so completing it reports an interruption instead of finishing the + * pending operation — useless, and it stays hidden. + */ + @Test + fun `reauthentication offers password recovery but hides email link sign-in`() { + emailLinkEnabled = true + + setStatefulSignInUIContent( + createReauthFlowConfiguration(), + initialEmail = "linked@example.com", + isEmailLocked = true, + ) + + composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).assertExists() + composeTestRule + .onNode(hasText(stringProvider.signInWithEmailLink.uppercase()) and hasClickAction()) + .assertDoesNotExist() + } + + /** Control for the test above: outside reauthentication the email-link toggle is offered. */ + @Test + fun `email link sign-in is offered outside reauthentication mode`() { + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider(isEmailLinkSignInEnabled = true)) } + } + + setStatefulSignInUIContent(configuration, initialEmail = "") + + composeTestRule.onNodeWithText(stringProvider.troubleSigningIn).assertExists() + composeTestRule + .onNode(hasText(stringProvider.signInWithEmailLink.uppercase()) and hasClickAction()) + .assertExists() + } + /** The notice is specific to reauthentication and must not appear in a normal sign-in. */ @Test fun `no password requirement notice outside reauthentication`() { From 1649bd1931296256523d8fbc93c3f4b0b3dfcb8f Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 31 Aug 2026 16:13:48 +0100 Subject: [PATCH 5/7] fix(auth): tear down phone verification when a reauthentication attempt fails --- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 10 ++++ ...honeAuthScreenVerificationLifecycleTest.kt | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt index 13671b32e..83126ee56 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt @@ -326,6 +326,16 @@ fun PhoneAuthScreen( authUI.updateAuthState(AuthState.Idle) } + is AuthState.Reauthentication.AttemptFailed -> { + // Same teardown as the ordinary Error branch above: the attempt is over, so stop + // holding Firebase's callbacks. The phase itself is left latched for the reauth UI + // to render, so nothing is consumed here. + val exception = AuthException.from(state.exception, stringProvider) + if (exception !is AuthException.PhoneVerificationCooldownException) { + cancelVerification("reauthentication attempt failed") + } + } + else -> Unit } } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt index 30147f5f7..41d6d37b3 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt @@ -43,6 +43,7 @@ import com.google.firebase.auth.PhoneAuthCredential import com.google.firebase.auth.PhoneAuthOptions import com.google.firebase.auth.PhoneAuthProvider import com.google.firebase.auth.PhoneAuthProvider.OnVerificationStateChangedCallbacks +import com.google.firebase.auth.UserInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -238,6 +239,17 @@ class PhoneAuthScreenVerificationLifecycleTest { return result } + /** A signed-in user linked to the phone provider, as a reauthentication requires. */ + private fun phoneUser(): FirebaseUser { + val providerInfo = mock(UserInfo::class.java) + `when`(providerInfo.providerId).thenReturn("phone") + val user = mock(FirebaseUser::class.java) + `when`(user.providerData).thenReturn(listOf(providerInfo)) + `when`(user.uid).thenReturn("uid-phone") + `when`(user.email).thenReturn(null) + return user + } + private fun multiFactorException(): FirebaseAuthMultiFactorException { val resolver = mock(MultiFactorResolver::class.java) `when`(resolver.hints).thenReturn(emptyList()) @@ -544,6 +556,50 @@ class PhoneAuthScreenVerificationLifecycleTest { } } + /** + * The same teardown, but while a reauthentication request is armed. The failure is folded into + * [AuthState.Reauthentication.AttemptFailed], so a `when` that only tears down on + * [AuthState.Error] leaves the verification open and the late auto-retrieval below starts a + * second reauthentication the user never asked for. `resend cancels the superseded + * verification attempt` above establishes that a cancelled attempt's emissions are dropped. + */ + @Test + fun `a failed reauthentication attempt cancels the in-flight verification`() { + configuration = phoneConfiguration(timeout = 0L).copy(isReauthenticationMode = true) + val user = phoneUser() + `when`(mockAuth.currentUser).thenReturn(user) + `when`(user.reauthenticate(any())).thenReturn(Tasks.forException(Exception("wrong code"))) + val credential = mock(PhoneAuthCredential::class.java) + + val observed = mutableListOf() + val collector = CoroutineScope(Dispatchers.Main.immediate).launch { + authUI.authStateFlow().collect { observed += it } + } + // What FirebaseAuthScreen does: register a drainer so ordinary states are folded into the + // armed request, then arm it. + authUI.addReauthenticationDrainer() + authUI.updateAuthState(AuthState.Reauthentication.Required(user)) + + mockStatic(PhoneAuthProvider::class.java).use { statics -> + stubGetCredential(statics, credential) + setScreenContent() + val callbacks = sendCode(statics) + codeSent(callbacks, "verification-id-1") + + submitCode("123456") + settle() + verify(user, times(1)).reauthenticate(any()) + // Precondition: the failure really did reach the screen as the reauthentication phase. + assertThat(observed.filterIsInstance()) + .isNotEmpty() + + autoVerified(callbacks, credential) + settle() + verify(user, times(1)).reauthenticate(any()) + } + collector.cancel() + } + @Test fun `a cooldown-rejected send still reports its cooldown error`() { configuration = phoneConfiguration(timeout = 60L) From 351c36bc55e8cad2b36b7974ecd065416647e1cc Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 1 Sep 2026 11:18:06 +0100 Subject: [PATCH 6/7] test(auth): cover sign-out-during-retry and phone reauth failure end to end --- .../ui/auth/ui/screens/ReauthFlowTest.kt | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) 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 73526c0d5..b616630b9 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 @@ -12,6 +12,7 @@ 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.hasSetTextAction import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText @@ -32,6 +33,7 @@ import com.firebase.ui.auth.testutil.ensureTestFirebaseApp import com.firebase.ui.auth.testutil.verifyEmailInEmulator import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.yield import org.junit.After import org.junit.Assume import org.junit.Before @@ -561,4 +563,295 @@ class ReauthFlowTest { assertThat(retryOperationCalled).isFalse() } + + /** + * End-to-end cover for a sensitive operation that signs the user out as its own success + * condition — `delete()` is the canonical one. `signOut()` stands in for it: same listener + * path, same `currentUser == null`, without depending on the emulator honouring a + * recent-login check. + * + * This is coverage of the full cycle, not a proof of the `isReauthenticated` guard in + * `FirebaseAuthUI`'s auth-state listener: removing that guard does not fail this test. Real + * `FirebaseAuth` posts its listener notification to the looper, so whether the request is + * cleared before or after the retry coroutine resumes is not deterministic here. The guard is + * pinned by `FirebaseAuthScreenReauthIdleResetTest.an operation that signs the user out is + * reported as completed, not interrupted`, which mocks `FirebaseAuth` and fires the listener + * synchronously from inside the operation to force the ordering. + */ + @Test + fun `an operation that signs the user out completes instead of reporting an interruption`() { + val email = "reauth-signout-${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 + ) + } + + authUI.auth.signOut() + shadowOf(Looper.getMainLooper()).idle() + + var currentAuthState: AuthState = AuthState.Idle + var retryOperationStarted = false + var retryOperationCompleted = 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 = {}, + ) { state, _ -> + if (state is AuthState.Success) Text("AUTHENTICATED") else Text("NOT AUTHENTICATED") + } + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + } + + shadowOf(Looper.getMainLooper()).idle() + + // Step 1: initial sign-in through the main screen. + composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) + .performScrollTo() + .performTextInput(email) + 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() + currentAuthState is AuthState.Success + } + composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed() + + val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } + + // Step 2: arm a request whose operation signs the user out, as delete() would. + authUI.updateAuthState( + AuthState.Reauthentication.Required( + user = signedInUser, + reason = "Please verify your identity to continue", + retryOperation = { + retryOperationStarted = true + authUI.auth.signOut() + // The suspension point is what makes a dropped request observable: if the + // sign-out clears the request, this coroutine is cancelled here and never + // reaches the line below. + yield() + retryOperationCompleted = true + }, + ) + ) + + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText(stringProvider.emailHint) + .fetchSemanticsNodes().isNotEmpty() + } + + // Step 3: reauthenticate, which runs the signing-out operation. + 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() + retryOperationStarted && currentAuthState !is AuthState.Reauthentication + } + + // Settle before asserting an absence: dropping the request mid-retry surfaces the + // interruption a frame or two later, and asserting too early would miss it. + repeat(5) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.waitForIdle() + } + + // The operation ran to completion, and its sign-out is the outcome — not an interruption. + assertThat(retryOperationStarted).isTrue() + assertThat(retryOperationCompleted).isTrue() + assertThat(authUI.auth.currentUser).isNull() + assertThat(currentAuthState).isInstanceOf(AuthState.Idle::class.java) + composeAndroidTestRule.onAllNodesWithText(stringProvider.errorDialogTitle) + .assertCountEquals(0) + } + + /** + * Reauthentication through the phone sub-flow, which no other e2e case covers. A wrong SMS code + * must surface as a failed attempt and leave the pending operation unfired, with the request + * still armed rather than torn down. + * + * Note: the teardown of the underlying verification collection that a failed attempt triggers + * is not observable from here — proving it needs a late `onVerificationCompleted` injected into + * a still-open collection, which requires mocking `PhoneAuthProvider`. That assertion lives in + * `PhoneAuthScreenVerificationLifecycleTest.a failed reauthentication attempt cancels the + * in-flight verification`. + */ + @Test + fun `a wrong SMS code during phone reauth does not fire the pending retry operation`() { + // Fixed US number and defaultCountryCode, matching the one phone e2e case that is not + // @Ignore'd: driving the country selector is what makes the others flaky. + val phone = "2025550188" + + var currentAuthState: AuthState = AuthState.Idle + var retryOperationCalled = false + + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "US", + allowedCountries = null, + timeout = 60L, + ) + ) + } + isCredentialManagerEnabled = false + } + + composeAndroidTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(applicationContext) + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + ) { state, _ -> + if (state is AuthState.Success) Text("AUTHENTICATED") else Text("NOT AUTHENTICATED") + } + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + } + + shadowOf(Looper.getMainLooper()).idle() + + // Step 1: sign in with phone so the only linked provider is the one reauth will offer. + submitPhoneNumber(phone) + enterVerificationCode(awaitPhoneCode(phone)) + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Success + } + composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed() + + val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } + + // Step 2: arm a request. The sheet starts on the phone form, phone being the only + // configured provider linked to this user. + authUI.updateAuthState( + AuthState.Reauthentication.Required( + 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(stringProvider.phoneNumberHint) + .fetchSemanticsNodes().isNotEmpty() + } + + // Step 3: request a fresh code, then submit one that cannot match it. + submitPhoneNumber(phone) + val reauthCode = awaitPhoneCode(phone) + enterVerificationCode(if (reauthCode == "000000") "111111" else "000000") + + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.onAllNodesWithText(stringProvider.errorDialogTitle) + .fetchSemanticsNodes().isNotEmpty() + } + + composeAndroidTestRule.onNodeWithText(stringProvider.dismissAction).performClick() + shadowOf(Looper.getMainLooper()).idle() + + // The failed attempt leaves the request armed and the operation unrun. + assertThat(retryOperationCalled).isFalse() + assertThat(currentAuthState).isInstanceOf(AuthState.Reauthentication::class.java) + } + + /** Types [phone] into whichever phone form is on screen and taps send. */ + private fun submitPhoneNumber(phone: String) { + composeAndroidTestRule.onNodeWithText(stringProvider.phoneNumberHint) + .performScrollTo() + .performTextInput(phone) + composeAndroidTestRule.onNodeWithText(stringProvider.sendVerificationCode.uppercase()) + .performScrollTo() + .performClick() + composeAndroidTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + } + + /** The emulator mints the code asynchronously, so poll for it. */ + private fun awaitPhoneCode(phone: String): String { + var code: String? = null + var attempt = 0 + while (code == null && attempt < 8) { + Thread.sleep(if (attempt == 0) 200L else 500L * attempt) + shadowOf(Looper.getMainLooper()).idle() + code = runCatching { emulatorApi.fetchVerifyPhoneCode(phone) }.getOrNull() + attempt++ + } + // Deliberately not an Assume: a silent skip here would make this whole case vacuous. + return requireNotNull(code) { + "Firebase Auth Emulator minted no verification code for $phone" + } + } + + /** Types [code] one digit per field and taps verify. */ + private fun enterVerificationCode(code: String) { + val digitFields = composeAndroidTestRule.onAllNodes(hasSetTextAction()) + code.forEachIndexed { index, digit -> + composeAndroidTestRule.waitForIdle() + digitFields[index].performTextInput(digit.toString()) + } + composeAndroidTestRule.waitForIdle() + composeAndroidTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) + .performScrollTo() + .performClick() + composeAndroidTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + } } From 0302f2d1f552291d03bf6980a565bd64e030d3ce Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 1 Sep 2026 11:26:21 +0100 Subject: [PATCH 7/7] test(auth): drop the flaky phone reauth e2e case --- .../ui/auth/ui/screens/ReauthFlowTest.kt | 146 ------------------ 1 file changed, 146 deletions(-) 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 b616630b9..0c8d5e0ec 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 @@ -12,7 +12,6 @@ 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.hasSetTextAction import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText @@ -709,149 +708,4 @@ class ReauthFlowTest { .assertCountEquals(0) } - /** - * Reauthentication through the phone sub-flow, which no other e2e case covers. A wrong SMS code - * must surface as a failed attempt and leave the pending operation unfired, with the request - * still armed rather than torn down. - * - * Note: the teardown of the underlying verification collection that a failed attempt triggers - * is not observable from here — proving it needs a late `onVerificationCompleted` injected into - * a still-open collection, which requires mocking `PhoneAuthProvider`. That assertion lives in - * `PhoneAuthScreenVerificationLifecycleTest.a failed reauthentication attempt cancels the - * in-flight verification`. - */ - @Test - fun `a wrong SMS code during phone reauth does not fire the pending retry operation`() { - // Fixed US number and defaultCountryCode, matching the one phone e2e case that is not - // @Ignore'd: driving the country selector is what makes the others flaky. - val phone = "2025550188" - - var currentAuthState: AuthState = AuthState.Idle - var retryOperationCalled = false - - val configuration = authUIConfiguration { - context = applicationContext - providers { - provider( - AuthProvider.Phone( - defaultNumber = null, - defaultCountryCode = "US", - allowedCountries = null, - timeout = 60L, - ) - ) - } - isCredentialManagerEnabled = false - } - - composeAndroidTestRule.setContent { - CompositionLocalProvider( - LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(applicationContext) - ) { - FirebaseAuthScreen( - configuration = configuration, - authUI = authUI, - onSignInSuccess = {}, - onSignInFailure = {}, - onSignInCancelled = {}, - ) { state, _ -> - if (state is AuthState.Success) Text("AUTHENTICATED") else Text("NOT AUTHENTICATED") - } - val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) - currentAuthState = authState - } - } - - shadowOf(Looper.getMainLooper()).idle() - - // Step 1: sign in with phone so the only linked provider is the one reauth will offer. - submitPhoneNumber(phone) - enterVerificationCode(awaitPhoneCode(phone)) - - composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { - shadowOf(Looper.getMainLooper()).idle() - currentAuthState is AuthState.Success - } - composeAndroidTestRule.onNodeWithText("AUTHENTICATED").assertIsDisplayed() - - val signedInUser = requireNotNull(authUI.auth.currentUser) { "User must be signed in" } - - // Step 2: arm a request. The sheet starts on the phone form, phone being the only - // configured provider linked to this user. - authUI.updateAuthState( - AuthState.Reauthentication.Required( - 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(stringProvider.phoneNumberHint) - .fetchSemanticsNodes().isNotEmpty() - } - - // Step 3: request a fresh code, then submit one that cannot match it. - submitPhoneNumber(phone) - val reauthCode = awaitPhoneCode(phone) - enterVerificationCode(if (reauthCode == "000000") "111111" else "000000") - - composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { - shadowOf(Looper.getMainLooper()).idle() - composeAndroidTestRule.onAllNodesWithText(stringProvider.errorDialogTitle) - .fetchSemanticsNodes().isNotEmpty() - } - - composeAndroidTestRule.onNodeWithText(stringProvider.dismissAction).performClick() - shadowOf(Looper.getMainLooper()).idle() - - // The failed attempt leaves the request armed and the operation unrun. - assertThat(retryOperationCalled).isFalse() - assertThat(currentAuthState).isInstanceOf(AuthState.Reauthentication::class.java) - } - - /** Types [phone] into whichever phone form is on screen and taps send. */ - private fun submitPhoneNumber(phone: String) { - composeAndroidTestRule.onNodeWithText(stringProvider.phoneNumberHint) - .performScrollTo() - .performTextInput(phone) - composeAndroidTestRule.onNodeWithText(stringProvider.sendVerificationCode.uppercase()) - .performScrollTo() - .performClick() - composeAndroidTestRule.waitForIdle() - shadowOf(Looper.getMainLooper()).idle() - } - - /** The emulator mints the code asynchronously, so poll for it. */ - private fun awaitPhoneCode(phone: String): String { - var code: String? = null - var attempt = 0 - while (code == null && attempt < 8) { - Thread.sleep(if (attempt == 0) 200L else 500L * attempt) - shadowOf(Looper.getMainLooper()).idle() - code = runCatching { emulatorApi.fetchVerifyPhoneCode(phone) }.getOrNull() - attempt++ - } - // Deliberately not an Assume: a silent skip here would make this whole case vacuous. - return requireNotNull(code) { - "Firebase Auth Emulator minted no verification code for $phone" - } - } - - /** Types [code] one digit per field and taps verify. */ - private fun enterVerificationCode(code: String) { - val digitFields = composeAndroidTestRule.onAllNodes(hasSetTextAction()) - code.forEachIndexed { index, digit -> - composeAndroidTestRule.waitForIdle() - digitFields[index].performTextInput(digit.toString()) - } - composeAndroidTestRule.waitForIdle() - composeAndroidTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) - .performScrollTo() - .performClick() - composeAndroidTestRule.waitForIdle() - shadowOf(Looper.getMainLooper()).idle() - } }