diff --git a/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt b/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt index e171f47a8c..67a6a4e40d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt +++ b/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt @@ -14,6 +14,8 @@ package com.firebase.ui.auth.data +import androidx.compose.runtime.saveable.Saver + /** * Represents country information for phone number authentication. * @@ -39,6 +41,21 @@ data class CountryData( fun getDisplayNameWithDialCode(): String = "$flagEmoji $name ($dialCode)" } +/** + * Round-trips [CountryData] through `rememberSaveable` as a positional list of its four fields. + */ +internal val CountryDataSaver: Saver> = Saver( + save = { listOf(it.name, it.dialCode, it.countryCode, it.flagEmoji) }, + restore = { saved -> + CountryData( + name = saved[0], + dialCode = saved[1], + countryCode = saved[2], + flagEmoji = saved[3], + ) + }, +) + /** * Converts an ISO 3166-1 alpha-2 country code to its corresponding flag emoji. * @@ -49,7 +66,7 @@ fun countryCodeToFlagEmoji(countryCode: String): String { if (countryCode.length != 2) return "" val uppercaseCode = countryCode.uppercase() - val baseCodePoint = 0x1F1E6 // Regional Indicator Symbol Letter A + val baseCodePoint = 0x1F1E6 val charCodeOffset = 'A'.code val firstChar = uppercaseCode[0].code diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt index 4f70343643..dfc1800cfe 100644 --- a/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt +++ b/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt @@ -15,6 +15,7 @@ package com.firebase.ui.auth.mfa import android.app.Activity +import androidx.compose.runtime.saveable.Saver import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.mfa.SmsEnrollmentHandler.Companion.RESEND_DELAY_SECONDS import com.google.firebase.auth.FirebaseAuth @@ -336,6 +337,33 @@ data class SmsEnrollmentSession( } } +/** + * Round-trips [SmsEnrollmentSession] through `rememberSaveable` as a positional list; every field + * it carries is `Parcelable` or a primitive. + */ +internal val SmsEnrollmentSessionSaver: Saver> = Saver( + save = { session -> + session?.let { + listOf( + it.verificationId, + it.phoneNumber, + it.forceResendingToken, + it.sentAt, + it.autoVerifiedCredential, + ) + } + }, + restore = { saved -> + SmsEnrollmentSession( + verificationId = saved[0] as String, + phoneNumber = saved[1] as String, + forceResendingToken = saved[2] as PhoneAuthProvider.ForceResendingToken?, + sentAt = saved[3] as Long, + autoVerifiedCredential = saved[4] as PhoneAuthCredential?, + ) + }, +) + /** * Masks the middle digits of a phone number for privacy. * @@ -357,20 +385,19 @@ fun maskPhoneNumber(phoneNumber: String): String { return phoneNumber } - // Determine country code length (typically 1-3 digits after +) - val digitsOnly = phoneNumber.substring(1) // Remove + + // Country-code length is a heuristic: NANP (+1) is one digit, most others two. + val digitsOnly = phoneNumber.substring(1) val countryCodeLength = when { - digitsOnly.length > 10 -> 2 // Likely 2-digit country code - digitsOnly[0] == '1' -> 1 // North America - else -> 2 // Most other countries + digitsOnly.length > 10 -> 2 + digitsOnly[0] == '1' -> 1 + else -> 2 } - val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include + - // Keep last 3-4 digits visible, with longer numbers showing more + val countryCode = phoneNumber.substring(0, countryCodeLength + 1) val lastDigitsCount = when { - phoneNumber.length >= 14 -> 4 // Long numbers show 4 digits - phoneNumber.length >= 11 -> 3 // Medium numbers show 3 digits - else -> 2 // Short numbers show 2 digits + phoneNumber.length >= 14 -> 4 + phoneNumber.length >= 11 -> 3 + else -> 2 } val lastDigits = phoneNumber.takeLast(lastDigitsCount) val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index c3dca1b379..304ffb075d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -87,6 +87,7 @@ import com.firebase.ui.auth.ui.components.getRecoveryMessage import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController import com.firebase.ui.auth.mfa.MfaChallengeContentState import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentStep import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration @@ -97,7 +98,10 @@ import com.firebase.ui.auth.ui.screens.email.isEmailLinkSignInOffered import com.firebase.ui.auth.ui.screens.email.isEmailSignUpOffered import com.firebase.ui.auth.ui.screens.email.navigateToEmailStep import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen -import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen +import com.firebase.ui.auth.ui.screens.mfa.exitMfaEnrollment +import com.firebase.ui.auth.ui.screens.mfa.mfaEnrollmentDestinations +import com.firebase.ui.auth.ui.screens.mfa.mfaEnrollmentStartStep +import com.firebase.ui.auth.ui.screens.mfa.rememberMfaEnrollmentFlowState import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen import com.firebase.ui.auth.ui.screens.reauth.CustomReauthContent @@ -179,6 +183,8 @@ fun FirebaseAuthScreen( val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } + // Above the NavHost, so a step switch cannot dispose it. + val mfaEnrollmentFlowState = rememberMfaEnrollmentFlowState() // FirebaseAuthUI only folds ordinary states into an armed request while a drainer is present. DisposableEffect(authUI) { authUI.addReauthenticationDrainer() @@ -404,7 +410,9 @@ fun FirebaseAuthScreen( // Inert while armed: this content stays composed beneath the slot. if (reauthState == null) { if (configuration.isMfaEnabled) { - navController.navigate(AuthRoute.MfaEnrollment.route) + navController.navigate( + mfaEnrollmentStartStep(mfaConfiguration).route + ) } else { val exception = AuthException.AuthCancelledException( message = "Multi-factor authentication is disabled in the configuration. " + @@ -445,7 +453,14 @@ fun FirebaseAuthScreen( onNavigate = { route -> // Inert while armed: this content stays composed beneath the slot. if (reauthState == null) { - navController.navigate(route.route) + // MfaEnrollment.route names SelectFactor; one factor skips it. + if (route == AuthRoute.MfaEnrollment) { + navController.navigate( + mfaEnrollmentStartStep(mfaConfiguration).route + ) + } else { + navController.navigate(route.route) + } } } ) @@ -463,28 +478,19 @@ fun FirebaseAuthScreen( } } - // As with the phone steps: every declared step registered, all one screen. - AuthRoute.MfaEnrollment.steps.forEach { step -> - composable(step.routePattern) { - val user = authUI.getCurrentUser() - if (user != null) { - MfaEnrollmentScreen( - user = user, - auth = authUI.auth, - configuration = mfaConfiguration, - authConfiguration = configuration, - content = mfaEnrollmentContent, - onComplete = { navController.popBackStack() }, - onSkip = { navController.popBackStack() }, - onError = { exception -> - onSignInFailure(AuthException.from(exception, stringProvider)) - } - ) - } else { - navController.popBackStack() - } + mfaEnrollmentDestinations( + navController = navController, + configuration = mfaConfiguration, + authConfiguration = configuration, + authUI = authUI, + flowState = mfaEnrollmentFlowState, + content = mfaEnrollmentContent, + onComplete = { navController.exitMfaEnrollment() }, + onSkip = { navController.exitMfaEnrollment() }, + onError = { exception -> + onSignInFailure(AuthException.from(exception, stringProvider)) } - } + ) composable(AuthRoute.MfaChallenge.routePattern) { // Retained for this entry: onSuccess clears pendingResolver, blanking the exit. @@ -1103,21 +1109,33 @@ sealed class AuthRoute { object MfaEnrollment : AuthRoute() { override val route: String get() = SelectFactor.route - /** One step per screen the enrolment flow walks through. */ - sealed class Step(private val id: String) : AuthRoute() { + /** + * One step per screen the enrolment flow walks through. [enrollmentStep] is the + * [MfaEnrollmentStep] the screen renders for this destination. + */ + sealed class Step( + private val id: String, + internal val enrollmentStep: MfaEnrollmentStep, + ) : AuthRoute() { override val route: String get() = id } - object SelectFactor : Step("auth_mfa_enrollment_select_factor") + object SelectFactor : Step("auth_mfa_enrollment_select_factor", MfaEnrollmentStep.SelectFactor) - object ConfigureSms : Step("auth_mfa_enrollment_configure_sms") + object ConfigureSms : Step("auth_mfa_enrollment_configure_sms", MfaEnrollmentStep.ConfigureSms) - object ConfigureTotp : Step("auth_mfa_enrollment_configure_totp") + object ConfigureTotp : Step("auth_mfa_enrollment_configure_totp", MfaEnrollmentStep.ConfigureTotp) - object VerifyFactor : Step("auth_mfa_enrollment_verify_factor") + object VerifyFactor : Step("auth_mfa_enrollment_verify_factor", MfaEnrollmentStep.VerifyFactor) internal val steps: List get() = listOf(SelectFactor, ConfigureSms, ConfigureTotp, VerifyFactor) + + internal fun stepFor(enrollmentStep: MfaEnrollmentStep): Step = + steps.first { it.enrollmentStep == enrollmentStep } + + /** Whether [route] — a live `NavDestination.route` — belongs to this flow. */ + internal fun isStep(route: String?): Boolean = steps.any { it.routePattern == route } } internal companion object { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt index 91b76122cc..90b29d0b24 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt @@ -140,7 +140,7 @@ internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, e } } -/** Shown for as long as a redirect out of an unreachable step takes. */ +/** Shown for as long as a redirect off a step that cannot render itself takes. */ @Composable internal fun RedirectingStep() { Box( diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDestinations.kt new file mode 100644 index 0000000000..db7fe7be7c --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDestinations.kt @@ -0,0 +1,199 @@ +/* + * 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.ui.screens.mfa + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.MfaConfiguration +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.data.CountryDataSaver +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.mfa.SmsEnrollmentSession +import com.firebase.ui.auth.mfa.SmsEnrollmentSessionSaver +import com.firebase.ui.auth.mfa.TotpSecret +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.email.RedirectingStep +import com.firebase.ui.auth.ui.screens.resetBackStackTo +import com.firebase.ui.auth.util.CountryUtils + +/** + * Everything an [MfaEnrollmentScreen] step needs that must outlive the step being left. + * + * Remembered by the host *above* the [androidx.navigation.compose.NavHost] and handed to every + * step through [mfaEnrollmentDestinations], so a step reads and writes this instead of its own + * local state and the data is still there when the flow returns to it. + * + * [selectedFactor], [phoneNumber], [verificationCode], [resendTimerSeconds], [smsSession] and + * [selectedCountry] are backed by [rememberSaveable] in [rememberMfaEnrollmentFlowState] and + * survive Activity recreation. [totpSecret], [totpQrCodeUrl] and [totpSecretExpiredMessage] are + * backed by plain [remember] and are lost: [TotpSecret] wraps `com.google.firebase.auth.TotpSecret`, + * an interface with no public reconstruction API, so there is no `Saver` to write for it. + * + * That loss is recovered rather than fatal: [MfaEnrollmentScreen]'s `LaunchedEffect(currentStep)` + * fetches a fresh secret when [totpSecret] is null on [MfaEnrollmentStep.ConfigureTotp], and + * bounces back to `ConfigureTotp` with [totpSecretExpiredMessage] set when the user was already + * past it. A new secret means a new QR code, so this is a user-visible re-scan, not a seamless + * recovery. + * + * @since 10.0.0 + */ +class MfaEnrollmentFlowState internal constructor( + val selectedFactor: MutableState, + val phoneNumber: MutableState, + val verificationCode: MutableState, + val resendTimerSeconds: MutableIntState, + val smsSession: MutableState, + val totpSecret: MutableState, + val totpQrCodeUrl: MutableState, + val selectedCountry: MutableState, + val totpSecretExpiredMessage: MutableState, +) + +/** + * Creates and remembers the [MfaEnrollmentFlowState] a host installs [mfaEnrollmentDestinations] + * with. Call once, above the `NavHost`, so the same instance is handed to every step — see + * [MfaEnrollmentFlowState] for which of its fields survive Activity recreation. + */ +@Composable +fun rememberMfaEnrollmentFlowState(): MfaEnrollmentFlowState { + val selectedFactor = rememberSaveable { mutableStateOf(null) } + val phoneNumber = rememberSaveable { mutableStateOf("") } + val verificationCode = rememberSaveable { mutableStateOf("") } + val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + val smsSession = rememberSaveable(stateSaver = SmsEnrollmentSessionSaver) { + mutableStateOf(null) + } + val totpSecret = remember { mutableStateOf(null) } + val totpQrCodeUrl = remember { mutableStateOf(null) } + val selectedCountry = rememberSaveable(stateSaver = CountryDataSaver) { + mutableStateOf(CountryUtils.getDefaultCountry()) + } + val totpSecretExpiredMessage = remember { mutableStateOf(null) } + return remember { + MfaEnrollmentFlowState( + selectedFactor = selectedFactor, + phoneNumber = phoneNumber, + verificationCode = verificationCode, + resendTimerSeconds = resendTimerSeconds, + smsSession = smsSession, + totpSecret = totpSecret, + totpQrCodeUrl = totpQrCodeUrl, + selectedCountry = selectedCountry, + totpSecretExpiredMessage = totpSecretExpiredMessage, + ) + } +} + +/** + * Registers the MFA enrolment flow's steps on [this] graph. + * + * Every move between steps **pushes**; this flow never replaces a destination. No enrolment or + * verification failure moves the user off the step they are on — each sets + * [MfaEnrollmentContentState.error] and stays put. + * + * @param flowState The state that must outlive a step switch — see [MfaEnrollmentFlowState]. + * Shared by every step this registers, and expected to be `remember`-ed by the host once, above + * the `NavHost`. + */ +internal fun NavGraphBuilder.mfaEnrollmentDestinations( + navController: NavHostController, + configuration: MfaConfiguration, + authConfiguration: AuthUIConfiguration?, + authUI: FirebaseAuthUI, + flowState: MfaEnrollmentFlowState, + content: (@Composable (MfaEnrollmentContentState) -> Unit)?, + onComplete: () -> Unit, + onSkip: () -> Unit = {}, + onError: (Exception) -> Unit = {}, +) { + AuthRoute.MfaEnrollment.steps.forEach { step -> + composable(route = step.routePattern) { + val user = authUI.getCurrentUser() + if (user == null) { + // Every step is registered, so one reached with no signed-in user leaves the flow. + // An effect, since leaving mutates the back stack; Unit runs it once per entry. + LaunchedEffect(Unit) { navController.exitMfaEnrollment() } + RedirectingStep() + return@composable + } + + MfaEnrollmentScreen( + user = user, + auth = authUI.auth, + configuration = configuration, + authConfiguration = authConfiguration, + content = content, + step = step.enrollmentStep, + onNavigateToStep = { target -> + navController.navigateToMfaStep(AuthRoute.MfaEnrollment.stepFor(target)) + }, + onNavigateBack = { navController.popBackStack() }, + flowState = flowState, + onComplete = onComplete, + onSkip = onSkip, + onError = onError, + ) + } + } +} + +/** Pushes [step] onto the back stack. Always a push, never a pop-then-push. */ +internal fun NavHostController.navigateToMfaStep(step: AuthRoute.MfaEnrollment.Step) { + navigate(step.route) +} + +/** + * Leaves the MFA enrolment flow, popping a step at a time until the top of the back stack is not + * one. + * + * Pops nothing when the flow is already left, so exiting twice leaves what is underneath — and + * its entry-scoped state — untouched. Resets to [AuthRoute.Success] only when the flow was the + * entire back stack and popping it emptied the stack. + */ +internal fun NavHostController.exitMfaEnrollment() { + var onStep = AuthRoute.MfaEnrollment.isStep(currentDestination?.route) + while (onStep && popBackStack()) { + onStep = AuthRoute.MfaEnrollment.isStep(currentDestination?.route) + } + if (onStep) resetBackStackTo(AuthRoute.Success) +} + +/** + * Where entering the MFA enrolment flow should land, resolved once at flow entry. + * + * A configuration offering exactly one factor has nothing to choose, so the flow starts on that + * factor's own configuration step directly, never visiting + * [AuthRoute.MfaEnrollment.SelectFactor]. + */ +internal fun mfaEnrollmentStartStep(configuration: MfaConfiguration): AuthRoute.MfaEnrollment.Step { + return when (configuration.allowedFactors.singleOrNull()) { + MfaFactor.Sms -> AuthRoute.MfaEnrollment.ConfigureSms + MfaFactor.Totp -> AuthRoute.MfaEnrollment.ConfigureTotp + null -> AuthRoute.MfaEnrollment.SelectFactor + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt index 0e928da988..3305214aaf 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt @@ -17,7 +17,6 @@ package com.firebase.ui.auth.ui.screens.mfa import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -28,14 +27,10 @@ import com.firebase.ui.auth.configuration.MfaConfiguration import com.firebase.ui.auth.configuration.MfaFactor import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.data.CountryData -import com.firebase.ui.auth.util.CountryUtils import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebase.ui.auth.mfa.MfaEnrollmentStep import com.firebase.ui.auth.mfa.SmsEnrollmentHandler -import com.firebase.ui.auth.mfa.SmsEnrollmentSession import com.firebase.ui.auth.mfa.TotpEnrollmentHandler -import com.firebase.ui.auth.mfa.TotpSecret import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser import kotlinx.coroutines.delay @@ -53,12 +48,32 @@ import kotlinx.coroutines.launch * 2. **ConfigureSms** or **ConfigureTotp** - User sets up their chosen factor * 3. **VerifyFactor** - User verifies with a code * + * The step can be driven from the outside — + * [com.firebase.ui.auth.ui.screens.mfa.mfaEnrollmentDestinations] gives every step its own + * navigation destination and passes [step], [onNavigateToStep] and [onNavigateBack] — or left to + * this composable, which then keeps the step in local state. Hosting it is preferred: each step + * gets a real back-stack entry and the configured screen transitions, and a step switch does not + * dispose what a previous step held, because that lives in [flowState]. + * * @param user The currently authenticated [FirebaseUser] to enroll in MFA * @param auth The [FirebaseAuth] instance * @param configuration MFA configuration controlling available factors and behavior * @param onComplete Callback invoked when enrollment completes successfully * @param onSkip Callback invoked when user skips enrollment (only if not required) * @param onError Callback invoked when an error occurs during enrollment + * @param step The step to render. When null this composable owns the step itself, starting at + * [MfaEnrollmentStep.SelectFactor] — or straight at the single allowed factor's configuration step + * when [MfaConfiguration.allowedFactors] holds only one. Goes together with [onNavigateToStep], + * [onNavigateBack] and [flowState]: passing any of the four without the rest throws. + * @param onNavigateToStep Invoked instead of changing local state when the flow moves forward — + * selecting a factor, or continuing from a configured one to verification. Always a push. Goes + * together with [step]. + * @param onNavigateBack Invoked instead of changing local state when the user backs out of a + * step. Hosted, this is `NavController.popBackStack()`, which returns to whichever of + * [MfaEnrollmentStep.ConfigureSms] or [MfaEnrollmentStep.ConfigureTotp] was actually pushed before + * [MfaEnrollmentStep.VerifyFactor]. Goes together with [step]. + * @param flowState The data a step switch must not dispose — see + * [com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentFlowState]. Goes together with [step]. * @param content A composable lambda that receives [MfaEnrollmentContentState] to render custom UI * * @since 10.0.0 @@ -72,8 +87,25 @@ fun MfaEnrollmentScreen( onComplete: () -> Unit, onSkip: () -> Unit = {}, onError: (Exception) -> Unit = {}, - content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null + step: MfaEnrollmentStep? = null, + onNavigateToStep: ((MfaEnrollmentStep) -> Unit)? = null, + onNavigateBack: (() -> Unit)? = null, + flowState: MfaEnrollmentFlowState? = null, + content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null, ) { + require( + (step == null) == (onNavigateToStep == null) && + (onNavigateToStep == null) == (onNavigateBack == null) && + (onNavigateBack == null) == (flowState == null) + ) { + "MfaEnrollmentScreen's step, onNavigateToStep, onNavigateBack and flowState go " + + "together: pass all four to drive the step from outside, or none to let the " + + "screen own it. Got step=$step, onNavigateToStep=" + + "${if (onNavigateToStep == null) "null" else "a callback"}, onNavigateBack=" + + "${if (onNavigateBack == null) "null" else "a callback"}, flowState=" + + "${if (flowState == null) "null" else "provided"}." + } + val activity = requireNotNull(LocalActivity.current) { "MfaEnrollmentScreen must be used within an Activity context for SMS verification" } @@ -91,6 +123,10 @@ fun MfaEnrollmentScreen( onComplete = onComplete, onSkip = onSkip, onError = onError, + step = step, + onNavigateToStep = onNavigateToStep, + onNavigateBack = onNavigateBack, + flowState = flowState, content = content ) } @@ -100,8 +136,7 @@ fun MfaEnrollmentScreen( * * Holds the entire enrollment state machine while taking [smsHandler] and [totpHandler] as * parameters, so unit tests can substitute stubbed handlers instead of hitting real Firebase - * statics. The public [MfaEnrollmentScreen] constructs the real handlers and delegates here; - * this function exists only so the handlers are injectable and is not part of the public API. + * statics. Not part of the public API. */ @Composable internal fun MfaEnrollmentScreenInternal( @@ -114,29 +149,36 @@ internal fun MfaEnrollmentScreenInternal( onComplete: () -> Unit, onSkip: () -> Unit = {}, onError: (Exception) -> Unit = {}, + step: MfaEnrollmentStep? = null, + onNavigateToStep: ((MfaEnrollmentStep) -> Unit)? = null, + onNavigateBack: (() -> Unit)? = null, + flowState: MfaEnrollmentFlowState? = null, content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null ) { val coroutineScope = rememberCoroutineScope() val applicationContext = LocalContext.current.applicationContext - val currentStep = rememberSaveable { mutableStateOf(MfaEnrollmentStep.SelectFactor) } - val selectedFactor = rememberSaveable { mutableStateOf(null) } + // Read only when this composable owns the step. + val localStep = rememberSaveable { mutableStateOf(MfaEnrollmentStep.SelectFactor) } + val currentStep = step ?: localStep.value + + val effectiveFlowState = flowState ?: rememberMfaEnrollmentFlowState() + val selectedFactor = effectiveFlowState.selectedFactor + val phoneNumber = effectiveFlowState.phoneNumber + val verificationCode = effectiveFlowState.verificationCode + val resendTimerSeconds = effectiveFlowState.resendTimerSeconds + val smsSession = effectiveFlowState.smsSession + val totpSecret = effectiveFlowState.totpSecret + val totpQrCodeUrl = effectiveFlowState.totpQrCodeUrl + val selectedCountry = effectiveFlowState.selectedCountry + val totpSecretExpiredMessage = effectiveFlowState.totpSecretExpiredMessage + + // Transient per-step UI state: never part of flowState, so a step switch resets it. val isLoading = remember { mutableStateOf(false) } val error = remember { mutableStateOf(null) } val lastException = remember { mutableStateOf(null) } val enrolledFactors = remember { mutableStateOf(user.multiFactor.enrolledFactors) } - val phoneNumber = rememberSaveable { mutableStateOf("") } - val selectedCountry = remember { mutableStateOf(CountryUtils.getDefaultCountry()) } - val smsSession = remember { mutableStateOf(null) } - - val totpSecret = remember { mutableStateOf(null) } - val totpQrCodeUrl = remember { mutableStateOf(null) } - - val verificationCode = rememberSaveable { mutableStateOf("") } - - val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } - val phoneAuthConfiguration = remember(authConfiguration, applicationContext) { authConfiguration ?: authUIConfiguration { context = applicationContext @@ -152,7 +194,6 @@ internal fun MfaEnrollmentScreenInternal( } } - // Handle resend timer countdown LaunchedEffect(resendTimerSeconds.intValue) { if (resendTimerSeconds.intValue > 0) { delay(1000) @@ -160,13 +201,25 @@ internal fun MfaEnrollmentScreenInternal( } } - LaunchedEffect(Unit) { - if (configuration.allowedFactors.size == 1) { - selectedFactor.value = configuration.allowedFactors.first() - when (selectedFactor.value) { - MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms - MfaFactor.Totp -> { - currentStep.value = MfaEnrollmentStep.ConfigureTotp + // Un-hosted only: hosted, mfaEnrollmentStartStep already resolved the single-factor start. + if (step == null) { + LaunchedEffect(Unit) { + if (configuration.allowedFactors.size == 1) { + localStep.value = when (configuration.allowedFactors.first()) { + MfaFactor.Sms -> MfaEnrollmentStep.ConfigureSms + MfaFactor.Totp -> MfaEnrollmentStep.ConfigureTotp + } + } + } + } + + // The null-secret guard fetches once per entry, so a back-and-forward must not re-fetch. + LaunchedEffect(currentStep) { + when (currentStep) { + MfaEnrollmentStep.ConfigureSms -> selectedFactor.value = MfaFactor.Sms + MfaEnrollmentStep.ConfigureTotp -> { + selectedFactor.value = MfaFactor.Totp + if (totpSecret.value == null) { isLoading.value = true try { val secret = totpHandler.generateSecret() @@ -175,7 +228,9 @@ internal fun MfaEnrollmentScreenInternal( accountName = user.email ?: user.phoneNumber ?: "User", issuer = auth.app.name ) - error.value = null + // Non-null only via the VerifyFactor redirect below; a first fetch clears. + error.value = totpSecretExpiredMessage.value + totpSecretExpiredMessage.value = null lastException.value = null } catch (e: Exception) { error.value = e.message @@ -185,69 +240,75 @@ internal fun MfaEnrollmentScreenInternal( isLoading.value = false } } - null -> {} } + MfaEnrollmentStep.VerifyFactor -> { + // A null secret here means Activity recreation dropped it: recover on ConfigureTotp. + if (selectedFactor.value == MfaFactor.Totp && totpSecret.value == null) { + totpSecretExpiredMessage.value = TOTP_SECRET_EXPIRED_MESSAGE + if (onNavigateBack != null) { + onNavigateBack() + } else { + localStep.value = MfaEnrollmentStep.ConfigureTotp + } + } + } + MfaEnrollmentStep.SelectFactor -> Unit + } + } + + /** + * Moves the flow forward one step: hosted, asks the host to navigate; un-hosted, swaps local + * state. Forward moves only — a backward move goes through `onBackClick`. + */ + fun goToStep(target: MfaEnrollmentStep) { + if (onNavigateToStep != null) { + onNavigateToStep(target) + } else { + localStep.value = target } } val state = MfaEnrollmentContentState( - step = currentStep.value, + step = currentStep, isLoading = isLoading.value, error = error.value, exception = lastException.value, onBackClick = { - when (currentStep.value) { - MfaEnrollmentStep.SelectFactor -> {} - MfaEnrollmentStep.ConfigureSms, MfaEnrollmentStep.ConfigureTotp -> { - currentStep.value = MfaEnrollmentStep.SelectFactor - selectedFactor.value = null - phoneNumber.value = "" - totpSecret.value = null - totpQrCodeUrl.value = null - } - MfaEnrollmentStep.VerifyFactor -> { - verificationCode.value = "" - when (selectedFactor.value) { - MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms - MfaFactor.Totp -> currentStep.value = MfaEnrollmentStep.ConfigureTotp - null -> currentStep.value = MfaEnrollmentStep.SelectFactor + if (onNavigateBack != null) { + // Hosted, flowState is deliberately not cleared: a step still on the stack keeps it. + onNavigateBack() + } else { + when (currentStep) { + MfaEnrollmentStep.SelectFactor -> {} + MfaEnrollmentStep.ConfigureSms, MfaEnrollmentStep.ConfigureTotp -> { + localStep.value = MfaEnrollmentStep.SelectFactor + selectedFactor.value = null + phoneNumber.value = "" + totpSecret.value = null + totpQrCodeUrl.value = null + } + MfaEnrollmentStep.VerifyFactor -> { + verificationCode.value = "" + localStep.value = when (selectedFactor.value) { + MfaFactor.Sms -> MfaEnrollmentStep.ConfigureSms + MfaFactor.Totp -> MfaEnrollmentStep.ConfigureTotp + null -> MfaEnrollmentStep.SelectFactor + } } } + error.value = null + lastException.value = null } - error.value = null - lastException.value = null }, availableFactors = configuration.allowedFactors, enrolledFactors = enrolledFactors.value, onFactorSelected = { factor -> - selectedFactor.value = factor - when (factor) { - MfaFactor.Sms -> { - currentStep.value = MfaEnrollmentStep.ConfigureSms - } - MfaFactor.Totp -> { - currentStep.value = MfaEnrollmentStep.ConfigureTotp - coroutineScope.launch { - isLoading.value = true - try { - val secret = totpHandler.generateSecret() - totpSecret.value = secret - totpQrCodeUrl.value = secret.generateQrCodeUrl( - accountName = user.email ?: user.phoneNumber ?: "User", - issuer = auth.app.name - ) - error.value = null - lastException.value = null - } catch (e: Exception) { - error.value = e.message - lastException.value = e - onError(e) - } finally { - isLoading.value = false - } - } + goToStep( + when (factor) { + MfaFactor.Sms -> MfaEnrollmentStep.ConfigureSms + MfaFactor.Totp -> MfaEnrollmentStep.ConfigureTotp } - } + ) }, onUnenrollFactor = { factorInfo -> coroutineScope.launch { @@ -255,7 +316,6 @@ internal fun MfaEnrollmentScreenInternal( try { user.multiFactor.unenroll(factorInfo).addOnCompleteListener { task -> if (task.isSuccessful) { - // Refresh the enrolled factors list enrolledFactors.value = user.multiFactor.enrolledFactors error.value = null } else { @@ -294,7 +354,7 @@ internal fun MfaEnrollmentScreenInternal( val fullPhoneNumber = "${selectedCountry.value.dialCode}${phoneNumber.value}" val session = smsHandler.sendVerificationCode(fullPhoneNumber) smsSession.value = session - currentStep.value = MfaEnrollmentStep.VerifyFactor + goToStep(MfaEnrollmentStep.VerifyFactor) resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS error.value = null lastException.value = null @@ -309,9 +369,7 @@ internal fun MfaEnrollmentScreenInternal( }, totpSecret = totpSecret.value, totpQrCodeUrl = totpQrCodeUrl.value, - onContinueToVerifyClick = { - currentStep.value = MfaEnrollmentStep.VerifyFactor - }, + onContinueToVerifyClick = { goToStep(MfaEnrollmentStep.VerifyFactor) }, verificationCode = verificationCode.value, onVerificationCodeChange = { code -> verificationCode.value = code @@ -349,7 +407,6 @@ internal fun MfaEnrollmentScreenInternal( null -> throw IllegalStateException("No factor selected") } - // Refresh enrolled factors after successful enrollment enrolledFactors.value = user.multiFactor.enrolledFactors onComplete() @@ -403,3 +460,12 @@ internal fun MfaEnrollmentScreenInternal( ) } } + +/** + * Surfaced via [MfaEnrollmentContentState.error] on [MfaEnrollmentStep.ConfigureTotp] after the + * user is bounced back from [MfaEnrollmentStep.VerifyFactor] because Activity recreation dropped + * the TOTP secret. The regenerated secret has a new `sharedSecretKey`, so the QR code on screen is + * a different one the user has to re-scan. + */ +internal const val TOTP_SECRET_EXPIRED_MESSAGE = + "Your authenticator setup session expired. Scan the new QR code to continue." diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt index 8a3535ed64..0449ab11ae 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentScreenTest.kt @@ -30,6 +30,7 @@ import com.firebase.ui.auth.mfa.TotpEnrollmentHandler import com.firebase.ui.auth.mfa.TotpSecret import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreenInternal +import com.firebase.ui.auth.ui.screens.mfa.TOTP_SECRET_EXPIRED_MESSAGE import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser @@ -493,15 +494,14 @@ class MfaEnrollmentScreenTest { } @Test - fun `TOTP verification without a secret reports the missing secret and skips onComplete`() { + fun `losing the TOTP secret to recreation never silently completes enrolment`() { val configuration = MfaConfiguration(allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp)) var completeCount = 0 val errors = mutableListOf() var currentState by mutableStateOf(null) - // Two allowed factors, so nothing is auto-selected: the secret is generated only when the - // TOTP factor is picked, which keeps the restore below from regenerating it. + // Two allowed factors, so nothing is auto-selected until the TOTP factor is picked. val restorationTester = StateRestorationTester(composeTestRule) restorationTester.setContent { MfaEnrollmentScreenInternal( @@ -539,30 +539,22 @@ class MfaEnrollmentScreenTest { assertEquals(MfaEnrollmentStep.VerifyFactor, currentState?.step) // Reach the verify step without a secret the way production does: across process death. - // `currentStep`, `selectedFactor` and `verificationCode` are `rememberSaveable` and are - // restored, while `totpSecret` is a plain `remember` and is not. That step/secret mismatch - // is what `onVerifyClick`'s null-secret branch guards. + // `currentStep` and `selectedFactor` are `rememberSaveable` and are restored, while + // `totpSecret` is a plain `remember` and is not. restorationTester.emulateSavedInstanceStateRestore() composeTestRule.waitForIdle() - assertEquals(MfaEnrollmentStep.VerifyFactor, currentState?.step) - assertEquals(MfaFactor.Totp, currentState?.selectedFactor) - assertNull(currentState?.totpSecret) - // The restore itself must be quiet, so the only error below is the one under test. - assertEquals(emptyList(), errors) - composeTestRule.runOnUiThread { - currentState?.onVerifyClick?.invoke() - } - composeTestRule.waitForIdle() + // A restored secret-less VerifyFactor is not a dead end: the screen bounces back to + // ConfigureTotp and fetches a fresh secret, which the user must re-scan. + assertEquals(MfaEnrollmentStep.ConfigureTotp, currentState?.step) + assertEquals(MfaFactor.Totp, currentState?.selectedFactor) + assertEquals(totpSecret, currentState?.totpSecret) + assertEquals(TOTP_SECRET_EXPIRED_MESSAGE, currentState?.error) + assertEquals(false, currentState?.isLoading) + // The property that matters either way: a lost secret never enrols anything silently. assertEquals(0, completeCount) - assertEquals(1, errors.size) - assertTrue(errors.single() is IllegalStateException) - assertEquals(NO_TOTP_SECRET_MESSAGE, errors.single().message) - assertEquals(errors.single(), currentState?.exception) - assertEquals(NO_TOTP_SECRET_MESSAGE, currentState?.error) - assertEquals(MfaEnrollmentStep.VerifyFactor, currentState?.step) - assertEquals(false, currentState?.isLoading) + assertEquals(emptyList(), errors) } @Test @@ -980,7 +972,6 @@ class MfaEnrollmentScreenTest { const val VERIFICATION_CODE = "123456" const val TOTP_DISPLAY_NAME = "Authenticator App" const val SMS_DISPLAY_NAME = "SMS" - const val NO_TOTP_SECRET_MESSAGE = "No TOTP secret available" const val NO_SMS_SESSION_MESSAGE = "No SMS session available" const val LOCAL_PHONE_NUMBER = "5551234567" const val EXPECTED_FULL_PHONE_NUMBER = "+445551234567" diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentFlowStateRestorationTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentFlowStateRestorationTest.kt new file mode 100644 index 0000000000..b30340f9b1 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentFlowStateRestorationTest.kt @@ -0,0 +1,245 @@ +/* + * 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.ui.screens.mfa + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.MfaConfiguration +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.mfa.SmsEnrollmentSession +import com.firebase.ui.auth.mfa.TotpSecret +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactor +import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Asserts which [MfaEnrollmentFlowState] fields survive an Activity recreation + * mid-SMS-verification. + * + * Drives the hosted flow to [AuthRoute.MfaEnrollment.VerifyFactor] for SMS with a live session, + * recreates the Activity via [StateRestorationTester], then checks the restored values for + * equality with what was set before — not merely for non-null. `smsSession` and `selectedCountry` + * survive via their hand-written `Saver`s; `totpSecret` and `totpQrCodeUrl` do not, and + * [MfaEnrollmentTotpRegenerationTest] covers how that loss is recovered. + * + * Guards the regression where a lost `smsSession` left + * [MfaEnrollmentContentState.onResendCodeClick] a silent no-op. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class MfaEnrollmentFlowStateRestorationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockAuth: FirebaseAuth + + @Mock + private lateinit var mockUser: FirebaseUser + + @Mock + private lateinit var mockMultiFactor: MultiFactor + + private lateinit var authUI: FirebaseAuthUI + + private var navController: NavHostController? = null + private var flowState: MfaEnrollmentFlowState? = null + private var lastState: MfaEnrollmentContentState? = null + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + FirebaseAuthUI.clearInstanceCache() + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { it.delete() } + val app = FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + `when`(mockAuth.app).thenReturn(app) + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.uid).thenReturn("mfa-restoration-user") + `when`(mockUser.email).thenReturn("user@example.com") + `when`(mockUser.multiFactor).thenReturn(mockMultiFactor) + `when`(mockMultiFactor.enrolledFactors).thenReturn(emptyList()) + authUI = FirebaseAuthUI.create(app, mockAuth) + } + + @After + fun tearDown() { + navController = null + flowState = null + lastState = null + FirebaseAuthUI.clearInstanceCache() + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + @Test + fun `recreation mid-SMS-verification now survives smsSession and selectedCountry, still drops the TOTP secret`() { + val restorationTester = StateRestorationTester(composeTestRule) + restorationTester.setContent { MfaFlowHost() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { requireNotNull(lastState).onFactorSelected(MfaFactor.Sms) } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + requireNotNull(lastState).onPhoneNumberChange(TYPED_PHONE_NUMBER) + } + composeTestRule.waitForIdle() + + // Stands in for onSendSmsCodeClick, which would make a real SMS network call. + val fakeSession = SmsEnrollmentSession( + verificationId = "verification-id", + phoneNumber = "+1$TYPED_PHONE_NUMBER", + forceResendingToken = null, + sentAt = System.currentTimeMillis(), + ) + val fakeCountry = CountryData( + name = "United Kingdom", + dialCode = "+44", + countryCode = "GB", + flagEmoji = "🇬🇧", + ) + val fakeTotpSecret = TotpSecret.from(mock(FirebaseTotpSecret::class.java)) + composeTestRule.runOnIdle { + val state = requireNotNull(flowState) + state.smsSession.value = fakeSession + state.totpSecret.value = fakeTotpSecret + state.totpQrCodeUrl.value = FAKE_QR_URL + state.selectedCountry.value = fakeCountry + requireNotNull(navController).navigateToMfaStep(AuthRoute.MfaEnrollment.VerifyFactor) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + requireNotNull(lastState).onVerificationCodeChange(TYPED_VERIFICATION_CODE) + } + composeTestRule.waitForIdle() + + // Sanity on the pre-recreation state, so a fixture mistake cannot pass as a loss. + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.VerifyFactor.routePattern) + assertThat(requireNotNull(lastState).selectedFactor).isEqualTo(MfaFactor.Sms) + assertThat(requireNotNull(lastState).phoneNumber).isEqualTo(TYPED_PHONE_NUMBER) + assertThat(requireNotNull(lastState).verificationCode).isEqualTo(TYPED_VERIFICATION_CODE) + assertThat(requireNotNull(flowState).smsSession.value).isEqualTo(fakeSession) + assertThat(requireNotNull(flowState).totpSecret.value).isEqualTo(fakeTotpSecret) + assertThat(requireNotNull(flowState).totpQrCodeUrl.value).isEqualTo(FAKE_QR_URL) + assertThat(requireNotNull(flowState).selectedCountry.value).isEqualTo(fakeCountry) + + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + + // The active route: NavController's own Saver restores the back stack. + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.VerifyFactor.routePattern) + // rememberSaveable fields survive. + assertThat(requireNotNull(lastState).selectedFactor).isEqualTo(MfaFactor.Sms) + assertThat(requireNotNull(lastState).phoneNumber).isEqualTo(TYPED_PHONE_NUMBER) + assertThat(requireNotNull(lastState).verificationCode).isEqualTo(TYPED_VERIFICATION_CODE) + assertThat(requireNotNull(lastState).resendTimer).isEqualTo(0) + // smsSession, selectedCountry: restored as the *same* value, not merely a non-null one. + assertThat(requireNotNull(flowState).smsSession.value).isEqualTo(fakeSession) + assertThat(requireNotNull(flowState).selectedCountry.value).isEqualTo(fakeCountry) + // The control the loss used to leave silently inert. + assertThat(requireNotNull(lastState).onResendCodeClick).isNotNull() + + // totpSecret, totpQrCodeUrl: plain remember, so still lost. + assertThat(requireNotNull(flowState).totpSecret.value).isNull() + assertThat(requireNotNull(flowState).totpQrCodeUrl.value).isNull() + } + + @Composable + private fun MfaFlowHost() { + val controller = rememberNavController() + val state = rememberMfaEnrollmentFlowState() + SideEffect { + navController = controller + flowState = state + } + + NavHost( + navController = controller, + startDestination = AuthRoute.MfaEnrollment.SelectFactor.routePattern, + // Transitions would keep two MFA destinations composed at once. + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) { + mfaEnrollmentDestinations( + navController = controller, + configuration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = false, + ), + authConfiguration = null, + authUI = authUI, + flowState = state, + content = { contentState -> lastState = contentState }, + onComplete = {}, + onSkip = {}, + onError = {}, + ) + } + } + + private fun currentRoute(): String? = composeTestRule.runOnIdle { + navController?.currentBackStackEntry?.destination?.route + } + + private companion object { + const val TYPED_PHONE_NUMBER = "5551234567" + const val TYPED_VERIFICATION_CODE = "123456" + const val FAKE_QR_URL = "otpauth://totp/test-issuer:user%40example.com?secret=FAKESECRET" + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentHostDestinationsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentHostDestinationsTest.kt new file mode 100644 index 0000000000..4db7c16c01 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentHostDestinationsTest.kt @@ -0,0 +1,302 @@ +/* + * 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.ui.screens.mfa + +import android.content.Context +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.AuthUITransitions +import com.firebase.ui.auth.configuration.MfaConfiguration +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactor +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.TotpMultiFactorAssertion +import com.google.firebase.auth.TotpMultiFactorGenerator +import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * [FirebaseAuthScreen] installs the MFA enrolment flow through [mfaEnrollmentDestinations] and + * supplies its own `onComplete` and `onSkip`. [MfaEnrollmentRouteNavigationTest] wires those two + * itself, so it pins [exitMfaEnrollment] but not the host's choice to call it. + * + * These drive the real screen from the "Manage MFA" callback all the way out, so what they pin is + * the wiring: a completed or skipped enrolment leaves the flow rather than stranding the user on + * the step underneath. + * + * The emulator cannot perform a real TOTP enrolment, so the secret and the assertion are mocked - + * the same approach [MfaEnrollmentRouteNavigationTest] takes. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class MfaEnrollmentHostDestinationsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockAuth: FirebaseAuth + + @Mock + private lateinit var mockUser: FirebaseUser + + @Mock + private lateinit var mockMultiFactor: MultiFactor + + private lateinit var applicationContext: Context + private lateinit var authUI: FirebaseAuthUI + + private var mfaState: MfaEnrollmentContentState? = null + private var uiContext: AuthSuccessUiContext? = null + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + applicationContext = ApplicationProvider.getApplicationContext() + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { it.delete() } + val app = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + `when`(mockAuth.app).thenReturn(app) + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.uid).thenReturn("mfa-host-user") + `when`(mockUser.email).thenReturn("user@example.com") + `when`(mockUser.isEmailVerified).thenReturn(true) + `when`(mockUser.multiFactor).thenReturn(mockMultiFactor) + `when`(mockMultiFactor.enrolledFactors).thenReturn(emptyList()) + authUI = FirebaseAuthUI.create(app, mockAuth) + } + + @After + fun tearDown() { + mfaState = null + uiContext = null + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + @Test + fun `a successful enrolment through the main screen leaves the flow`() { + withMockedTotpEnrollment { + signInAndEnterEnrollment() + selectFactor(MfaFactor.Totp) + assertStep(MfaEnrollmentStep.ConfigureTotp) + continueToVerify() + assertStep(MfaEnrollmentStep.VerifyFactor) + + typeVerificationCode() + verifyFactor() + + assertLeftTheFlow() + } + } + + @Test + fun `a skip through the main screen leaves the flow`() { + signInAndEnterEnrollment() + selectFactor(MfaFactor.Sms) + assertStep(MfaEnrollmentStep.ConfigureSms) + + skipEnrollment() + + assertLeftTheFlow() + } + + // Harness + + /** + * Renders the real screen, signs in so the success destination is the whole back stack, then + * enters the flow through the callback the "Manage MFA" control uses. + */ + private fun signInAndEnterEnrollment() { + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = mfaEnabledConfiguration(), + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + mfaConfiguration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = false, + ), + mfaEnrollmentContent = { state -> + mfaState = state + // Tagged with the step name, so a strand reports which step it stranded on. + Text(text = state.step.toString(), modifier = Modifier.testTag(MFA_STEP_TAG)) + }, + authenticatedContent = { _, context -> + uiContext = context + Text(text = "authenticated", modifier = Modifier.testTag(AUTHENTICATED_TAG)) + }, + ) + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + authUI.updateAuthState( + AuthState.Success(result = null, user = mockUser, isNewUser = false) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(AUTHENTICATED_TAG).assertIsDisplayed() + + composeTestRule.runOnIdle { requireNotNull(uiContext).onManageMfa() } + composeTestRule.waitForIdle() + assertStep(MfaEnrollmentStep.SelectFactor) + } + + /** No MFA step composed, and the destination the flow was entered from is back. */ + private fun assertLeftTheFlow() { + composeTestRule.onNodeWithTag(MFA_STEP_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(AUTHENTICATED_TAG).assertIsDisplayed() + } + + private fun assertStep(step: MfaEnrollmentStep) { + assertThat(requireNotNull(mfaState).step).isEqualTo(step) + } + + private fun selectFactor(factor: MfaFactor) { + composeTestRule.runOnIdle { requireNotNull(mfaState).onFactorSelected(factor) } + composeTestRule.waitForIdle() + } + + private fun continueToVerify() { + composeTestRule.runOnIdle { requireNotNull(mfaState).onContinueToVerifyClick() } + composeTestRule.waitForIdle() + } + + private fun typeVerificationCode() { + composeTestRule.runOnIdle { + requireNotNull(mfaState).onVerificationCodeChange(VERIFICATION_CODE) + } + composeTestRule.waitForIdle() + } + + private fun verifyFactor() { + composeTestRule.runOnIdle { requireNotNull(mfaState).onVerifyClick() } + composeTestRule.waitForIdle() + } + + private fun skipEnrollment() { + composeTestRule.runOnIdle { + requireNotNull(requireNotNull(mfaState).onSkipClick).invoke() + } + composeTestRule.waitForIdle() + } + + private fun mfaEnabledConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + // The default fades would keep the destination being left composed alongside its successor. + transitions = AuthUITransitions( + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) + } + + /** + * Stubs the TOTP secret, the enrolment assertion and [MultiFactor.enroll] so a TOTP enrolment + * completes synchronously, for the duration of [block]. Every action and assertion depending + * on the stubs must run inside [block]. + */ + private fun withMockedTotpEnrollment(block: () -> Unit) { + val mockSession = mock(MultiFactorSession::class.java) + val mockSecret = mock(FirebaseTotpSecret::class.java) + val mockAssertion = mock(TotpMultiFactorAssertion::class.java) + `when`(mockMultiFactor.session).thenReturn(Tasks.forResult(mockSession)) + `when`(mockMultiFactor.enroll(any(), any())).thenReturn(Tasks.forResult(null)) + `when`(mockSecret.sharedSecretKey).thenReturn(FAKE_SHARED_SECRET) + `when`(mockSecret.generateQrCodeUrl(any(), any())).thenReturn(FAKE_QR_URL) + + mockStatic(TotpMultiFactorGenerator::class.java).use { totpStatic -> + totpStatic.`when`> { + TotpMultiFactorGenerator.generateSecret(mockSession) + }.thenReturn(Tasks.forResult(mockSecret)) + totpStatic.`when` { + TotpMultiFactorGenerator.getAssertionForEnrollment(mockSecret, VERIFICATION_CODE) + }.thenReturn(mockAssertion) + + block() + } + } + + private companion object { + const val MFA_STEP_TAG = "mfa-enrollment-step" + const val AUTHENTICATED_TAG = "authenticated-destination" + const val VERIFICATION_CODE = "123456" + const val FAKE_SHARED_SECRET = "JBSWY3DPEHPK3PXP" + const val FAKE_QR_URL = + "otpauth://totp/test-issuer:user%40example.com?secret=JBSWY3DPEHPK3PXP" + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentRouteNavigationTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentRouteNavigationTest.kt new file mode 100644 index 0000000000..8cc4065d1c --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentRouteNavigationTest.kt @@ -0,0 +1,650 @@ +/* + * 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.ui.screens.mfa + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.get +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.MfaConfiguration +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactor +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.TotpMultiFactorGenerator +import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.times +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Covers moving the MFA enrolment flow's steps — [AuthRoute.MfaEnrollment.SelectFactor], + * [AuthRoute.MfaEnrollment.ConfigureSms], [AuthRoute.MfaEnrollment.ConfigureTotp] and + * [AuthRoute.MfaEnrollment.VerifyFactor] — onto real navigation destinations. + * + * The unit under test is [mfaEnrollmentDestinations], hosted here in a bare `NavHost` so the back + * stack can be read directly. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class MfaEnrollmentRouteNavigationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockAuth: FirebaseAuth + + @Mock + private lateinit var mockUser: FirebaseUser + + @Mock + private lateinit var mockMultiFactor: MultiFactor + + private lateinit var authUI: FirebaseAuthUI + + private var navController: NavHostController? = null + private var lastState: MfaEnrollmentContentState? = null + private var pressBack: (() -> Unit)? = null + private var reportComplete: (() -> Unit)? = null + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + FirebaseAuthUI.clearInstanceCache() + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { it.delete() } + val app = FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + `when`(mockAuth.app).thenReturn(app) + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.uid).thenReturn("mfa-route-user") + `when`(mockUser.email).thenReturn("user@example.com") + `when`(mockUser.multiFactor).thenReturn(mockMultiFactor) + `when`(mockMultiFactor.enrolledFactors).thenReturn(emptyList()) + authUI = FirebaseAuthUI.create(app, mockAuth) + } + + @After + fun tearDown() { + navController = null + lastState = null + pressBack = null + reportComplete = null + FirebaseAuthUI.clearInstanceCache() + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + // A step switch must not dispose what a previous step held + + /** + * Guards the regression where backing out to [AuthRoute.MfaEnrollment.SelectFactor] blanked + * the typed phone number. + */ + @Test + fun `the typed phone number survives a detour through TOTP and back`() { + start() + selectFactor(MfaFactor.Sms) + typePhoneNumber(TYPED_PHONE_NUMBER) + + back() + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.SelectFactor.routePattern) + + // A detour, not an immediate re-selection: local state could survive the latter by luck. + selectFactor(MfaFactor.Totp) + back() + + selectFactor(MfaFactor.Sms) + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureSms.routePattern) + assertThat(requireNotNull(lastState).phoneNumber).isEqualTo(TYPED_PHONE_NUMBER) + } + + // System back walks VerifyFactor back to whichever factor was chosen + + @Test + fun `back from VerifyFactor returns to ConfigureSms when SMS was chosen`() { + start() + selectFactor(MfaFactor.Sms) + pushVerifyFactorDirectly() + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.VerifyFactor.routePattern) + + back() + + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureSms.routePattern) + } + + @Test + fun `back from VerifyFactor returns to ConfigureTotp when TOTP was chosen`() { + withMockedTotpSecret { + start() + selectFactor(MfaFactor.Totp) + continueToVerify() + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.VerifyFactor.routePattern) + + back() + + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureTotp.routePattern) + } + } + + // The TOTP secret is fetched once, not on every visit to ConfigureTotp + + @Test + fun `the TOTP secret is fetched once and survives a back-and-forward through SMS`() { + withMockedTotpSecret { totpStatic, mockSession -> + start() + + selectFactor(MfaFactor.Totp) + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureTotp.routePattern) + assertThat(requireNotNull(lastState).totpSecret).isNotNull() + assertThat(requireNotNull(lastState).totpQrCodeUrl).isEqualTo(FAKE_QR_URL) + + back() + selectFactor(MfaFactor.Sms) + back() + selectFactor(MfaFactor.Totp) + + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureTotp.routePattern) + assertThat(requireNotNull(lastState).totpQrCodeUrl).isEqualTo(FAKE_QR_URL) + totpStatic.verify( + { TotpMultiFactorGenerator.generateSecret(mockSession) }, + times(1), + ) + } + } + + // A single allowed factor resolves its start step at flow entry + + @Test + fun `an SMS-only configuration resolves to ConfigureSms`() { + assertThat(mfaEnrollmentStartStep(smsOnlyConfiguration())) + .isEqualTo(AuthRoute.MfaEnrollment.ConfigureSms) + } + + @Test + fun `a TOTP-only configuration resolves to ConfigureTotp`() { + assertThat(mfaEnrollmentStartStep(totpOnlyConfiguration())) + .isEqualTo(AuthRoute.MfaEnrollment.ConfigureTotp) + } + + @Test + fun `a configuration with more than one factor resolves to SelectFactor`() { + assertThat(mfaEnrollmentStartStep(twoFactorConfiguration())) + .isEqualTo(AuthRoute.MfaEnrollment.SelectFactor) + } + + @Test + fun `an SMS-only flow never visits SelectFactor`() { + val configuration = smsOnlyConfiguration() + start(configuration, startStep = mfaEnrollmentStartStep(configuration)) + + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureSms.routePattern) + assertThat(backStackRoutes()) + .containsExactly(AuthRoute.MfaEnrollment.ConfigureSms.routePattern) + } + + /** The secret must still be pre-fetched when `ConfigureTotp` is the flow's first destination. */ + @Test + fun `a TOTP-only flow fetches the secret without ever visiting SelectFactor`() { + withMockedTotpSecret { totpStatic, mockSession -> + val configuration = totpOnlyConfiguration() + start(configuration, startStep = mfaEnrollmentStartStep(configuration)) + + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureTotp.routePattern) + assertThat(backStackRoutes()) + .containsExactly(AuthRoute.MfaEnrollment.ConfigureTotp.routePattern) + assertThat(requireNotNull(lastState).totpQrCodeUrl).isEqualTo(FAKE_QR_URL) + totpStatic.verify( + { TotpMultiFactorGenerator.generateSecret(mockSession) }, + times(1), + ) + } + } + + // Completing or skipping leaves the flow, from whichever step it happened on + + @Test + fun `a successful enrolment three steps deep leaves the flow`() { + startOutsideFlow() + val hostEntry = hostEntry() + selectFactor(MfaFactor.Sms) + pushVerifyFactorDirectly() + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.VerifyFactor.routePattern) + + completeEnrollment() + + assertThat(currentRoute()).isEqualTo(HOST_ROUTE) + assertThat(backStackRoutes()).containsExactly(HOST_ROUTE) + assertThat(hostEntry()).isSameInstanceAs(hostEntry) + } + + @Test + fun `a skip from a pushed step leaves the flow`() { + startOutsideFlow() + val hostEntry = hostEntry() + selectFactor(MfaFactor.Sms) + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureSms.routePattern) + + skipEnrollment() + + assertThat(currentRoute()).isEqualTo(HOST_ROUTE) + assertThat(backStackRoutes()).containsExactly(HOST_ROUTE) + assertThat(hostEntry()).isSameInstanceAs(hostEntry) + } + + /** The start step is `ConfigureSms`, so an exit pinned to `SelectFactor` would pop nothing. */ + @Test + fun `a successful enrolment leaves an SMS-only flow that never visited SelectFactor`() { + startOutsideFlow(smsOnlyConfiguration()) + val hostEntry = hostEntry() + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureSms.routePattern) + pushVerifyFactorDirectly() + + completeEnrollment() + + assertThat(currentRoute()).isEqualTo(HOST_ROUTE) + assertThat(hostEntry()).isSameInstanceAs(hostEntry) + } + + /** + * A host can enter at any step through `AuthSuccessUiContext.onNavigate`, which never pushes + * the resolved start step — so an exit that pops up to that start step finds nothing. + */ + @Test + fun `a successful enrolment leaves a flow entered at a step that is not its start step`() { + startOutsideFlow(enterAtStartStep = false) + val hostEntry = hostEntry() + navigateDirectlyTo(AuthRoute.MfaEnrollment.ConfigureSms) + pushVerifyFactorDirectly() + assertThat(backStackRoutes()) + .doesNotContain(AuthRoute.MfaEnrollment.SelectFactor.routePattern) + + completeEnrollment() + + assertThat(currentRoute()).isEqualTo(HOST_ROUTE) + assertThat(backStackRoutes()).containsExactly(HOST_ROUTE) + assertThat(hostEntry()).isSameInstanceAs(hostEntry) + } + + /** + * The "Manage MFA" control is an undebounced `Button` and entry is a bare `navigate`, so two + * taps in one frame stack the start step twice. An exit popping only the topmost occurrence + * would land on the duplicate. + */ + @Test + fun `a successful enrolment leaves a flow whose start step was entered twice`() { + startOutsideFlow() + val hostEntry = hostEntry() + enterFlow(twoFactorConfiguration()) + selectFactor(MfaFactor.Sms) + assertThat(backStackRoutes()).containsExactly( + HOST_ROUTE, + AuthRoute.MfaEnrollment.SelectFactor.routePattern, + AuthRoute.MfaEnrollment.SelectFactor.routePattern, + AuthRoute.MfaEnrollment.ConfigureSms.routePattern, + ).inOrder() + + completeEnrollment() + + assertThat(currentRoute()).isEqualTo(HOST_ROUTE) + assertThat(backStackRoutes()).containsExactly(HOST_ROUTE) + assertThat(hostEntry()).isSameInstanceAs(hostEntry) + } + + /** + * `onNavigate` accepts any step, so an SMS-only configuration — whose start step is + * `ConfigureSms` — can still be entered at `SelectFactor`. An exit inclusive of the resolved + * start step would strand the user on the picker it stacked underneath. + */ + @Test + fun `a successful enrolment leaves an SMS-only flow entered at SelectFactor`() { + startOutsideFlow(smsOnlyConfiguration(), enterAtStartStep = false) + val hostEntry = hostEntry() + navigateDirectlyTo(AuthRoute.MfaEnrollment.SelectFactor) + selectFactor(MfaFactor.Sms) + pushVerifyFactorDirectly() + + completeEnrollment() + + assertThat(currentRoute()).isEqualTo(HOST_ROUTE) + assertThat(backStackRoutes()).containsExactly(HOST_ROUTE) + assertThat(hostEntry()).isSameInstanceAs(hostEntry) + } + + /** + * `onVerifyClick` reports completion from an unguarded coroutine, so a second exit is + * reachable. It must not rebuild the host entry the caller's own state is scoped to. + */ + @Test + fun `a second exit after the flow has been left changes nothing`() { + startOutsideFlow() + val hostEntry = hostEntry() + selectFactor(MfaFactor.Sms) + completeEnrollment() + + completeEnrollment() + + assertThat(currentRoute()).isEqualTo(HOST_ROUTE) + assertThat(backStackRoutes()).containsExactly(HOST_ROUTE) + assertThat(hostEntry()).isSameInstanceAs(hostEntry) + } + + /** + * A step reached with no signed-in user cannot render, and neither can the step underneath + * it. Popping one at a time would walk the flow out a step per frame; leaving does it in one. + */ + @Test + fun `a step reached with no signed-in user leaves the whole flow`() { + startOutsideFlow() + val hostEntry = hostEntry() + selectFactor(MfaFactor.Sms) + pushVerifyFactorDirectly() + `when`(mockAuth.currentUser).thenReturn(null) + + navigateDirectlyTo(AuthRoute.MfaEnrollment.ConfigureTotp) + + assertThat(currentRoute()).isEqualTo(HOST_ROUTE) + assertThat(backStackRoutes()).containsExactly(HOST_ROUTE) + assertThat(hostEntry()).isSameInstanceAs(hostEntry) + } + + /** Nothing to pop back to: the fallback has to put something on the emptied stack. */ + @Test + fun `an exit from a flow that is the whole back stack resets to Success`() { + start() + selectFactor(MfaFactor.Sms) + assertThat(backStackRoutes()).doesNotContain(HOST_ROUTE) + + completeEnrollment() + + assertThat(currentRoute()).isEqualTo(AuthRoute.Success.routePattern) + assertThat(backStackRoutes()).containsExactly(AuthRoute.Success.routePattern) + } + + // Every public AuthRoute.MfaEnrollment value is a registered destination + + /** + * Wrapped in [withMockedTotpSecret]: the loop walks `ConfigureTotp` before `VerifyFactor`, so + * without a live secret the TOTP-loss recovery would bounce `VerifyFactor` back — correct + * behavior, but it would hide whether that step is reachable at all. + */ + @Test + fun `every declared MFA enrolment step is reachable directly`() { + withMockedTotpSecret { + start() + + AuthRoute.MfaEnrollment.steps.forEach { step -> + navigateDirectlyTo(step) + assertThat(currentRoute()).isEqualTo(step.routePattern) + } + } + } + + // Harness + + private fun twoFactorConfiguration() = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = false, + ) + + private fun smsOnlyConfiguration() = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms), + requireEnrollment = false, + ) + + private fun totpOnlyConfiguration() = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Totp), + requireEnrollment = false, + ) + + /** + * Stubs [mockMultiFactor]'s session and [TotpMultiFactorGenerator.generateSecret] to complete + * synchronously with a fake secret, for the duration of [block]. Every action and assertion + * depending on the stub must run inside [block]. + */ + private fun withMockedTotpSecret( + block: ( + totpStatic: MockedStatic, + mockSession: MultiFactorSession, + ) -> Unit + ) { + val mockSession = mock(MultiFactorSession::class.java) + val mockFirebaseSecret = mock(FirebaseTotpSecret::class.java) + `when`(mockMultiFactor.session).thenReturn(Tasks.forResult(mockSession)) + `when`(mockFirebaseSecret.sharedSecretKey).thenReturn(FAKE_SHARED_SECRET) + `when`(mockFirebaseSecret.generateQrCodeUrl(any(), any())).thenReturn(FAKE_QR_URL) + + mockStatic(TotpMultiFactorGenerator::class.java).use { totpStatic -> + totpStatic.`when`> { + TotpMultiFactorGenerator.generateSecret(mockSession) + }.thenReturn(Tasks.forResult(mockFirebaseSecret)) + + block(totpStatic, mockSession) + } + } + + private fun withMockedTotpSecret(block: () -> Unit) = withMockedTotpSecret { _, _ -> block() } + + private fun start( + configuration: MfaConfiguration = twoFactorConfiguration(), + startStep: AuthRoute.MfaEnrollment.Step = AuthRoute.MfaEnrollment.SelectFactor, + ) { + composeTestRule.setContent { MfaFlowHost(configuration, startStep) } + composeTestRule.waitForIdle() + } + + /** + * Hosts the flow the way a real host does: [HOST_ROUTE] underneath it. Needed to tell leaving + * the flow apart both from landing on one of its steps and from the [AuthRoute.Success] + * fallback, which is a separate destination here. + * + * @param enterAtStartStep false to stay on [HOST_ROUTE], for a caller entering the flow at a + * step of its own choosing. + */ + private fun startOutsideFlow( + configuration: MfaConfiguration = twoFactorConfiguration(), + enterAtStartStep: Boolean = true, + ) { + composeTestRule.setContent { + MfaFlowHost( + configuration = configuration, + startStep = AuthRoute.MfaEnrollment.SelectFactor, + startOutsideFlow = true, + ) + } + composeTestRule.waitForIdle() + if (enterAtStartStep) enterFlow(configuration) + } + + /** Enters the flow the way both of `FirebaseAuthScreen`'s entry points do. */ + private fun enterFlow(configuration: MfaConfiguration) { + composeTestRule.runOnIdle { + requireNotNull(navController).navigate(mfaEnrollmentStartStep(configuration).route) + } + composeTestRule.waitForIdle() + } + + /** + * The live [HOST_ROUTE] entry, or null once it is gone. Compared by reference: a pop leaves + * the same instance, a reset builds a new one and destroys whatever was scoped to the old. + */ + private fun hostEntry(): NavBackStackEntry? = composeTestRule.runOnIdle { + navController?.navigatorProvider?.get(ComposeNavigator::class) + ?.backStack?.value + ?.firstOrNull { it.destination.route == HOST_ROUTE } + } + + /** Invokes the host's `onComplete`, as the screen does on a successful enrolment. */ + private fun completeEnrollment() { + composeTestRule.runOnIdle { requireNotNull(reportComplete).invoke() } + composeTestRule.waitForIdle() + } + + private fun skipEnrollment() { + composeTestRule.runOnIdle { + requireNotNull(requireNotNull(lastState).onSkipClick).invoke() + } + composeTestRule.waitForIdle() + } + + private fun selectFactor(factor: MfaFactor) { + composeTestRule.runOnIdle { requireNotNull(lastState).onFactorSelected(factor) } + composeTestRule.waitForIdle() + } + + private fun typePhoneNumber(value: String) { + composeTestRule.runOnIdle { requireNotNull(lastState).onPhoneNumberChange(value) } + composeTestRule.waitForIdle() + } + + private fun continueToVerify() { + composeTestRule.runOnIdle { requireNotNull(lastState).onContinueToVerifyClick() } + composeTestRule.waitForIdle() + } + + /** Enters [AuthRoute.MfaEnrollment.VerifyFactor] as `onSendSmsCodeClick` does, minus the + * real SMS network call. */ + private fun pushVerifyFactorDirectly() { + composeTestRule.runOnIdle { + requireNotNull(navController).navigateToMfaStep(AuthRoute.MfaEnrollment.VerifyFactor) + } + composeTestRule.waitForIdle() + } + + /** Enters [step] the way a host's `onNavigate` does — bypassing the screen's own guards. */ + private fun navigateDirectlyTo(step: AuthRoute.MfaEnrollment.Step) { + composeTestRule.runOnIdle { requireNotNull(navController).navigate(step.route) } + composeTestRule.waitForIdle() + } + + private fun back() { + composeTestRule.runOnUiThread { requireNotNull(pressBack).invoke() } + composeTestRule.waitForIdle() + } + + private fun currentRoute(): String? = + composeTestRule.runOnIdle { navController?.currentBackStackEntry?.destination?.route } + + /** + * The composed destinations on the stack, bottom to top. Reads the `ComposeNavigator`'s own + * back stack; `NavController.currentBackStack` is `@RestrictTo`. + */ + private fun backStackRoutes(): List = composeTestRule.runOnIdle { + navController?.navigatorProvider?.get(ComposeNavigator::class) + ?.backStack?.value + ?.map { it.destination.route } + .orEmpty() + } + + @Composable + private fun MfaFlowHost( + configuration: MfaConfiguration, + startStep: AuthRoute.MfaEnrollment.Step, + startOutsideFlow: Boolean = false, + ) { + val controller = rememberNavController() + val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val flowState = rememberMfaEnrollmentFlowState() + val exit: () -> Unit = { controller.exitMfaEnrollment() } + SideEffect { + navController = controller + pressBack = dispatcher?.let { { it.onBackPressed() } } + reportComplete = exit + } + + NavHost( + navController = controller, + startDestination = + if (startOutsideFlow) HOST_ROUTE else startStep.routePattern, + // Transitions would keep two MFA destinations composed at once. + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) { + composable(HOST_ROUTE) {} + composable(AuthRoute.Success.routePattern) {} + mfaEnrollmentDestinations( + navController = controller, + configuration = configuration, + authConfiguration = null, + authUI = authUI, + flowState = flowState, + content = { state -> lastState = state }, + onComplete = exit, + onSkip = exit, + onError = {}, + ) + } + } + + private companion object { + /** + * Stands in for whatever the host had on the stack before the flow was entered. + * Deliberately not [AuthRoute.Success]: that is the exit's fallback target, and the two + * outcomes have to be distinguishable. + */ + const val HOST_ROUTE = "host_outside_flow" + + const val TYPED_PHONE_NUMBER = "5551234567" + const val FAKE_SHARED_SECRET = "JBSWY3DPEHPK3PXP" + const val FAKE_QR_URL = "otpauth://totp/test-issuer:user%40example.com?secret=JBSWY3DPEHPK3PXP" + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentTotpRegenerationTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentTotpRegenerationTest.kt new file mode 100644 index 0000000000..5fab8ba822 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentTotpRegenerationTest.kt @@ -0,0 +1,336 @@ +/* + * 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.ui.screens.mfa + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.MfaConfiguration +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactor +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.TotpMultiFactorGenerator +import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.times +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Covers the recovery from `totpSecret`/`totpQrCodeUrl` being lost to Activity recreation — the + * loss [MfaEnrollmentFlowStateRestorationTest] establishes. + * + * Drives the hosted flow to [AuthRoute.MfaEnrollment.VerifyFactor] for TOTP with a live secret, + * recreates the Activity via [StateRestorationTester], and asserts the executed recovery: + * [TotpMultiFactorGenerator.generateSecret] is called again, the user lands back on + * [AuthRoute.MfaEnrollment.ConfigureTotp] with a *new* secret and QR code rather than the stale + * one, and [MfaEnrollmentContentState.error] explains why. + * + * Guards the regression where that loss dead-ended in `onVerifyClick` throwing + * `"No TOTP secret available"` with nowhere to go. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class MfaEnrollmentTotpRegenerationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockAuth: FirebaseAuth + + @Mock + private lateinit var mockUser: FirebaseUser + + @Mock + private lateinit var mockMultiFactor: MultiFactor + + private lateinit var authUI: FirebaseAuthUI + + private var navController: NavHostController? = null + private var flowState: MfaEnrollmentFlowState? = null + private var lastState: MfaEnrollmentContentState? = null + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + FirebaseAuthUI.clearInstanceCache() + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { it.delete() } + val app = FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + `when`(mockAuth.app).thenReturn(app) + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.uid).thenReturn("mfa-totp-regen-user") + `when`(mockUser.email).thenReturn("user@example.com") + `when`(mockUser.multiFactor).thenReturn(mockMultiFactor) + `when`(mockMultiFactor.enrolledFactors).thenReturn(emptyList()) + authUI = FirebaseAuthUI.create(app, mockAuth) + } + + @After + fun tearDown() { + navController = null + flowState = null + lastState = null + FirebaseAuthUI.clearInstanceCache() + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + @Test + fun `losing the TOTP secret to recreation regenerates it and bounces VerifyFactor back to ConfigureTotp`() { + val mockSession = mock(MultiFactorSession::class.java) + val firstSecret = mock(FirebaseTotpSecret::class.java) + val secondSecret = mock(FirebaseTotpSecret::class.java) + `when`(mockMultiFactor.session).thenReturn(Tasks.forResult(mockSession)) + `when`(firstSecret.sharedSecretKey).thenReturn(FIRST_SHARED_SECRET) + `when`(firstSecret.generateQrCodeUrl(any(), any())).thenReturn(FIRST_QR_URL) + `when`(secondSecret.sharedSecretKey).thenReturn(SECOND_SHARED_SECRET) + `when`(secondSecret.generateQrCodeUrl(any(), any())).thenReturn(SECOND_QR_URL) + + // Held pending: a completed Task would resolve in the same idle pass as the recomposition. + val secondSecretSource = TaskCompletionSource() + + mockStatic(TotpMultiFactorGenerator::class.java).use { totpStatic -> + totpStatic.`when`> { + TotpMultiFactorGenerator.generateSecret(mockSession) + }.thenReturn(Tasks.forResult(firstSecret), secondSecretSource.task) + + val restorationTester = StateRestorationTester(composeTestRule) + restorationTester.setContent { + MfaFlowHost(twoFactorConfiguration(), AuthRoute.MfaEnrollment.SelectFactor) + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { requireNotNull(lastState).onFactorSelected(MfaFactor.Totp) } + composeTestRule.waitForIdle() + + // Sanity on the pre-recreation state, so a fixture mistake cannot pass as a loss. + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureTotp.routePattern) + assertThat(requireNotNull(lastState).totpQrCodeUrl).isEqualTo(FIRST_QR_URL) + totpStatic.verify( + { TotpMultiFactorGenerator.generateSecret(mockSession) }, + times(1), + ) + + composeTestRule.runOnIdle { requireNotNull(lastState).onContinueToVerifyClick() } + composeTestRule.waitForIdle() + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.VerifyFactor.routePattern) + + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + + // Observed before the pending fetch resolves: VerifyFactor has already popped back. + assertThat(requireNotNull(flowState).totpSecret.value).isNull() + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureTotp.routePattern) + assertThat(requireNotNull(lastState).isLoading).isTrue() + + // (a) generateSecret was called again — exactly once more, not on every recomposition. + totpStatic.verify( + { TotpMultiFactorGenerator.generateSecret(mockSession) }, + times(2), + ) + + secondSecretSource.setResult(secondSecret) + composeTestRule.waitForIdle() + + // (b) The regenerated secret is a genuinely new one, not the stale pre-recreation QR. + assertThat(requireNotNull(lastState).totpQrCodeUrl).isEqualTo(SECOND_QR_URL) + assertThat(requireNotNull(lastState).totpQrCodeUrl).isNotEqualTo(FIRST_QR_URL) + assertThat(requireNotNull(flowState).totpSecret.value).isNotNull() + // (c) The user is told why: this was not a silent recovery. + assertThat(requireNotNull(lastState).error).isEqualTo(TOTP_SECRET_EXPIRED_MESSAGE) + } + } + + /** + * The single-allowed-factor variant of the test above: [AuthRoute.MfaEnrollment.ConfigureTotp] + * **is** the `NavHost`'s `startDestination`, with no entry beneath it. + * + * With nothing below it, a `popBackStack()` that overshoots or that leaves `VerifyFactor` in + * place shows up as a wrong route or a non-root stack, which [isAtBackStackRoot] catches. + */ + @Test + fun `losing the TOTP secret to recreation regenerates it and bounces back to ConfigureTotp when TOTP is the only allowed factor`() { + val mockSession = mock(MultiFactorSession::class.java) + val firstSecret = mock(FirebaseTotpSecret::class.java) + val secondSecret = mock(FirebaseTotpSecret::class.java) + `when`(mockMultiFactor.session).thenReturn(Tasks.forResult(mockSession)) + `when`(firstSecret.sharedSecretKey).thenReturn(FIRST_SHARED_SECRET) + `when`(firstSecret.generateQrCodeUrl(any(), any())).thenReturn(FIRST_QR_URL) + `when`(secondSecret.sharedSecretKey).thenReturn(SECOND_SHARED_SECRET) + `when`(secondSecret.generateQrCodeUrl(any(), any())).thenReturn(SECOND_QR_URL) + + // Held pending, as in the two-factor test, so the null totpSecret is observed. + val secondSecretSource = TaskCompletionSource() + + mockStatic(TotpMultiFactorGenerator::class.java).use { totpStatic -> + totpStatic.`when`> { + TotpMultiFactorGenerator.generateSecret(mockSession) + }.thenReturn(Tasks.forResult(firstSecret), secondSecretSource.task) + + val configuration = totpOnlyConfiguration() + val restorationTester = StateRestorationTester(composeTestRule) + restorationTester.setContent { + MfaFlowHost(configuration, mfaEnrollmentStartStep(configuration)) + } + composeTestRule.waitForIdle() + + // Sanity: landed directly on ConfigureTotp, at the back-stack root, with a live secret. + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureTotp.routePattern) + assertThat(isAtBackStackRoot()).isTrue() + assertThat(requireNotNull(lastState).totpQrCodeUrl).isEqualTo(FIRST_QR_URL) + totpStatic.verify( + { TotpMultiFactorGenerator.generateSecret(mockSession) }, + times(1), + ) + + composeTestRule.runOnIdle { requireNotNull(lastState).onContinueToVerifyClick() } + composeTestRule.waitForIdle() + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.VerifyFactor.routePattern) + + restorationTester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + + // With no SelectFactor entry, back-to-ConfigureTotp and back-to-root are one claim. + assertThat(requireNotNull(flowState).totpSecret.value).isNull() + assertThat(currentRoute()).isEqualTo(AuthRoute.MfaEnrollment.ConfigureTotp.routePattern) + assertThat(isAtBackStackRoot()).isTrue() + assertThat(requireNotNull(lastState).isLoading).isTrue() + + // (a) generateSecret was called again — exactly once more, not on every recomposition. + totpStatic.verify( + { TotpMultiFactorGenerator.generateSecret(mockSession) }, + times(2), + ) + + secondSecretSource.setResult(secondSecret) + composeTestRule.waitForIdle() + + // (b) The regenerated secret is a genuinely new one, not the stale pre-recreation QR. + assertThat(requireNotNull(lastState).totpQrCodeUrl).isEqualTo(SECOND_QR_URL) + assertThat(requireNotNull(lastState).totpQrCodeUrl).isNotEqualTo(FIRST_QR_URL) + assertThat(requireNotNull(flowState).totpSecret.value).isNotNull() + // (c) The user is told why: this was not a silent recovery. + assertThat(requireNotNull(lastState).error).isEqualTo(TOTP_SECRET_EXPIRED_MESSAGE) + } + } + + @Composable + private fun MfaFlowHost( + configuration: MfaConfiguration, + startStep: AuthRoute.MfaEnrollment.Step, + ) { + val controller = rememberNavController() + val state = rememberMfaEnrollmentFlowState() + SideEffect { + navController = controller + flowState = state + } + + NavHost( + navController = controller, + startDestination = startStep.routePattern, + // Transitions would keep two MFA destinations composed at once. + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) { + mfaEnrollmentDestinations( + navController = controller, + configuration = configuration, + authConfiguration = null, + authUI = authUI, + flowState = state, + content = { contentState -> lastState = contentState }, + onComplete = {}, + onSkip = {}, + onError = {}, + ) + } + } + + private fun currentRoute(): String? = composeTestRule.runOnIdle { + navController?.currentBackStackEntry?.destination?.route + } + + /** + * Whether the current back-stack entry has nothing beneath it — true only when it is the + * `NavHost`'s `startDestination` itself, with no earlier entry to pop to. + */ + private fun isAtBackStackRoot(): Boolean = composeTestRule.runOnIdle { + navController?.previousBackStackEntry == null + } + + private fun twoFactorConfiguration() = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = false, + ) + + private fun totpOnlyConfiguration() = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Totp), + requireEnrollment = false, + ) + + private companion object { + const val FIRST_SHARED_SECRET = "JBSWY3DPEHPK3PXP" + const val SECOND_SHARED_SECRET = "KRSXG5CTMVRXEZLU" + const val FIRST_QR_URL = "otpauth://totp/test-issuer:user%40example.com?secret=$FIRST_SHARED_SECRET" + const val SECOND_QR_URL = "otpauth://totp/test-issuer:user%40example.com?secret=$SECOND_SHARED_SECRET" + } +}