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 @@ -25,11 +25,19 @@ import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
Expand Down Expand Up @@ -212,6 +220,9 @@ private fun AppAuthenticatedContent(
val configuration = uiContext.configuration
when (state) {
is AuthState.Success -> {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var isDeletingAccount by remember { mutableStateOf(false) }
val user = uiContext.authUI.getCurrentUser()
val identifier = user.displayIdentifier()
Column(
Expand Down Expand Up @@ -261,6 +272,28 @@ private fun AppAuthenticatedContent(
Button(onClick = uiContext.onSignOut) {
Text(stringProvider.signOutAction)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
// ReauthenticationRequired state was emitted —
// FirebaseAuthScreen navigates to the reauth flow automatically.
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
isDeletingAccount = false
}
}
},
enabled = !isDeletingAccount
) {
if (isDeletingAccount) CircularProgressIndicator() else Text("Delete account")
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ import java.util.concurrent.atomic.AtomicBoolean
*/
class AuthFlowController internal constructor(
private val authUI: FirebaseAuthUI,
private val configuration: AuthUIConfiguration
internal val configuration: AuthUIConfiguration
) {

private val coroutineScope = CoroutineScope(Dispatchers.Main + Job())
Expand Down
29 changes: 29 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,35 @@ abstract class AuthState private constructor() {
"AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)"
}

/**
* Reauthentication is required before a sensitive operation (e.g. delete account, change email)
* can proceed. Use [FirebaseAuthUI.createReauthFlow] to launch the reauthentication flow.
*
* @property user The [FirebaseUser] that needs to reauthenticate
* @property reason Optional human-readable reason to show the user
*/
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 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
}

override fun toString(): String =
"AuthState.ReauthenticationRequired(user=$user, reason=$reason)"
}

/**
* Password reset link has been sent to the user's email.
*/
Expand Down
54 changes: 54 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import android.content.Intent
import androidx.annotation.RestrictTo
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.configuration.auth_provider.filterToLinkedProviders
import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook
import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle
import com.google.firebase.Firebase
Expand Down Expand Up @@ -211,6 +213,45 @@ class FirebaseAuthUI private constructor(
return AuthFlowController(this, configuration)
}

/**
* Creates a reauthentication flow scoped to the current user's linked providers.
*
* This method builds a sign-in flow where:
* - Only providers already linked to the current [FirebaseUser] are offered
* - Account creation is disabled
* - The credential path calls [FirebaseUser.reauthenticateWithCredential] instead of
* [FirebaseAuth.signInWithCredential]
*
* Use this before sensitive operations (delete account, change email, etc.) that require
* a recent sign-in.
*
* @param configuration Base [AuthUIConfiguration] whose provider list is filtered to
* the user's linked providers. All other settings are preserved.
* @param reason Optional human-readable string shown to the user explaining why
* reauthentication is needed (e.g. "To delete your account we need to verify it's you").
* @return An [AuthFlowController] configured for reauthentication
* @throws AuthException.UserNotFoundException if no user is currently signed in
* @throws IllegalStateException if none of the configured providers are linked to the
* current user
* @since 10.0.0
*/
fun createReauthFlow(configuration: AuthUIConfiguration): AuthFlowController {
val currentUser = auth.currentUser
?: throw AuthException.UserNotFoundException(
message = "No user is currently signed in"
)
val linked = configuration.providers.filterToLinkedProviders(currentUser)
check(linked.isNotEmpty()) {
"No configured providers are linked to the current user"
}
val reauthConfig = configuration.copy(
providers = linked,
isNewEmailAccountsAllowed = false,
isReauthenticationMode = true,
)
return AuthFlowController(this, reauthConfig)
}

/**
* Returns a [Flow] that emits [AuthState] changes.
*
Expand Down Expand Up @@ -463,6 +504,19 @@ class FirebaseAuthUI private constructor(
// Update state to idle (user deleted and signed out)
updateAuthState(AuthState.Idle)

} catch (e: FirebaseAuthRecentLoginRequiredException) {
auth.currentUser?.let {
updateAuthState(
AuthState.ReauthenticationRequired(
user = it,
retryOperation = { ctx -> delete(ctx) },
)
)
}
throw AuthException.InvalidCredentialsException(
message = e.message ?: "Recent login required for this operation",
cause = e
)
} catch (e: CancellationException) {
// Handle coroutine cancellation
val cancelledException = AuthException.AuthCancelledException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class AuthUIConfigurationBuilder {
var isDisplayNameRequired: Boolean = true
var isProviderChoiceAlwaysShown: Boolean = false
var transitions: AuthUITransitions? = null
internal var isReauthenticationMode: Boolean = false

fun providers(block: AuthProvidersBuilder.() -> Unit) =
providers.addAll(AuthProvidersBuilder().apply(block).build())
Expand Down Expand Up @@ -114,7 +115,8 @@ class AuthUIConfigurationBuilder {
isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
isDisplayNameRequired = isDisplayNameRequired,
isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown,
transitions = transitions
transitions = transitions,
isReauthenticationMode = isReauthenticationMode,
)
}
}
Expand Down Expand Up @@ -204,4 +206,34 @@ class AuthUIConfiguration(
* If null, uses default fade in/out transitions.
*/
val transitions: AuthUITransitions? = null,
)

/**
* When true, the flow operates as a reauthentication flow: account creation is disabled and
* only providers already linked to the current user are shown. Set by [FirebaseAuthUI.createReauthFlow].
*/
internal val isReauthenticationMode: Boolean = false,
) {
internal fun copy(
providers: List<AuthProvider> = this.providers,
isNewEmailAccountsAllowed: Boolean = this.isNewEmailAccountsAllowed,
isReauthenticationMode: Boolean = this.isReauthenticationMode,
): AuthUIConfiguration = AuthUIConfiguration(
context = this.context,
providers = providers,
theme = this.theme,
locale = this.locale,
stringProvider = this.stringProvider,
isCredentialManagerEnabled = this.isCredentialManagerEnabled,
isMfaEnabled = this.isMfaEnabled,
isAnonymousUpgradeEnabled = this.isAnonymousUpgradeEnabled,
tosUrl = this.tosUrl,
privacyPolicyUrl = this.privacyPolicyUrl,
logo = this.logo,
passwordResetActionCodeSettings = this.passwordResetActionCodeSettings,
isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
isDisplayNameRequired = this.isDisplayNameRequired,
isProviderChoiceAlwaysShown = this.isProviderChoiceAlwaysShown,
transitions = this.transitions,
isReauthenticationMode = isReauthenticationMode,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -1045,3 +1045,17 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
}
}
}

