From d6b7068eb636a0386309265f3d96582bb00986d2 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 22 Oct 2025 14:12:32 +0200 Subject: [PATCH] feat: add support for MFA challenge during sign in --- .../EmailAuthProvider+FirebaseAuthUI.kt | 13 ++ .../compose/ui/screens/FirebaseAuthScreen.kt | 3 + .../ui/screens/MfaChallengeDefaults.kt | 29 ++-- .../ui/screens/MfaChallengeScreenTest.kt | 126 ++++++++++++++++++ 4 files changed, 160 insertions(+), 11 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index b85f4ef4b..f010ab06c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -32,6 +32,7 @@ import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthMultiFactorException import com.google.firebase.auth.FirebaseAuthUserCollisionException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.tasks.await @@ -362,6 +363,12 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( }.also { updateAuthState(AuthState.Idle) } + } catch (e: FirebaseAuthMultiFactorException) { + // MFA required - extract resolver and update state + val resolver = e.resolver + val hint = resolver.hints.firstOrNull()?.displayName + updateAuthState(AuthState.RequiresMfa(resolver, hint)) + return null } catch (e: CancellationException) { val cancelledException = AuthException.AuthCancelledException( message = "Sign in with email and password was cancelled", @@ -482,6 +489,12 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( } updateAuthState(AuthState.Idle) } + } catch (e: FirebaseAuthMultiFactorException) { + // MFA required - extract resolver and update state + val resolver = e.resolver + val hint = resolver.hints.firstOrNull()?.displayName + updateAuthState(AuthState.RequiresMfa(resolver, hint)) + return null } catch (e: FirebaseAuthUserCollisionException) { // Account collision: account already exists with different sign-in method // Create AccountLinkingRequiredException with credential for linking diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt index 68e3d2e77..076f5de87 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt @@ -268,9 +268,12 @@ fun FirebaseAuthScreen( auth = authUI.auth, onSuccess = { pendingResolver.value = null + // Reset auth state to Idle so the firebaseAuthFlow Success state takes over + authUI.updateAuthState(AuthState.Idle) }, onCancel = { pendingResolver.value = null + authUI.updateAuthState(AuthState.Cancelled) navController.popBackStack() }, onError = { exception -> diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeDefaults.kt index bcad58a5c..de4e6a839 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeDefaults.kt @@ -17,33 +17,38 @@ package com.firebase.ui.auth.compose.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.firebase.ui.auth.compose.configuration.MfaFactor import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.validators.VerificationCodeValidator import com.firebase.ui.auth.compose.mfa.MfaChallengeContentState +import com.firebase.ui.auth.compose.ui.components.VerificationCodeInputField @Composable internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { val isSms = state.factorType == MfaFactor.Sms val stringProvider = DefaultAuthUIStringProvider(LocalContext.current) + val verificationCodeValidator = remember { + VerificationCodeValidator(stringProvider) + } Column( modifier = Modifier @@ -82,17 +87,19 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { ) } - OutlinedTextField( - value = state.verificationCode, - onValueChange = state.onVerificationCodeChange, - label = { Text(stringProvider.verificationCodeLabel) }, - enabled = !state.isLoading, + Spacer(modifier = Modifier.height(8.dp)) + + VerificationCodeInputField( + modifier = Modifier.align(Alignment.CenterHorizontally), + codeLength = 6, + validator = verificationCodeValidator, isError = state.error != null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - modifier = Modifier.fillMaxWidth() + errorMessage = state.error, + onCodeChange = state.onVerificationCodeChange ) + Spacer(modifier = Modifier.height(8.dp)) + if (isSms) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt index 3fd795d87..f23a81983 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt @@ -261,6 +261,132 @@ class MfaChallengeScreenTest { .assertIsEnabled() } + @Test + fun `default UI shows VerificationCodeInputField for SMS factor`() { + `when`(mockPhoneHint.factorId).thenReturn("phone") + `when`(mockPhoneHint.phoneNumber).thenReturn("+1234567890") + `when`(mockResolver.hints).thenReturn(listOf(mockPhoneHint)) + + var capturedState: MfaChallengeContentState? = null + + composeTestRule.setContent { + TestMfaChallengeScreen( + resolver = mockResolver, + onStateChange = { capturedState = it } + ) + } + + composeTestRule.waitForIdle() + + // Verify SMS factor type is detected + assertThat(capturedState?.factorType).isEqualTo(MfaFactor.Sms) + + // Verify masked phone number is set correctly + // +1234567890 is 11 chars: +1 (2) + 6 masked + 890 (3) = +1••••••890 + assertThat(capturedState?.maskedPhoneNumber).isEqualTo("+1••••••890") + + // Verify that the verification code input works (via the state object that would be used by VerificationCodeInputField) + assertThat(capturedState?.verificationCode).isEmpty() + assertThat(capturedState?.isValid).isFalse() + } + + @Test + fun `default UI shows VerificationCodeInputField for TOTP factor`() { + `when`(mockTotpHint.factorId).thenReturn("totp") + `when`(mockResolver.hints).thenReturn(listOf(mockTotpHint)) + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = authUI.auth, + onSuccess = {}, + onCancel = {}, + onError = {} + ) + } + + composeTestRule.waitForIdle() + + // Verify the default UI is displayed with TOTP-specific title + composeTestRule.onNodeWithText(stringProvider.mfaStepVerifyFactorTitle) + .assertIsDisplayed() + + // Verify VerificationCodeInputField is present + composeTestRule.onNodeWithText(stringProvider.verifyAction) + .assertIsDisplayed() + .assertIsNotEnabled() // Should be disabled until code is entered + } + + @Test + fun `default UI shows resend button for SMS factor`() { + // Test SMS factor + `when`(mockPhoneHint.factorId).thenReturn("phone") + `when`(mockPhoneHint.phoneNumber).thenReturn("+1234567890") + `when`(mockResolver.hints).thenReturn(listOf(mockPhoneHint)) + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = authUI.auth, + onSuccess = {}, + onCancel = {}, + onError = {} + ) + } + + composeTestRule.waitForIdle() + + // Should show resend button for SMS + composeTestRule.onNodeWithText(stringProvider.resendCode, substring = true) + .assertIsDisplayed() + } + + @Test + fun `default UI does not show resend button for TOTP factor`() { + // Test TOTP factor + `when`(mockTotpHint.factorId).thenReturn("totp") + `when`(mockResolver.hints).thenReturn(listOf(mockTotpHint)) + + composeTestRule.setContent { + MfaChallengeScreen( + resolver = mockResolver, + auth = authUI.auth, + onSuccess = {}, + onCancel = {}, + onError = {} + ) + } + + composeTestRule.waitForIdle() + + // Should NOT show resend button for TOTP + composeTestRule.onNodeWithText(stringProvider.resendCode, substring = true) + .assertDoesNotExist() + } + + @Test + fun `default UI displays masked phone number for SMS factor`() { + `when`(mockPhoneHint.factorId).thenReturn("phone") + `when`(mockPhoneHint.phoneNumber).thenReturn("+1234567890") + `when`(mockResolver.hints).thenReturn(listOf(mockPhoneHint)) + + var capturedState: MfaChallengeContentState? = null + + composeTestRule.setContent { + TestMfaChallengeScreen( + resolver = mockResolver, + onStateChange = { capturedState = it } + ) + } + + composeTestRule.waitForIdle() + + // Verify masked phone number is set correctly in the state + // +1234567890 is 11 chars: +1 (2) + 6 masked + 890 (3) = +1••••••890 + assertThat(capturedState?.maskedPhoneNumber).isEqualTo("+1••••••890") + assertThat(capturedState?.factorType).isEqualTo(MfaFactor.Sms) + } + @Composable private fun TestMfaChallengeScreen( resolver: MultiFactorResolver,