Skip to content

Commit 6e3f755

Browse files
authored
feat: add support for MFA challenge during sign in (#2248)
1 parent 5de660b commit 6e3f755

File tree

4 files changed

+160
-11
lines changed

4 files changed

+160
-11
lines changed

auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import com.google.firebase.auth.AuthCredential
3232
import com.google.firebase.auth.AuthResult
3333
import com.google.firebase.auth.EmailAuthProvider
3434
import com.google.firebase.auth.FirebaseAuth
35+
import com.google.firebase.auth.FirebaseAuthMultiFactorException
3536
import com.google.firebase.auth.FirebaseAuthUserCollisionException
3637
import kotlinx.coroutines.CancellationException
3738
import kotlinx.coroutines.tasks.await
@@ -362,6 +363,12 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
362363
}.also {
363364
updateAuthState(AuthState.Idle)
364365
}
366+
} catch (e: FirebaseAuthMultiFactorException) {
367+
// MFA required - extract resolver and update state
368+
val resolver = e.resolver
369+
val hint = resolver.hints.firstOrNull()?.displayName
370+
updateAuthState(AuthState.RequiresMfa(resolver, hint))
371+
return null
365372
} catch (e: CancellationException) {
366373
val cancelledException = AuthException.AuthCancelledException(
367374
message = "Sign in with email and password was cancelled",
@@ -482,6 +489,12 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
482489
}
483490
updateAuthState(AuthState.Idle)
484491
}
492+
} catch (e: FirebaseAuthMultiFactorException) {
493+
// MFA required - extract resolver and update state
494+
val resolver = e.resolver
495+
val hint = resolver.hints.firstOrNull()?.displayName
496+
updateAuthState(AuthState.RequiresMfa(resolver, hint))
497+
return null
485498
} catch (e: FirebaseAuthUserCollisionException) {
486499
// Account collision: account already exists with different sign-in method
487500
// Create AccountLinkingRequiredException with credential for linking

auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,12 @@ fun FirebaseAuthScreen(
268268
auth = authUI.auth,
269269
onSuccess = {
270270
pendingResolver.value = null
271+
// Reset auth state to Idle so the firebaseAuthFlow Success state takes over
272+
authUI.updateAuthState(AuthState.Idle)
271273
},
272274
onCancel = {
273275
pendingResolver.value = null
276+
authUI.updateAuthState(AuthState.Cancelled)
274277
navController.popBackStack()
275278
},
276279
onError = { exception ->

auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeDefaults.kt

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,33 +17,38 @@ package com.firebase.ui.auth.compose.ui.screens
1717
import androidx.compose.foundation.layout.Arrangement
1818
import androidx.compose.foundation.layout.Column
1919
import androidx.compose.foundation.layout.Row
20+
import androidx.compose.foundation.layout.Spacer
2021
import androidx.compose.foundation.layout.fillMaxWidth
22+
import androidx.compose.foundation.layout.height
2123
import androidx.compose.foundation.layout.padding
2224
import androidx.compose.foundation.rememberScrollState
23-
import androidx.compose.foundation.text.KeyboardOptions
2425
import androidx.compose.foundation.verticalScroll
2526
import androidx.compose.material3.Button
2627
import androidx.compose.material3.CircularProgressIndicator
2728
import androidx.compose.material3.MaterialTheme
2829
import androidx.compose.material3.OutlinedButton
29-
import androidx.compose.material3.OutlinedTextField
3030
import androidx.compose.material3.Text
3131
import androidx.compose.material3.TextButton
3232
import androidx.compose.runtime.Composable
33+
import androidx.compose.runtime.remember
3334
import androidx.compose.ui.Alignment
3435
import androidx.compose.ui.Modifier
3536
import androidx.compose.ui.platform.LocalContext
36-
import androidx.compose.ui.text.input.KeyboardType
3737
import androidx.compose.ui.text.style.TextAlign
3838
import androidx.compose.ui.unit.dp
3939
import com.firebase.ui.auth.compose.configuration.MfaFactor
4040
import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider
41+
import com.firebase.ui.auth.compose.configuration.validators.VerificationCodeValidator
4142
import com.firebase.ui.auth.compose.mfa.MfaChallengeContentState
43+
import com.firebase.ui.auth.compose.ui.components.VerificationCodeInputField
4244

4345
@Composable
4446
internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) {
4547
val isSms = state.factorType == MfaFactor.Sms
4648
val stringProvider = DefaultAuthUIStringProvider(LocalContext.current)
49+
val verificationCodeValidator = remember {
50+
VerificationCodeValidator(stringProvider)
51+
}
4752

4853
Column(
4954
modifier = Modifier
@@ -82,17 +87,19 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) {
8287
)
8388
}
8489

85-
OutlinedTextField(
86-
value = state.verificationCode,
87-
onValueChange = state.onVerificationCodeChange,
88-
label = { Text(stringProvider.verificationCodeLabel) },
89-
enabled = !state.isLoading,
90+
Spacer(modifier = Modifier.height(8.dp))
91+
92+
VerificationCodeInputField(
93+
modifier = Modifier.align(Alignment.CenterHorizontally),
94+
codeLength = 6,
95+
validator = verificationCodeValidator,
9096
isError = state.error != null,
91-
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
92-
singleLine = true,
93-
modifier = Modifier.fillMaxWidth()
97+
errorMessage = state.error,
98+
onCodeChange = state.onVerificationCodeChange
9499
)
95100

101+
Spacer(modifier = Modifier.height(8.dp))
102+
96103
if (isSms) {
97104
Row(
98105
modifier = Modifier.fillMaxWidth(),

e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/MfaChallengeScreenTest.kt

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,132 @@ class MfaChallengeScreenTest {
261261
.assertIsEnabled()
262262
}
263263

264+
@Test
265+
fun `default UI shows VerificationCodeInputField for SMS factor`() {
266+
`when`(mockPhoneHint.factorId).thenReturn("phone")
267+
`when`(mockPhoneHint.phoneNumber).thenReturn("+1234567890")
268+
`when`(mockResolver.hints).thenReturn(listOf<MultiFactorInfo>(mockPhoneHint))
269+
270+
var capturedState: MfaChallengeContentState? = null
271+
272+
composeTestRule.setContent {
273+
TestMfaChallengeScreen(
274+
resolver = mockResolver,
275+
onStateChange = { capturedState = it }
276+
)
277+
}
278+
279+
composeTestRule.waitForIdle()
280+
281+
// Verify SMS factor type is detected
282+
assertThat(capturedState?.factorType).isEqualTo(MfaFactor.Sms)
283+
284+
// Verify masked phone number is set correctly
285+
// +1234567890 is 11 chars: +1 (2) + 6 masked + 890 (3) = +1••••••890
286+
assertThat(capturedState?.maskedPhoneNumber).isEqualTo("+1••••••890")
287+
288+
// Verify that the verification code input works (via the state object that would be used by VerificationCodeInputField)
289+
assertThat(capturedState?.verificationCode).isEmpty()
290+
assertThat(capturedState?.isValid).isFalse()
291+
}
292+
293+
@Test
294+
fun `default UI shows VerificationCodeInputField for TOTP factor`() {
295+
`when`(mockTotpHint.factorId).thenReturn("totp")
296+
`when`(mockResolver.hints).thenReturn(listOf<MultiFactorInfo>(mockTotpHint))
297+
298+
composeTestRule.setContent {
299+
MfaChallengeScreen(
300+
resolver = mockResolver,
301+
auth = authUI.auth,
302+
onSuccess = {},
303+
onCancel = {},
304+
onError = {}
305+
)
306+
}
307+
308+
composeTestRule.waitForIdle()
309+
310+
// Verify the default UI is displayed with TOTP-specific title
311+
composeTestRule.onNodeWithText(stringProvider.mfaStepVerifyFactorTitle)
312+
.assertIsDisplayed()
313+
314+
// Verify VerificationCodeInputField is present
315+
composeTestRule.onNodeWithText(stringProvider.verifyAction)
316+
.assertIsDisplayed()
317+
.assertIsNotEnabled() // Should be disabled until code is entered
318+
}
319+
320+
@Test
321+
fun `default UI shows resend button for SMS factor`() {
322+
// Test SMS factor
323+
`when`(mockPhoneHint.factorId).thenReturn("phone")
324+
`when`(mockPhoneHint.phoneNumber).thenReturn("+1234567890")
325+
`when`(mockResolver.hints).thenReturn(listOf<MultiFactorInfo>(mockPhoneHint))
326+
327+
composeTestRule.setContent {
328+
MfaChallengeScreen(
329+
resolver = mockResolver,
330+
auth = authUI.auth,
331+
onSuccess = {},
332+
onCancel = {},
333+
onError = {}
334+
)
335+
}
336+
337+
composeTestRule.waitForIdle()
338+
339+
// Should show resend button for SMS
340+
composeTestRule.onNodeWithText(stringProvider.resendCode, substring = true)
341+
.assertIsDisplayed()
342+
}
343+
344+
@Test
345+
fun `default UI does not show resend button for TOTP factor`() {
346+
// Test TOTP factor
347+
`when`(mockTotpHint.factorId).thenReturn("totp")
348+
`when`(mockResolver.hints).thenReturn(listOf<MultiFactorInfo>(mockTotpHint))
349+
350+
composeTestRule.setContent {
351+
MfaChallengeScreen(
352+
resolver = mockResolver,
353+
auth = authUI.auth,
354+
onSuccess = {},
355+
onCancel = {},
356+
onError = {}
357+
)
358+
}
359+
360+
composeTestRule.waitForIdle()
361+
362+
// Should NOT show resend button for TOTP
363+
composeTestRule.onNodeWithText(stringProvider.resendCode, substring = true)
364+
.assertDoesNotExist()
365+
}
366+
367+
@Test
368+
fun `default UI displays masked phone number for SMS factor`() {
369+
`when`(mockPhoneHint.factorId).thenReturn("phone")
370+
`when`(mockPhoneHint.phoneNumber).thenReturn("+1234567890")
371+
`when`(mockResolver.hints).thenReturn(listOf<MultiFactorInfo>(mockPhoneHint))
372+
373+
var capturedState: MfaChallengeContentState? = null
374+
375+
composeTestRule.setContent {
376+
TestMfaChallengeScreen(
377+
resolver = mockResolver,
378+
onStateChange = { capturedState = it }
379+
)
380+
}
381+
382+
composeTestRule.waitForIdle()
383+
384+
// Verify masked phone number is set correctly in the state
385+
// +1234567890 is 11 chars: +1 (2) + 6 masked + 890 (3) = +1••••••890
386+
assertThat(capturedState?.maskedPhoneNumber).isEqualTo("+1••••••890")
387+
assertThat(capturedState?.factorType).isEqualTo(MfaFactor.Sms)
388+
}
389+
264390
@Composable
265391
private fun TestMfaChallengeScreen(
266392
resolver: MultiFactorResolver,

0 commit comments

Comments
 (0)