/**
* Filters this provider list to only those whose [AuthProvider.providerId] matches a provider
* already linked to [user], as reported by [com.google.firebase.auth.FirebaseUser.providerData].
*
* Used by [com.firebase.ui.auth.FirebaseAuthUI.createReauthFlow] to restrict the reauthentication
* UI to methods the user has actually registered.
*/
internal fun List<AuthProvider>.filterToLinkedProviders(
user: com.google.firebase.auth.FirebaseUser,
): List<AuthProvider> {
val linkedIds = user.providerData.map { it.providerId }.toSet()
return filter { it.providerId in linkedIds }
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ import kotlinx.coroutines.tasks.await

private const val TAG = "EmailAuthProvider"

/**
* Signs in or reauthenticates with [credential] depending on [AuthUIConfiguration.isReauthenticationMode].
*
* - Normal mode: [com.google.firebase.auth.FirebaseAuth.signInWithCredential], returns [AuthResult].
* - Reauth mode: [com.google.firebase.auth.FirebaseUser.reauthenticate] (Task<Void>), returns null.
* Callers must reconstruct auth state from [com.google.firebase.auth.FirebaseAuth.currentUser].
*/
internal suspend fun FirebaseAuthUI.signInOrReauth(
credential: AuthCredential,
config: AuthUIConfiguration,
): AuthResult? = if (config.isReauthenticationMode) {
auth.currentUser!!.reauthenticate(credential).await()
null
} else {
Comment on lines +58 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the double-bang operator (!!) on auth.currentUser is unsafe and can lead to a NullPointerException if the user session is concurrently cleared or invalidated. It is safer to use a defensive null check and throw a descriptive AuthException.UserNotFoundException if the user is null.

): AuthResult? = if (config.isReauthenticationMode) {
    val currentUser = auth.currentUser ?: throw AuthException.UserNotFoundException(
        message = \"No user is currently signed in for reauthentication\"
    )
    currentUser.reauthenticate(credential).await()
    null
} else {

auth.signInWithCredential(credential).await()
}

/**
* Creates an email/password account or links the credential to an anonymous user.
*
Expand Down Expand Up @@ -322,6 +339,14 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
): AuthResult? {
try {
updateAuthState(AuthState.Loading("Signing in..."))
// In reauth mode build a credential and go through signInAndLinkWithCredential so
// signInOrReauth routes to FirebaseUser.reauthenticate() instead of signInWithCredential().
if (config.isReauthenticationMode) {
return signInAndLinkWithCredential(
config = config,
credential = EmailAuthProvider.getCredential(email, password),
)
}
return if (canUpgradeAnonymous(config, auth)) {
// Anonymous upgrade flow: validate credential in scratch auth
val credentialToValidate = EmailAuthProvider.getCredential(email, password)
Expand Down Expand Up @@ -548,17 +573,22 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
): AuthResult? {
try {
updateAuthState(AuthState.Loading("Signing in user..."))
return if (canUpgradeAnonymous(config, auth)) {
val result = if (canUpgradeAnonymous(config, auth)) {
auth.currentUser?.linkWithCredential(credential)?.await()
} else {
auth.signInWithCredential(credential).await()
}.also { result ->
// Merge profile information from the provider
result?.user?.let {
mergeProfile(auth, displayName, photoUrl)
signInOrReauth(credential, config)
}
// 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))
}
updateAuthStateWithResult(result)
return null
}
result?.user?.let { mergeProfile(auth, displayName, photoUrl) }
updateAuthStateWithResult(result)
return result
} catch (e: FirebaseAuthMultiFactorException) {
// MFA required - extract resolver and update state
val resolver = e.resolver
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
onResult = {},
)

DisposableEffect(Unit) {
DisposableEffect(config) {
loginManager.registerCallback(
callbackManager,
object : FacebookCallback<LoginResult> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler(
provider: AuthProvider.Google,
): () -> Unit {
val coroutineScope = rememberCoroutineScope()
return remember(this) {
return remember(this, config) {
{
coroutineScope.launch {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler(
"Ensure FirebaseAuthScreen is used within an Activity."
)

return remember(this, provider.providerId) {
return remember(this, provider.providerId, config) {
{
coroutineScope.launch {
try {
Expand Down Expand Up @@ -165,11 +165,14 @@ internal suspend fun FirebaseAuthUI.signInWithProvider(
return
}

// Determine if we should upgrade anonymous user or do normal sign-in
val authResult = if (canUpgradeAnonymous(config, auth)) {
auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await()
} else {
auth.startActivityForSignInWithProvider(activity, oauthProvider).await()
// Determine if we should upgrade anonymous user, reauthenticate, or do normal sign-in
val authResult = when {
canUpgradeAnonymous(config, auth) ->
auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await()
config.isReauthenticationMode ->
auth.currentUser!!.startActivityForReauthenticateWithProvider(activity, oauthProvider).await()
Comment on lines +172 to +173
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the double-bang operator (!!) on auth.currentUser is unsafe and can lead to a NullPointerException if the user session is concurrently cleared or invalidated. It is safer to use a defensive null check and throw a descriptive AuthException.UserNotFoundException if the user is null.

            config.isReauthenticationMode -> {
                val currentUser = auth.currentUser ?: throw AuthException.UserNotFoundException(
                    message = \"No user is currently signed in for reauthentication\"
                )
                currentUser.startActivityForReauthenticateWithProvider(activity, oauthProvider).await()
            }

else ->
auth.startActivityForSignInWithProvider(activity, oauthProvider).await()
}

// Extract OAuth credential and complete sign-in
Expand Down
Loading
Loading