Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -827,7 +827,7 @@ FirebaseAuthScreen(
phoneContent = { state -> /* ... */ },
mfaEnrollmentContent = { state -> /* ... */ },
mfaChallengeContent = { state -> /* ... */ },
reauthContent = { state, onDismiss -> /* ... */ },
reauthContent = { state -> /* ... */ },
) { authState, uiContext ->
// authenticated content
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
},
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