Skip to content
Draft
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 @@ -36,7 +36,7 @@ import com.google.firebase.firestore.FirebaseFirestore

class MainActivity : ComponentActivity() {
companion object {
internal const val USE_AUTH_EMULATOR = true
internal const val USE_AUTH_EMULATOR = false
private const val AUTH_EMULATOR_HOST = "10.0.2.2"
private const val AUTH_EMULATOR_PORT = 9099

Expand Down
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
39 changes: 20 additions & 19 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,37 @@ mfaChallengeContent = { state ->

#### Reauthentication (`reauthContent`)

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

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

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

While this slot is shown the library suppresses its own loading and error dialogs, so render `state.isLoading` and `state.error` yourself. On success the library resumes the operation that required reauthentication — there is nothing to retry. `state.onDismiss` abandons it; backing out of a single provider attempt returns to the slot with the operation still pending.

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 Down
14 changes: 10 additions & 4 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,30 +76,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
2 changes: 1 addition & 1 deletion auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -744,4 +744,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 @@ -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)
)
Expand Down Expand Up @@ -654,9 +660,17 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
// signInOrReauth returns null in reauth mode (Task<Void> 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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import com.firebase.ui.auth.configuration.validators.PasswordValidator
* @param visualTransformation Visual transformation for the input (e.g., password).
* @param leadingIcon An optional icon to display at the start of the field.
* @param trailingIcon An optional icon to display at the start of the field.
* @param readOnly If the value cannot be edited by the user.
*/
@Composable
fun AuthTextField(
Expand All @@ -103,6 +104,7 @@ fun AuthTextField(
visualTransformation: VisualTransformation = VisualTransformation.None,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
readOnly: Boolean = false,
) {
var passwordVisible by remember { mutableStateOf(false) }

Expand Down Expand Up @@ -133,6 +135,7 @@ fun AuthTextField(
label = label,
singleLine = true,
enabled = enabled,
readOnly = readOnly,
isError = isError ?: validator?.hasError ?: false,
supportingText = {
if (validator?.hasError ?: false) {
Expand Down
Loading
Loading