Skip to content

Commit 87a6884

Browse files
committed
test(auth): extend TOTP example to handle Phone/SMS as well
this helped verify an MFA+SMS verification issue on iOS + emulator MFA works now with email+password primary and either SMS or TOTP or both now
1 parent 610024b commit 87a6884

File tree

3 files changed

+225
-25
lines changed

3 files changed

+225
-25
lines changed

docs/auth/multi-factor-auth.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The [official guide for Firebase web TOTP authentication](https://firebase.googl
1414

1515
The API details and usage examples may be combined with the full Phone auth example below to give you an MFA solution that fully supports TOTP or SMS MFA.
1616

17-
You may also find it useful to investigate [the local / manual test screens](https://github.com/invertase/react-native-firebase/blob/main/tests/local-tests/auth/auth-totp-demonstrator.tsx) that we use to verify this functionality.
17+
You may also find it useful to investigate [the local / manual test screens](https://github.com/invertase/react-native-firebase/blob/main/tests/local-tests/auth/auth-mfa-demonstrator.tsx) that we use to verify this functionality.
1818

1919
# Phone MFA
2020

tests/local-tests/auth/auth-totp-demonstrator.tsx renamed to tests/local-tests/auth/auth-mfa-demonstrator.tsx

Lines changed: 222 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import {
3737
getMultiFactorResolver,
3838
multiFactor,
3939
onAuthStateChanged,
40+
PhoneAuthProvider,
41+
PhoneMultiFactorGenerator,
4042
reload,
4143
sendEmailVerification,
4244
signInWithEmailAndPassword,
@@ -61,7 +63,7 @@ const Button = (props: {
6163
);
6264
};
6365

64-
export function AuthTOTPDemonstrator() {
66+
export function AuthMFADemonstrator() {
6567
const [authReady, setAuthReady] = useState(false);
6668
const [user, setUser] = useState<FirebaseAuthTypes.User | null>(null);
6769

@@ -187,7 +189,7 @@ const Login = () => {
187189
};
188190

189191
if (mfaError) {
190-
return <MfaLogin error={mfaError} />;
192+
return <MfaLogin error={mfaError} clearError={() => setMfaError(undefined)} />;
191193
}
192194

193195
return (
@@ -225,37 +227,73 @@ const Login = () => {
225227
</View>
226228
);
227229
};
228-
229-
const MfaLogin = ({ error }: { error: FirebaseAuthTypes.MultiFactorError }) => {
230+
const MfaLogin = ({
231+
error,
232+
clearError,
233+
}: {
234+
error: FirebaseAuthTypes.MultiFactorError;
235+
clearError: () => void;
236+
}) => {
230237
const [resolver, setResolver] = useState<FirebaseAuthTypes.MultiFactorResolver>();
231238
const [activeFactor, setActiveFactor] = useState<FirebaseAuthTypes.MultiFactorInfo>();
232-
239+
const [verificationId, setVerificationId] = useState<string>('');
233240
const [code, setCode] = useState<string>('');
234241
const [isLoading, setLoading] = useState(false);
235242

236243
useEffect(() => {
237244
const resolver = getMultiFactorResolver(getAuth(), error);
238245
setResolver(resolver);
239-
setActiveFactor(resolver.hints[0]);
246+
console.log('Active factors: ' + JSON.stringify(resolver.hints));
247+
console.log('resolver.hints[0] is ' + JSON.stringify(resolver.hints[0]));
240248
if (resolver.hints.length === 1) {
241-
const hint = resolver.hints[0];
242-
setActiveFactor(hint);
249+
setActiveFactor(resolver.hints[0]);
250+
console.log('activeFactor is ' + JSON.stringify(activeFactor));
251+
console.log('activeFactor.factorId is ' + JSON.stringify(activeFactor?.factorId));
243252
}
244253
}, [error]);
245254

246-
const handleConfirm = async () => {
255+
const requestCode = async () => {
247256
if (!resolver) return;
248257

249258
try {
250259
setLoading(true);
251-
// For demo, assume only 1 hint and it's totp
252-
const multiFactorAssertion = TotpMultiFactorGenerator.assertionForSignIn(
253-
activeFactor!.uid,
254-
code,
260+
setVerificationId(
261+
await new PhoneAuthProvider(getAuth()).verifyPhoneNumber({
262+
multiFactorHint: activeFactor,
263+
session: resolver.session,
264+
}),
255265
);
266+
} catch (error) {
267+
console.error('Error during MFA Phone code send:', error);
268+
} finally {
269+
setLoading(false);
270+
}
271+
};
272+
273+
const handleConfirm = async () => {
274+
if (!resolver || !activeFactor) return;
275+
276+
try {
277+
setLoading(true);
278+
let multiFactorAssertion: FirebaseAuthTypes.MultiFactorAssertion;
279+
switch (activeFactor.factorId) {
280+
case 'totp':
281+
multiFactorAssertion = TotpMultiFactorGenerator.assertionForSignIn(
282+
activeFactor!.uid,
283+
code,
284+
);
285+
break;
286+
case 'phone':
287+
const phoneAuthCredential = new PhoneAuthProvider.credential(verificationId, code);
288+
multiFactorAssertion = PhoneMultiFactorGenerator.assertion(phoneAuthCredential);
289+
break;
290+
default:
291+
throw new Error('Unknown MFA factor type: ' + activeFactor.factorId);
292+
}
293+
256294
return await resolver.resolveSignIn(multiFactorAssertion);
257295
} catch (error) {
258-
console.error('Error during MFA sign in:', error);
296+
console.error('Error during MFA TOTP sign in:', error);
259297
} finally {
260298
setLoading(false);
261299
}
@@ -265,15 +303,57 @@ const MfaLogin = ({ error }: { error: FirebaseAuthTypes.MultiFactorError }) => {
265303
return null;
266304
}
267305

268-
// For demo, assume only 1 hint and it's totp
306+
if (!activeFactor) {
307+
return (
308+
<View style={styles.container}>
309+
<View style={styles.card}>
310+
<Text style={styles.title}>MFA Factor Selection</Text>
311+
<Text style={styles.subtitle}>
312+
You have multiple second factors enrolled. Please select one.
313+
</Text>
314+
{resolver.hints?.map(factor => (
315+
<Button
316+
style={{ marginTop: 20 }}
317+
key={factor.uid}
318+
onPress={() => setActiveFactor(factor)}
319+
>
320+
{`${factor.displayName || factor.factorId} (${factor.factorId})`}
321+
</Button>
322+
))}
323+
324+
<Pressable style={styles.secondaryButton} onPress={clearError}>
325+
<Text style={styles.secondaryButtonText}>Sign Out</Text>
326+
</Pressable>
327+
</View>
328+
</View>
329+
);
330+
}
331+
269332
return (
270333
<View style={styles.container}>
271334
<View style={styles.card}>
272-
<Text style={styles.title}>Two-Factor Authentication</Text>
273-
<Text style={styles.subtitle}>
274-
Please enter the verification code from your authenticator app
275-
</Text>
335+
{/* Show the TOTP code entry if that factor is selected */}
336+
{activeFactor !== undefined && activeFactor.factorId === 'totp' && (
337+
<>
338+
<Text style={styles.title}>TOTP Two-Factor Authentication</Text>
339+
<Text style={styles.subtitle}>
340+
Please enter the verification code from your authenticator app
341+
</Text>
342+
</>
343+
)}
344+
{/* Show the Phone verify && code entry if that factor is selected */}
345+
{activeFactor !== undefined && activeFactor.factorId === 'phone' && (
346+
<>
347+
<Text style={styles.title}>Phone Two-Factor Authentication</Text>
348+
<Text style={styles.subtitle}>1) Request SMS code</Text>
276349

350+
<Button onPress={requestCode} isLoading={isLoading}>
351+
Request SMS Code
352+
</Button>
353+
354+
<Text style={styles.subtitle}>2) enter the code, then Verify</Text>
355+
</>
356+
)}
277357
<View style={styles.inputContainer}>
278358
<TextInput
279359
style={styles.input}
@@ -290,6 +370,17 @@ const MfaLogin = ({ error }: { error: FirebaseAuthTypes.MultiFactorError }) => {
290370
<Button onPress={handleConfirm} isLoading={isLoading}>
291371
Verify
292372
</Button>
373+
374+
{/* Allow user to change factor if more than one */}
375+
{activeFactor && resolver.hints.length > 1 && (
376+
<Pressable style={styles.secondaryButton} onPress={() => setActiveFactor(undefined)}>
377+
<Text style={styles.secondaryButtonText}>Switch Factor</Text>
378+
</Pressable>
379+
)}
380+
381+
<Pressable style={styles.secondaryButton} onPress={clearError}>
382+
<Text style={styles.secondaryButtonText}>Sign Out</Text>
383+
</Pressable>
293384
</View>
294385
</View>
295386
);
@@ -299,6 +390,7 @@ const Home = () => {
299390
const [factors, setFactors] = useState(getAuth().currentUser?.multiFactor?.enrolledFactors);
300391
const [addingFactor, setAddingFactor] = useState(false);
301392
const [removingFactor, setRemovingFactor] = useState(false);
393+
const [addingPhoneFactor, setAddingPhoneFactor] = useState(false);
302394

303395
const [totpSecret, setTotpSecret] = useState<TotpSecret | null>(null);
304396

@@ -336,11 +428,21 @@ const Home = () => {
336428
}
337429
};
338430

431+
if (addingPhoneFactor) {
432+
return (
433+
<EnrollPhone
434+
onComplete={() => {
435+
setFactors(getAuth().currentUser?.multiFactor?.enrolledFactors);
436+
setAddingPhoneFactor(false);
437+
}}
438+
/>
439+
);
440+
}
441+
339442
if (totpSecret) {
340443
return (
341444
<EnrollTotp
342445
totpSecret={totpSecret}
343-
// totpUriQRBase64={totpUriQRBase64}
344446
onComplete={() => {
345447
setFactors(getAuth().currentUser?.multiFactor?.enrolledFactors);
346448
setTotpSecret(null);
@@ -361,6 +463,7 @@ const Home = () => {
361463

362464
{factors?.map(factor => (
363465
<Button
466+
style={{ marginTop: 20 }}
364467
key={factor.uid}
365468
onPress={() => handleRemoveFactor(factor)}
366469
isLoading={removingFactor}
@@ -369,8 +472,105 @@ const Home = () => {
369472
</Button>
370473
))}
371474

372-
<Button style={{ marginTop: 20 }} isLoading={addingFactor} onPress={generateTotpSecret}>
373-
Add TOTP Factor
475+
{factors?.find(factor => factor.factorId === 'totp') === undefined && (
476+
<Button style={{ marginTop: 20 }} isLoading={addingFactor} onPress={generateTotpSecret}>
477+
Add TOTP Factor
478+
</Button>
479+
)}
480+
481+
{factors?.find(factor => factor.factorId === 'phone') === undefined && (
482+
<Button
483+
style={{ marginTop: 20 }}
484+
isLoading={addingFactor}
485+
onPress={() => setAddingPhoneFactor(true)}
486+
>
487+
Add SMS Factor
488+
</Button>
489+
)}
490+
491+
<Pressable style={styles.secondaryButton} onPress={() => signOut(getAuth())}>
492+
<Text style={styles.secondaryButtonText}>Sign Out</Text>
493+
</Pressable>
494+
</View>
495+
</View>
496+
);
497+
};
498+
499+
const EnrollPhone = ({ onComplete }: { onComplete: () => void }) => {
500+
const [waitingForPhoneVerification, setWaitingForPhoneVerification] = useState(false);
501+
const [verificationCode, setVerificationCode] = useState('');
502+
const [verificationId, setVerificationId] = useState('');
503+
const [phoneNumber, setPhoneNumber] = useState('');
504+
const [isLoading, setLoading] = useState(false);
505+
506+
const handleVerifyPhone = async () => {
507+
setLoading(true);
508+
setWaitingForPhoneVerification(true);
509+
try {
510+
const user = getAuth().currentUser;
511+
if (!user) return;
512+
513+
const session = await multiFactor(user).getSession();
514+
setVerificationId(
515+
await new PhoneAuthProvider(getAuth()).verifyPhoneNumber({
516+
phoneNumber,
517+
session,
518+
}),
519+
);
520+
} catch (error) {
521+
console.error('Error sending phone verification:', error);
522+
} finally {
523+
setLoading(false);
524+
}
525+
};
526+
527+
const handleEnrollPhone = async () => {
528+
setLoading(true);
529+
try {
530+
const user = getAuth().currentUser;
531+
if (!user) return;
532+
const cred = PhoneAuthProvider.credential(verificationId, verificationCode);
533+
const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(cred);
534+
await multiFactor(user).enroll(multiFactorAssertion, 'Phone');
535+
onComplete();
536+
} catch (error) {
537+
console.error('Error enrolling Phone:', error);
538+
} finally {
539+
setLoading(false);
540+
setWaitingForPhoneVerification(false);
541+
}
542+
};
543+
544+
return (
545+
<View style={styles.container}>
546+
<View style={styles.card}>
547+
<Text style={styles.title}>Enroll Phone</Text>
548+
549+
<Text style={styles.subtitle}>1) Enter phone # and press send code</Text>
550+
551+
<TextInput
552+
style={styles.input}
553+
value={phoneNumber}
554+
placeholder="+593985787666"
555+
placeholderTextColor="#9CA3AF"
556+
onChangeText={setPhoneNumber}
557+
/>
558+
<Text style={styles.subtitle}>2) Enter the verification code received.</Text>
559+
<TextInput
560+
style={styles.input}
561+
placeholder="Verification Code"
562+
placeholderTextColor="#9CA3AF"
563+
keyboardType="number-pad"
564+
textAlign="center"
565+
maxLength={6}
566+
onChangeText={setVerificationCode}
567+
value={verificationCode}
568+
/>
569+
<Button onPress={handleVerifyPhone} isLoading={isLoading || waitingForPhoneVerification}>
570+
Send Code
571+
</Button>
572+
<Button style={{ marginTop: 20 }} onPress={handleEnrollPhone} isLoading={isLoading}>
573+
Confirm
374574
</Button>
375575

376576
<Pressable style={styles.secondaryButton} onPress={() => signOut(getAuth())}>

tests/local-tests/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { AITestComponent } from './ai/ai';
2626
import { DatabaseOnChildMovedTest } from './database';
2727
import { FirestoreOnSnapshotInSyncTest } from './firestore/onSnapshotInSync';
2828
import { VertexAITestComponent } from './vertexai/vertexai';
29-
import { AuthTOTPDemonstrator } from './auth/auth-totp-demonstrator';
29+
import { AuthTOTPDemonstrator } from './auth/auth-mfa-demonstrator';
3030

3131
const testComponents = {
3232
// List your imported components here...
@@ -35,7 +35,7 @@ const testComponents = {
3535
'Database onChildMoved Test': DatabaseOnChildMovedTest,
3636
'Firestore onSnapshotInSync Test': FirestoreOnSnapshotInSyncTest,
3737
'VertexAI Generation Example': VertexAITestComponent,
38-
'Auth TOTP Demonstrator': AuthTOTPDemonstrator,
38+
'Auth MFA Demonstrator': AuthMFADemonstrator,
3939
};
4040

4141
export function TestComponents() {

0 commit comments

Comments
 (0)