Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<String?>(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)) {
Expand All @@ -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") }
},
)
}

Expand Down
54 changes: 31 additions & 23 deletions auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,7 @@ FirebaseAuthScreen(
phoneContent = { state -> /* ... */ },
mfaEnrollmentContent = { state -> /* ... */ },
mfaChallengeContent = { state -> /* ... */ },
reauthContent = { state, onDismiss -> /* ... */ },
reauthContent = { state -> /* ... */ },
) { authState, uiContext ->
// authenticated content
}
Expand Down Expand Up @@ -993,43 +993,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 {
Expand All @@ -1045,15 +1050,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
},
Expand Down
2 changes: 2 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthState>
get() {
Expand Down
Loading