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.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 @@ -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
44 changes: 24 additions & 20 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,36 +992,39 @@ mfaChallengeContent = { state ->

#### Reauthentication (`reauthContent`)

Replaces the default reauthentication bottom sheet shown when a sensitive operation requires the user to re-verify their identity. Receives the `AuthState.ReauthenticationRequired` state (including an optional `reason` string and the signed-in `user`) and an `onDismiss` callback that resets auth state to `Idle`.
Replaces the default reauthentication bottom sheet shown when a sensitive operation requires the user to re-verify their identity. The `ReauthContentState` carries `user`, `reason`, the `providers` already filtered to those linked to that user, and callbacks to select a provider or dismiss.

The library owns the credential exchange, so the slot only renders a provider chooser. Selecting a federated provider reauthenticates directly; selecting `AuthProvider.Email` or `AuthProvider.Phone` hands off to the library's own email/phone sub-flow, which honours your `emailContent` / `phoneContent` slots and replaces this slot while it is active. Password and OTP entry therefore never appear here.

```kotlin
reauthContent = { state, onDismiss ->
reauthContent = { state ->
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Verify your identity") },
onDismissRequest = state.onDismiss,
title = { Text(state.reason ?: "Verify your identity") },
text = {
Column {
state.reason?.let { Text(it) }
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
)
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (state.isLoading) CircularProgressIndicator()
state.providers.forEach { provider ->
Button(
onClick = { state.onProviderSelected(provider) },
enabled = !state.isLoading,
) { Text("Continue with ${provider.providerName}") }
}
}
},
confirmButton = {
Button(onClick = {
// Re-authenticate then update auth state on success
}) { Text("Confirm") }
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = state.onDismiss) { Text("Cancel") }
},
)
}
```

While this slot is shown the library suppresses its own loading and error dialogs, so render `state.isLoading` and `state.error` yourself. `state.error` is the same message the library's own error dialog would have shown, and `state.exception` carries the exception behind it when you need to branch on the failure type. On success the library resumes the operation that required reauthentication — there is nothing to retry. `state.onDismiss` abandons reauthentication and calls `onSignInCancelled`, so any pending operation will never run; backing out of a single provider attempt returns to the slot with the operation still pending and does *not* call `onSignInCancelled`. Render the slot so it blocks interaction with the content behind it — that content stays composed, and the library only makes its own affordances inert.

An armed reauthentication survives Activity recreation: rotating keeps the pending operation, the latched `state.error`, and any active email/phone sub-flow. `state.exception` is not saveable, so after a recreation `state.error` still carries the message while `state.exception` is `null` — branch on the type only for a failure your own composition observed. The pending operation cannot survive process death, and if it is lost the flow emits an `AuthState.Error` explaining that identity confirmation was interrupted rather than dropping the operation silently.

For most cases, use [`withReauth`](#reauthentication) instead — it handles the full reauth cycle automatically and only shows the default bottom sheet. Use `reauthContent` when you need a custom design for the reauth UI.

### Reauthentication
Expand All @@ -1048,11 +1051,12 @@ lifecycleScope.launch {
3. `FirebaseAuthScreen` shows the reauth UI scoped to the user's linked providers.
4. On successful reauthentication, retries the operation automatically and emits `AuthState.Success` or `AuthState.Error`.

The armed reauthentication lives on the process-cached `FirebaseAuthUI`, so it survives Activity recreation; it does not survive process death, and a lost operation is reported as an `AuthState.Error` rather than silently dropped.

**Activity-based alternative:** use `createReauthFlow` to start a standalone reauthentication activity scoped to the current user's linked providers, returning an `AuthFlowController`.

```kotlin
val reauth = authUI.createReauthFlow(
context = context,
configuration = authUIConfiguration {
// Providers are automatically filtered to those linked to the current user
},
Expand Down
33 changes: 17 additions & 16 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import com.google.firebase.auth.PhoneAuthProvider
* This class encapsulates all possible authentication states that can occur during
* the authentication flow, including success, error, and intermediate states.
*
* Use the companion object factory methods or specific subclass constructors to create instances.
* Instances come from the companion object factory methods or a subclass constructor; states only
* the library may publish have an `internal` constructor.
*
* @since 10.0.0
*/
Expand Down Expand Up @@ -76,30 +77,36 @@ 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 {
if (this === other) return true
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)"
}

/**
Expand Down Expand Up @@ -257,21 +264,15 @@ abstract class AuthState private constructor() {
class ReauthenticationRequired(
val user: FirebaseUser,
val reason: String? = null,
// Not included in equals/hashCode — lambdas have no meaningful equality.
val retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : AuthState() {
override val isNotification: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ReauthenticationRequired) return false
return user == other.user && reason == other.reason
}

override fun hashCode(): Int {
var result = user.hashCode()
result = 31 * result + (reason?.hashCode() ?: 0)
return result
}
// Identity, not value: arming a second sensitive operation for the same user must replace
// the first, and MutableStateFlow silently drops a write equal to the current value.
override fun equals(other: Any?): Boolean = this === other

override fun hashCode(): Int = System.identityHashCode(this)

override fun toString(): String =
"AuthState.ReauthenticationRequired(user=$user, reason=$reason)"
Expand Down
5 changes: 3 additions & 2 deletions auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,8 @@ class FirebaseAuthUI private constructor(
val current = _authStateFlow.value
if (current is AuthState.Success ||
current is AuthState.RequiresEmailVerification ||
current is AuthState.RequiresProfileCompletion
current is AuthState.RequiresProfileCompletion ||
current is AuthState.ReauthenticationRequired
) {
_authStateFlow.value = AuthState.Idle
}
Expand Down Expand Up @@ -744,4 +745,4 @@ class FirebaseAuthUI private constructor(

const val UNCONFIGURED_CONFIG_VALUE: String = "CHANGE-ME"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,15 @@ class AuthUIConfiguration(
isCredentialManagerEnabled = this.isCredentialManagerEnabled,
isMfaEnabled = this.isMfaEnabled,
isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled,
isCredentialLinkingEnabled = this.isCredentialLinkingEnabled,
tosUrl = this.tosUrl,
privacyPolicyUrl = this.privacyPolicyUrl,
logo = this.logo,
passwordResetActionCodeSettings = this.passwordResetActionCodeSettings,
isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
isDisplayNameRequired = this.isDisplayNameRequired,
isProviderChoiceAlwaysShown = this.isProviderChoiceAlwaysShown,
legacyFetchSignInWithEmail = this.legacyFetchSignInWithEmail,
transitions = this.transitions,
isReauthenticationMode = isReauthenticationMode,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,9 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
internal fun canLinkCredential(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean {
val currentUser = auth.currentUser
return config.isCredentialLinkingEnabled
// Linking is not a proof of identity: diverting a reauthentication to
// linkWithCredential would yield an unstamped Success the guard must reject.
&& !config.isReauthenticationMode
&& currentUser != null
&& !currentUser.isAnonymous
}
Expand Down
Loading