Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d27083a
refactor(auth)!: replace the retained reauth closure with a resolver …
demolaf Sep 3, 2026
276e821
test(auth): record the ordered state sequence of each sign-in path
demolaf Sep 3, 2026
5ebe580
refactor(auth)!: give provider code its own flow scope instead of the…
demolaf Sep 3, 2026
4dedd10
refactor(auth): read the arming guards off the back stack rather than…
demolaf Sep 3, 2026
097cc23
refactor(auth): let a flow's screens read their state from the flow t…
demolaf Sep 3, 2026
5fca2ef
feat(auth)!: report a declined reauthentication to the caller instead…
demolaf Sep 3, 2026
7a9869b
refactor(auth): name what happens to a reauthentication request inste…
demolaf Sep 3, 2026
0b17f77
refactor(auth)!: give reauthentication requests their own channel ins…
demolaf Sep 4, 2026
a78d1c2
docs(auth): say what the reauthentication api does instead of arguing…
demolaf Sep 4, 2026
eafa9e7
test(auth): drive the reauthentication e2e tests through withReauth
demolaf Sep 4, 2026
fa66e51
fix(auth): clear the reauthentication handover state however the retr…
demolaf Sep 4, 2026
1481fbf
test(auth): cover a password change and an account deletion through r…
demolaf Sep 4, 2026
da2be3a
refactor(auth)!: make Reauthentication.Required's user constructor in…
demolaf Sep 4, 2026
26b1fbf
test(auth): pin what makes one reauthentication request state differ …
demolaf Sep 4, 2026
bf937e9
fix(auth): read a user's email once when deciding what their sign-in …
demolaf Sep 4, 2026
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 @@ -45,6 +45,7 @@ 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.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import com.firebase.ui.auth.AuthException
Expand Down Expand Up @@ -334,9 +335,13 @@ private fun AppAuthenticatedContent(
lifecycleOwner.lifecycleScope.launch {
isDeletingAccount = true
try {
// Reauthentication, if it is needed, happens inside this call:
// the progress indicator below covers it, and the deletion is
// retried here rather than needing anything from this caller.
uiContext.authUI.delete(context)
} catch (e: AuthException.InvalidCredentialsException) {
Log.d("HighLevelApiDemoActivity", "Reauth required before delete")
} catch (e: AuthException.AuthCancelledException) {
// Declined at the identity check; the account is untouched.
Log.d("HighLevelApiDemoActivity", "Delete cancelled", e)
} catch (e: AuthException) {
Log.e("HighLevelApiDemoActivity", "Delete failed", e)
} finally {
Expand Down Expand Up @@ -567,6 +572,15 @@ private fun ChangePasswordDialog(
Log.d("HighLevelApiDemoActivity", "Password changed successfully")
onDismiss()
}
} catch (e: CancellationException) {
// withReauth suspends across the reauthentication sheet, so this
// scope really can be cancelled mid-call. Never report that as a
// failure the user can retry.
throw e
} catch (e: AuthException.AuthCancelledException) {
// The user backed out of confirming their identity. Nothing failed,
// and the password was not changed — so say neither.
Log.d("HighLevelApiDemoActivity", "Reauthentication declined", e)
} catch (e: Exception) {
updateError = "Failed to update password. Please try again."
} finally {
Expand Down
117 changes: 117 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/AuthFlowScope.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.firebase.ui.auth

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser

/** Where one auth flow's states go. */
internal fun interface AuthStateSink {
fun emit(state: AuthState)
}

/**
* One auth flow's collaborators, and where its states go. Provider code is written against this,
* not [FirebaseAuthUI], so it reaches the public state channel only through [sink].
*
* @since 10.0.0
*/
internal class AuthFlowScope(
val auth: FirebaseAuth,
val config: AuthUIConfiguration,
val credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null,
val loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null,
/**
* What this flow is currently doing, for the screens rendering it. Under a reauthentication
* request's scope this is that request's phase rather than the host's state.
*/
val state: State<AuthState>,
private val sink: AuthStateSink,
) {
fun emit(state: AuthState) = sink.emit(state)

/**
* Publishes what [result] means for this flow: a password user who still owes email
* verification is not signed in yet, however successful the credential exchange was.
*/
fun emitResult(result: AuthResult?, defaultIsNewUser: Boolean = false) {
val user = result?.user
if (user != null) {
val isNewUser = result.additionalUserInfo?.isNewUser ?: defaultIsNewUser
emit(authUserState(user, result, isNewUser))
} else {
emit(AuthState.Idle)
}
}
}

/**
* What a signed-in [user] means as an [AuthState]: the single source of truth for whether they
* still owe email verification. Callers must not re-derive it — only password users with an email
* can satisfy that screen.
*/
internal fun authUserState(user: FirebaseUser, result: AuthResult?, isNewUser: Boolean): AuthState {
val email = user.email
return if (!user.isEmailVerified &&
email != null &&
user.providerData.any { it.providerId == "password" }
) {
AuthState.RequiresEmailVerification(user = user, email = email)
} else {
AuthState.Success(result = result, user = user, isNewUser = isNewUser)
}
}

/** The auth flow the current composition belongs to, or null outside one. */
internal val LocalAuthFlowScope = staticCompositionLocalOf<AuthFlowScope?> { null }

/**
* The ambient flow when composed inside one, otherwise a fresh flow over [authUI]'s public state —
* which is what a consumer composing `EmailAuthScreen` or `PhoneAuthScreen` on its own gets.
*/
@Composable
internal fun rememberAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
): AuthFlowScope {
val ambient = LocalAuthFlowScope.current
val hostState = remember(authUI) { authUI.authStateFlow() }
.collectAsState(AuthState.Idle)
return remember(ambient, authUI, configuration, hostState) {
ambient ?: hostAuthFlowScope(authUI, configuration, hostState)
}
}

/** An [AuthFlowScope] over [authUI]'s public flow, in both directions. */
internal fun hostAuthFlowScope(
authUI: FirebaseAuthUI,
configuration: AuthUIConfiguration,
state: State<AuthState>,
): AuthFlowScope = AuthFlowScope(
auth = authUI.auth,
config = configuration,
credentialManagerProvider = authUI.testCredentialManagerProvider,
loginManagerProvider = authUI.testLoginManagerProvider,
state = state,
sink = { authUI.updateAuthState(it) },
)
94 changes: 32 additions & 62 deletions auth/src/main/java/com/firebase/ui/auth/AuthState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.MultiFactorResolver
import com.google.firebase.auth.PhoneAuthCredential
import com.google.firebase.auth.PhoneAuthProvider
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID

/**
Expand Down Expand Up @@ -256,11 +257,8 @@ abstract class AuthState private constructor() {
}

/**
* A state in the lifecycle of one reauthentication request.
*
* Every state carries a stable [requestId], so Activity recreation can distinguish a
* continuation of the same sensitive operation from a new operation for the same user. The
* request itself is process-local because its retry callback cannot be serialized.
* A state in the lifecycle of one reauthentication request. Every state carries a stable
* [requestId], so recreation can tell a continuation from a new operation for the same user.
*/
sealed class Reauthentication : AuthState() {
abstract val requestId: String
Expand All @@ -273,21 +271,31 @@ abstract class AuthState private constructor() {
val requestId: String,
val user: FirebaseUser,
val reason: String?,
retryOperation: (suspend (android.content.Context) -> Unit)?,
/**
* Where the caller awaiting this request is parked, or null when nobody is — a
* standalone flow from [FirebaseAuthUI.createReauthFlow] has no operation behind it.
*/
val resolver: CompletableDeferred<Boolean>? = null,
) {
/** Null once [claimRetryOperation] consumed it, so no recreation can re-run it. */
var retryOperation: (suspend (android.content.Context) -> Unit)? = retryOperation
private set
/** Whether a caller is waiting on this request to decide a pending operation. */
val hasPendingOperation: Boolean get() = resolver != null

/** Whether the awaiting caller is still there to resume. */
val isResumable: Boolean get() = resolver?.isActive != false

/** Whether this request ever carried an operation, even after it was claimed. */
val hasRetryOperation: Boolean = retryOperation != null
/** Credentials were accepted: the caller resumes and retries. Idempotent. */
fun resolve() {
resolver?.complete(true)
}

/**
* Hands the operation out exactly once. A second claim means the first run was lost,
* which must be reported rather than retried: the operation may have committed already.
* The request ended without proof. Completed with a value, not an exception: failing a
* parented Deferred would cancel the caller's scope, so [FirebaseAuthUI.withReauth]
* throws in its own frame instead.
*/
fun claimRetryOperation(): (suspend (android.content.Context) -> Unit)? =
retryOperation.also { retryOperation = null }
fun decline() {
resolver?.complete(false)
}
}

/**
Expand All @@ -302,26 +310,27 @@ abstract class AuthState private constructor() {
class Required internal constructor(
override val request: Request,
) : Reauthentication() {
constructor(
/** A request with nobody waiting on it, as a standalone reauthentication flow has. */
internal constructor(
user: FirebaseUser,
reason: String? = null,
retryOperation: (suspend (android.content.Context) -> Unit)? = null,
) : this(
Request(
requestId = UUID.randomUUID().toString(),
user = user,
reason = reason,
retryOperation = retryOperation,
)
)

override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
val user: FirebaseUser get() = request.user
val reason: String? get() = request.reason
val retryOperation: (suspend (android.content.Context) -> Unit)?
get() = request.retryOperation

/**
* Identity is the request. Snapshot state and [FirebaseAuthUI.pendingReauth] both
* conflate equal values, so a transition that must be observed changes the phase type.
*/
override fun equals(other: Any?): Boolean =
other is Required && requestId == other.requestId

Expand All @@ -341,7 +350,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The most recent credential attempt failed, but the request remains armed. */
/** The most recent credential attempt failed, but the request remains outstanding. */
internal class AttemptFailed(
override val request: Request,
val exception: Exception,
Expand Down Expand Up @@ -395,7 +404,7 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** Credentials were accepted for the request's user. */
/** Credentials were accepted for the request's user. Terminal for the exchange. */
internal class Succeeded(
override val request: Request,
val success: Success,
Expand All @@ -404,43 +413,9 @@ abstract class AuthState private constructor() {
override val userUid: String get() = request.user.uid
}

/** The sensitive operation is being retried after credentials were accepted. */
internal class RetryingOperation(
override val request: Request,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/** The retry completed and [outcome] is ready to become the ordinary auth state. */
internal class OperationFinished(
override val request: Request,
val outcome: AuthState,
) : Reauthentication() {
override val requestId: String get() = request.requestId
override val userUid: String get() = request.user.uid
}

/**
* Saved UI state proved a request existed, but its process-local retry callback was lost.
*/
internal class Interrupted(
override val requestId: String,
override val userUid: String,
) : Reauthentication() {
override val request: Request? = null
}

/**
* Whether this request's reauthentication already succeeded. A sign-out must not clear such
* a phase, because the pending operation succeeding can be what signed the user out.
*/
internal val isReauthenticated: Boolean
get() = this is Succeeded || this is RetryingOperation || this is OperationFinished

/**
* A provider attempt is about to run, clearing any previously surfaced failure. Null once
* credentials were accepted, so a late attempt cannot rewind a running operation.
* credentials were accepted, so a late attempt cannot rewind a finished request.
*/
internal fun attemptStarted(): AuthState? = when (this) {
is Required,
Expand Down Expand Up @@ -484,11 +459,6 @@ abstract class AuthState private constructor() {

else -> null
}

/** The retried sensitive operation produced [outcome]. Null unless a retry is in flight. */
internal fun operationFinished(outcome: AuthState): AuthState? =
(this as? RetryingOperation)
?.let { OperationFinished(it.request, outcome) }
}

/**
Expand Down
Loading