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..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 @@ -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.reauth.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) } @@ -333,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) { @@ -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..ee30e41b3 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,43 +992,48 @@ 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. + +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, 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. `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`, 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. ### Reauthentication 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 { @@ -1044,15 +1049,18 @@ 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 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`. ```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/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 410107cdd..cfb6f4634 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. @@ -28,7 +29,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 +78,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 +93,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)" } /** @@ -248,33 +256,239 @@ 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, - // Not included in equals/hashCode — lambdas have no meaningful equality. - 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 - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ReauthenticationRequired) return false - return user == other.user && reason == other.reason + + /** 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 { - var result = user.hashCode() - result = 31 * result + (reason?.hashCode() ?: 0) - return result + /** + * 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 + } + + /** + * 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. + */ + 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 432d25c82..c7f675a17 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 @@ -247,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, ) @@ -265,6 +272,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. @@ -318,12 +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 - ) { - _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)) } @@ -363,9 +379,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) { @@ -494,7 +648,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. * @@ -525,7 +680,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 = { @@ -535,7 +690,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) { @@ -568,7 +723,7 @@ class FirebaseAuthUI private constructor( } catch (e: FirebaseAuthRecentLoginRequiredException) { auth.currentUser?.let { updateAuthState( - AuthState.ReauthenticationRequired( + AuthState.Reauthentication.Required( user = it, retryOperation = { ctx -> delete(ctx) }, ) @@ -722,4 +877,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/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index 7eb92114e..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,7 +249,8 @@ class AuthUIConfiguration( stringProvider = this.stringProvider, isCredentialManagerEnabled = this.isCredentialManagerEnabled, isMfaEnabled = this.isMfaEnabled, - isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled, + isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, + isCredentialLinkingEnabled = isCredentialLinkingEnabled, tosUrl = this.tosUrl, privacyPolicyUrl = this.privacyPolicyUrl, logo = this.logo, @@ -255,6 +258,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..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 } @@ -1005,6 +1008,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 1e480eda9..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 @@ -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) } @@ -1077,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/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..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 @@ -86,6 +89,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,8 +107,10 @@ fun AuthTextField( visualTransformation: VisualTransformation = VisualTransformation.None, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, + 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) { @@ -124,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) @@ -133,6 +149,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..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 @@ -43,12 +43,15 @@ 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 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.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -64,6 +67,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 @@ -78,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 @@ -85,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 @@ -114,6 +126,11 @@ 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. 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. An enrolled + * second factor is challenged over the slot, honouring [mfaChallengeContent]. * * @since 10.0.0 */ @@ -134,7 +151,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 @@ -148,21 +165,66 @@ 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) } + // 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 reauthPresentation = rememberSaveable(stateSaver = ReauthPresentationStateSaver) { + mutableStateOf(null) + } + val clearReauthPresentation: () -> Unit = remember { + { reauthPresentation.value = null } + } + 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, + // 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, + ) + } + } + 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) { + 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 - // 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. + // 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) } @@ -175,7 +237,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 +254,17 @@ fun FirebaseAuthScreen( }, onSignInFailure = onSignInFailure, ) + // 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 (currentReauthState.value == null) { + currentOuterProviderSelected.value(provider) + } + } + } val continueWithProvider: (String) -> Unit = { providerId -> configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) } } @@ -255,7 +328,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 +394,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 (reauthState == 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 +437,10 @@ fun FirebaseAuthScreen( } }, onNavigate = { route -> - navController.navigate(route.route) + // Inert while armed: this content stays composed beneath the slot. + if (reauthState == null) { + navController.navigate(route.route) + } } ) } @@ -425,7 +506,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 && reauthState == null) { try { // Try to retrieve saved email from DataStore (same-device flow) val savedEmail = @@ -459,40 +542,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 + 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 + ) { + clearReauthPresentation() + authUI.updateAuthState( + AuthState.Reauthentication.Interrupted( + requestId = savedPresentation.requestId, + userUid = savedPresentation.userUid, + ) + ) + return@LaunchedEffect + } + when (state) { is AuthState.Success -> { 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 -> - 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)) - } - } - return@LaunchedEffect - } - } - state.result?.let { result -> if (state.user.uid != lastSuccessfulUserId.value) { onSignInSuccess(result) @@ -514,26 +592,114 @@ fun FirebaseAuthScreen( } } - is AuthState.ReauthenticationRequired -> { - pendingReauthOperation.value = state.retryOperation + is AuthState.Reauthentication.Required -> { val linked = configuration.providers.filterToLinkedProviders(state.user) if (linked.isEmpty()) { - authUI.updateAuthState( + clearReauthPresentation() + authUI.finishReauthentication( 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 + 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 { - pendingReauthConfig.value = configuration.copy( - providers = linked, - isNewEmailAccountsAllowed = false, - isReauthenticationMode = true, + 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)) + } + } + + 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, ) } } @@ -561,9 +727,7 @@ fun FirebaseAuthScreen( } is AuthState.Cancelled -> { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - pendingReauthState.value = null + clearReauthPresentation() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -581,9 +745,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 + clearReauthPresentation() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -594,10 +756,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 + if (previous != null && !previous.isNotification) { + clearReauthPresentation() pendingResolver.value = null pendingLinkingCredential.value = null lastSuccessfulUserId.value = null @@ -614,6 +774,46 @@ 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 && + 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 if (errorState != null) { @@ -626,9 +826,8 @@ fun FirebaseAuthScreen( dialogController.showErrorDialog( exception = exception, errorState = errorState, - onRetry = { _ -> - // Child screens handle their own retry logic - }, + // Child screens own their retry logic, so there is nothing to retry here. + onRetry = null, onRecover = when (exception) { is AuthException.EmailAlreadyInUseException -> { { @@ -692,46 +891,96 @@ fun FirebaseAuthScreen( // Render the top-level dialog (only one instance) dialogController.CurrentDialog() - val loadingState = authState as? AuthState.Loading - if (loadingState != null) { - 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) } - // Custom reauth UI — rendered when the caller provides reauthContent. - val pendingReauth = pendingReauthState.value - if (pendingReauth != null && reauthContent != null) { - reauthContent(pendingReauth) { - pendingReauthOperation.value = null - pendingReauthState.value = null - authUI.updateAuthState(AuthState.Idle) + // 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, clearReauthPresentation) { + { + 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(authUI, reauthState?.requestId) { + { + reauthState?.requestId?.let { requestId -> + authUI.updateReauthentication(requestId) { it.attemptStarted() } + } + } + } + val onReauthSubRouteChange: (AuthRoute?) -> Unit = + 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)) } } - // 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( + if (reauthConfig != null && reauthRequired != null && reauthUiVisible) { + if (reauthContent != null) { + CustomReauthContent( authUI = authUI, reauthConfig = reauthConfig, + reauthState = reauthRequired, activity = activity, context = context, emailContent = emailContent, phoneContent = phoneContent, - customMethodPickerLayout = customMethodPickerLayout, - onDismiss = { - pendingReauthOperation.value = null - pendingReauthConfig.value = null - authUI.updateAuthState(AuthState.Idle) - }, + mfaChallengeContent = mfaChallengeContent, + mfaResolver = reauthMfa?.resolver, + isLoading = authState is AuthState.Reauthentication.Authenticating, + // The same string ErrorRecoveryDialog would have shown for this failure. + error = reauthErrorMessage, + exception = reauthException, + activeSubRoute = reauthSubRoute, + onActiveSubRouteChange = onReauthSubRouteChange, + onAttemptStarted = onReauthAttemptStarted, + onMfaError = onReauthMfaError, + onDismiss = onReauthDismiss, + content = reauthContent, ) + } else { + ModalBottomSheet( + onDismissRequest = onReauthDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + ReauthSheetContent( + authUI = authUI, + reauthConfig = reauthConfig, + requestId = reauthRequired.requestId, + activity = activity, + context = context, + prefillEmail = reauthRequired.user.email, + emailContent = emailContent, + phoneContent = phoneContent, + mfaChallengeContent = mfaChallengeContent, + mfaResolver = reauthMfa?.resolver, + customMethodPickerLayout = customMethodPickerLayout, + onDismiss = onReauthDismiss, + ) + } } } } @@ -951,84 +1200,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, - 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, - content = emailContent, - onSuccess = {}, - onError = {}, - onCancel = { - if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss() - } - ) - } - - composable(AuthRoute.Phone.route) { - com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen( - context = context, - configuration = reauthConfig, - authUI = authUI, - content = phoneContent, - onSuccess = {}, - onError = {}, - onCancel = { - if (skipsMethodPicker || !sheetNavController.popBackStack()) onDismiss() - } - ) - } - } -} @Composable -private fun FirebaseAuthUI.rememberOnProviderSelected( +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 adf17afc5..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 @@ -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,19 +159,42 @@ 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 + 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. @@ -193,28 +219,31 @@ fun EmailAuthScreen( dialogController?.showErrorDialog( exception = exception, errorState = state, - onRetry = { ex -> - when (ex) { - is AuthException.UserNotFoundException -> { - val provider = configuration.providers - .filterIsInstance() - .first() - if (provider.isNewAccountsAllowed) { - // 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) { @@ -250,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 } } @@ -263,6 +302,7 @@ fun EmailAuthScreen( mode = mode.value, displayName = displayNameValue.value, email = emailTextValue.value, + isEmailLocked = isEmailLocked, password = passwordTextValue.value, confirmPassword = confirmPasswordTextValue.value, isLoading = isLoading, @@ -270,7 +310,9 @@ fun EmailAuthScreen( resetLinkSent = resetLinkSentLocal, emailSignInLinkSent = emailSignInLinkSentLocal, onEmailChange = { email -> - emailTextValue.value = email + if (!isEmailLocked) { + emailTextValue.value = email + } }, onPasswordChange = { password -> passwordTextValue.value = password @@ -362,23 +404,29 @@ 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 = "" } + // 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 = { - textValues.forEach { it.value = "" } - mode.value = EmailAuthMode.EmailLinkSignIn - emailSignInLinkSentLocal = false + if (!configuration.isReauthenticationMode) { + resetTextValues() + mode.value = EmailAuthMode.EmailLinkSignIn + emailSignInLinkSentLocal = false + } }, ) @@ -414,7 +462,8 @@ private fun DefaultEmailAuthContent( onGoToSignUp = state.onGoToSignUp, onGoToResetPassword = state.onGoToResetPassword, onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn, - onNavigateBack = onCancel + onNavigateBack = onCancel, + isEmailLocked = state.isEmailLocked, ) } @@ -422,6 +471,7 @@ private fun DefaultEmailAuthContent( SignInEmailLinkUI( configuration = configuration, email = state.email, + isEmailLocked = state.isEmailLocked, isLoading = state.isLoading, emailSignInLinkSent = state.emailSignInLinkSent, onEmailChange = state.onEmailChange, @@ -446,7 +496,8 @@ private fun DefaultEmailAuthContent( onConfirmPasswordChange = state.onConfirmPasswordChange, onSignUpClick = state.onSignUpClick, onGoToSignIn = state.onGoToSignIn, - onNavigateBack = onCancel + onNavigateBack = onCancel, + isEmailLocked = state.isEmailLocked, ) } @@ -455,6 +506,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..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 @@ -48,12 +48,14 @@ 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 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 @@ -70,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( @@ -87,6 +92,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 +111,21 @@ fun SignInUI( } } + val isSignUpOffered = provider.isNewAccountsAllowed && + configuration.isNewEmailAccountsAllowed && + !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 + // 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 +172,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 +197,7 @@ fun SignInUI( value = email, validator = emailValidator, enabled = !isLoading, + readOnly = isEmailLocked, label = { Text(stringProvider.emailHint) }, @@ -217,11 +237,23 @@ 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), ) { - if (provider.isNewAccountsAllowed) { + if (isSignUpOffered) { Button( onClick = { onGoToSignUp() @@ -250,7 +282,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/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..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 @@ -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 @@ -294,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 } } @@ -411,8 +453,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/reauth/ReauthContentState.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt new file mode 100644 index 000000000..cdbf54cd4 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/reauth/ReauthContentState.kt @@ -0,0 +1,100 @@ +/* + * 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 com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +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 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. Survives Activity recreation with the + * active reauthentication request. + * + * @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 = {}, + + /** 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 52cd8b87d..5b2ef2917 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -178,6 +178,14 @@ 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. + Confirming your identity was interrupted. Please try that action again. + You cannot create a new account while confirming your identity. + 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. Incorrect password. 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..22738bd28 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 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 Reauthentication Required 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.Reauthentication.Required(mockFirebaseUser, reason = "Confirm it is you") + ) + delay(100) + assertThat(states.last()) + .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) + 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 @@ -435,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( @@ -458,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( @@ -478,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 // ============================================================================================= @@ -499,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) @@ -507,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) @@ -521,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") } @@ -538,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) @@ -547,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) { @@ -557,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 @@ -617,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/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/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt index b06489e3d..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 @@ -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,128 @@ 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 + * 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) @@ -1745,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 new file mode 100644 index 000000000..15f1450f2 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt @@ -0,0 +1,1774 @@ +/* + * 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.StateRestorationTester +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.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 +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 +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.Reauthentication.Required(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") + // 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 = 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.Reauthentication.Required(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") + val signedInAuthUI = signedInAuthUI(user) + + 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.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.Reauthentication.Required(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.Reauthentication.Required(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 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) + val expectedMessage = requireNotNull(AuthException.from(thrown, stringProvider).message) + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = emailAndPhoneConfiguration(), + authUI = signedInAuthUI, + 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 { + signedInAuthUI.updateAuthState(AuthState.Reauthentication.Required(user)) + } + composeTestRule.waitForIdle() + assertThat(requireNotNull(captured).error).isNull() + + 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() + assertThat(requireNotNull(captured).error).isEqualTo(expectedMessage) + + assertThat( + 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).isEqualTo(expectedMessage) + assertThat(requireNotNull(captured).exception).isNotNull() + } + + /** + * 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.Reauthentication.Required(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] (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. + */ + @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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.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. + */ + @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.Reauthentication.Required(user, retryOperation = { ran.add("first") }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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.Reauthentication.Required(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() + } + + /** + * 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 `an MFA challenge inside the reauth slot 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")) + }, + 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( + configuration = emailAndPhoneConfiguration(), + authUI = authUI, + 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") }, + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Reauthentication.Required(user, retryOperation = { retryCount++ }) + ) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + authUI.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() + + assertThat(retryCount).isEqualTo(0) + assertThat(requireNotNull(captured).exception) + .isInstanceOf(AuthException.UserNotFoundException::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. + */ + @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.Reauthentication.Required(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() + } + + /** 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") + 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.Reauthentication.Required(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) + .isInstanceOf(AuthException.InvalidCredentialsException::class.java) + } + + /** + * 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.Reauthentication.Required(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.Reauthentication.Required(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) + } + + /** Activity recreation keeps the request and retry callback in the process-owned AuthState. */ + @Test + fun `an attempt survives Activity recreation and completes the same request`() { + 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() + composeTestRule.runOnIdle { signedInAuthUI.updateAuthState(AuthState.Loading()) } + 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.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 3bd40b643..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,31 +15,36 @@ 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 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.R import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.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 @@ -56,7 +61,6 @@ class FirebaseAuthScreenReauthIdleResetTest { private lateinit var mockFirebaseAuth: FirebaseAuth private lateinit var authUI: FirebaseAuthUI - private lateinit var stringProvider: DefaultAuthUIStringProvider @Before fun setUp() { @@ -79,7 +83,6 @@ class FirebaseAuthScreenReauthIdleResetTest { `when`(mockFirebaseAuth.app).thenReturn(defaultApp) authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) - stringProvider = DefaultAuthUIStringProvider(context) } @After @@ -95,6 +98,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 +113,7 @@ class FirebaseAuthScreenReauthIdleResetTest { } } + var capturedError: String? = null composeTestRule.setContent { FirebaseAuthScreen( configuration = configuration, @@ -116,7 +121,8 @@ class FirebaseAuthScreenReauthIdleResetTest { onSignInSuccess = {}, onSignInFailure = {}, onSignInCancelled = {}, - reauthContent = { _, _ -> + reauthContent = { state -> + capturedError = state.error Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker")) } ) @@ -124,23 +130,114 @@ 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() + // Custom reauth content owns the error presentation and the request remains active. + 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() - // The reauth sheet must survive the notification-consume Idle. + 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/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/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..51dd6a7a1 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenReauthEmailLockTest.kt @@ -0,0 +1,467 @@ +/* + * 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.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.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 +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 = {}, + ) + } + } + + /** + * 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. + */ + @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 + * reaches ResetPassword itself, and a custom `emailContent` slot can reach the rest. + */ + @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)) + } + + /** + * 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 `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).assertExists() + composeTestRule.onNodeWithText(stringProvider.signInWithEmailLink, ignoreCase = true) + .assertDoesNotExist() + } + + /** + * 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 ResetPassword mode switch works while the EmailLink one is 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() + + assertThat(observed.last()).isEqualTo(EmailAuthMode.ResetPassword) + + composeTestRule.runOnIdle { requireNotNull(goToEmailLinkSignIn).invoke() } + composeTestRule.waitForIdle() + + assertThat(observed.last()).isEqualTo(EmailAuthMode.ResetPassword) + assertThat(observed.toSet()) + .containsExactly(EmailAuthMode.SignIn, EmailAuthMode.ResetPassword) + } + + /** + * 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..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 @@ -16,26 +16,84 @@ 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 +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.onNodeWithTag 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.R +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 com.google.firebase.auth.actionCodeSettings +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" + /** - * Unit tests for [SignInUI], covering the sign-up button's visibility and email pre-fill. + * 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, email pre-fill, and the + * reauthentication-mode restrictions on the email field and Credential Manager autofill. * * @suppress Internal test class */ @@ -53,6 +111,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 +245,314 @@ 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(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. + * + * 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 + + /** 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, + isEmailLocked: Boolean = false, + onSignInClicked: () -> Unit = {}, + onCredentialProbeDone: (() -> Unit)? = null, + ) { + 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, + ) + 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() + } + } + } + } + } + + /** + * 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 + + var probeDone = false + setStatefulSignInUIContent( + createReauthFlowConfiguration(), + initialEmail = "linked@example.com", + onSignInClicked = { signInClicks++ }, + onCredentialProbeDone = { probeDone = true }, + ) + awaitOrTimeout { probeDone || FakeCredentialManagerProvider.wasQueried } + + assertThat(FakeCredentialManagerProvider.wasQueried).isFalse() + composeTestRule.onNodeWithText(SAVED_CREDENTIAL_USERNAME).assertDoesNotExist() + composeTestRule.onNodeWithText("linked@example.com").assertExists() + assertThat(signInClicks).isEqualTo(0) + } + + /** + * 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()) { + composeTestRule.waitForIdle() + Thread.sleep(25) + } + } + + /** + * 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, 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`() { + 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 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`() { + 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`() { + 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/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) 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 80a456ac7..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 @@ -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 @@ -28,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 @@ -77,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. * @@ -166,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 }, @@ -184,10 +187,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 +209,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 +251,13 @@ class ReauthFlowTest { passwordValidationRules = emptyList() ) ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) } isCredentialManagerEnabled = false } @@ -248,10 +272,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") } } }, ) { _, _ -> @@ -264,11 +289,12 @@ 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 }, ) ) @@ -284,18 +310,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() + } + + 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.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("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(currentAuthState).isInstanceOf(AuthState.Idle::class.java) + assertThat(retryOperationCalled).isTrue() } @Test @@ -376,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 }, @@ -393,10 +536,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) 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`, …).