Skip to content

Commit 7695806

Browse files
authored
Merge pull request #1229 from firebase/@invertase/mfa-enrollment
2 parents 99753d9 + 581a73f commit 7695806

File tree

56 files changed

+4099
-121
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+4099
-121
lines changed

examples/react/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ function App() {
8282
Password Reset Screen
8383
</NavLink>
8484
</li>
85+
<li>
86+
<NavLink to="/screens/mfa-enrollment-screen" className="text-blue-500 hover:underline">
87+
MFA Enrollment Screen
88+
</NavLink>
89+
</li>
8590
</ul>
8691
</div>
8792
</div>

examples/react/src/main.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ import OAuthScreenPage from "./screens/oauth-screen";
4747
/** Password Reset */
4848
import ForgotPasswordPage from "./screens/forgot-password-screen";
4949

50+
/** MFA Enrollment */
51+
import MultiFactorAuthEnrollmentScreenPage from "./screens/mfa-enrollment-screen";
52+
5053
const root = document.getElementById("root")!;
5154

5255
ReactDOM.createRoot(root).render(
@@ -72,6 +75,7 @@ ReactDOM.createRoot(root).render(
7275
<Route path="/screens/sign-up-auth-screen-w-oauth" element={<SignUpAuthScreenWithOAuthPage />} />
7376
<Route path="/screens/oauth-screen" element={<OAuthScreenPage />} />
7477
<Route path="/screens/forgot-password-screen" element={<ForgotPasswordPage />} />
78+
<Route path="/screens/mfa-enrollment-screen" element={<MultiFactorAuthEnrollmentScreenPage />} />
7579
</Routes>
7680
</FirebaseUIProvider>
7781
</BrowserRouter>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
"use client";
18+
19+
import { MultiFactorAuthEnrollmentScreen } from "@firebase-ui/react";
20+
import { FactorId } from "firebase/auth";
21+
22+
export default function MultiFactorAuthEnrollmentScreenPage() {
23+
return (
24+
<MultiFactorAuthEnrollmentScreen
25+
hints={[FactorId.TOTP, FactorId.PHONE]}
26+
onEnrollment={() => {
27+
console.log("Enrollment successful");
28+
}}
29+
/>
30+
);
31+
}

packages/core/src/auth.ts

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@ import {
2424
EmailAuthProvider,
2525
linkWithCredential,
2626
PhoneAuthProvider,
27+
TotpMultiFactorGenerator,
28+
multiFactor,
2729
type ActionCodeSettings,
2830
type ApplicationVerifier,
2931
type AuthProvider,
3032
type UserCredential,
3133
type AuthCredential,
3234
type TotpSecret,
33-
type PhoneInfoOptions,
35+
type MultiFactorAssertion,
36+
type MultiFactorUser,
37+
type MultiFactorInfo,
3438
} from "firebase/auth";
3539
import QRCode from "qrcode-generator";
3640
import { type FirebaseUI } from "./config";
@@ -54,13 +58,18 @@ async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): P
5458
}
5559
}
5660

61+
function setPendingState(ui: FirebaseUI) {
62+
ui.setRedirectError(undefined);
63+
ui.setState("pending");
64+
}
65+
5766
export async function signInWithEmailAndPassword(
5867
ui: FirebaseUI,
5968
email: string,
6069
password: string
6170
): Promise<UserCredential> {
6271
try {
63-
ui.setState("pending");
72+
setPendingState(ui);
6473
const credential = EmailAuthProvider.credential(email, password);
6574

6675
if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) {
@@ -87,7 +96,7 @@ export async function createUserWithEmailAndPassword(
8796
displayName?: string
8897
): Promise<UserCredential> {
8998
try {
90-
ui.setState("pending");
99+
setPendingState(ui);
91100
const credential = EmailAuthProvider.credential(email, password);
92101

93102
if (hasBehavior(ui, "requireDisplayName") && !displayName) {
@@ -122,13 +131,38 @@ export async function createUserWithEmailAndPassword(
122131

123132
export async function verifyPhoneNumber(
124133
ui: FirebaseUI,
125-
phoneNumber: PhoneInfoOptions | string,
126-
appVerifier: ApplicationVerifier
134+
phoneNumber: string,
135+
appVerifier: ApplicationVerifier,
136+
mfaUser?: MultiFactorUser,
137+
mfaHint?: MultiFactorInfo
127138
): Promise<string> {
128139
try {
129-
ui.setState("pending");
140+
setPendingState(ui);
130141
const provider = new PhoneAuthProvider(ui.auth);
131-
return await provider.verifyPhoneNumber(phoneNumber, appVerifier);
142+
143+
if (mfaHint && ui.multiFactorResolver) {
144+
// MFA assertion flow
145+
return await provider.verifyPhoneNumber(
146+
{
147+
multiFactorHint: mfaHint,
148+
session: ui.multiFactorResolver.session,
149+
},
150+
appVerifier
151+
);
152+
} else if (mfaUser) {
153+
// MFA enrollment flow
154+
const session = await mfaUser.getSession();
155+
return await provider.verifyPhoneNumber(
156+
{
157+
phoneNumber,
158+
session,
159+
},
160+
appVerifier
161+
);
162+
} else {
163+
// Regular phone auth flow
164+
return await provider.verifyPhoneNumber(phoneNumber, appVerifier);
165+
}
132166
} catch (error) {
133167
handleFirebaseError(ui, error);
134168
} finally {
@@ -142,7 +176,7 @@ export async function confirmPhoneNumber(
142176
verificationCode: string
143177
): Promise<UserCredential> {
144178
try {
145-
ui.setState("pending");
179+
setPendingState(ui);
146180
const currentUser = ui.auth.currentUser;
147181
const credential = PhoneAuthProvider.credential(verificationId, verificationCode);
148182

@@ -165,7 +199,7 @@ export async function confirmPhoneNumber(
165199

166200
export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Promise<void> {
167201
try {
168-
ui.setState("pending");
202+
setPendingState(ui);
169203
await _sendPasswordResetEmail(ui.auth, email);
170204
} catch (error) {
171205
handleFirebaseError(ui, error);
@@ -176,7 +210,7 @@ export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Pro
176210

177211
export async function sendSignInLinkToEmail(ui: FirebaseUI, email: string): Promise<void> {
178212
try {
179-
ui.setState("pending");
213+
setPendingState(ui);
180214
const actionCodeSettings = {
181215
url: window.location.href,
182216
// TODO(ehesp): Check this...
@@ -200,7 +234,7 @@ export async function signInWithEmailLink(ui: FirebaseUI, email: string, link: s
200234

201235
export async function signInWithCredential(ui: FirebaseUI, credential: AuthCredential): Promise<UserCredential> {
202236
try {
203-
ui.setState("pending");
237+
setPendingState(ui);
204238
if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) {
205239
const userCredential = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential);
206240

@@ -222,7 +256,7 @@ export async function signInWithCredential(ui: FirebaseUI, credential: AuthCrede
222256

223257
export async function signInAnonymously(ui: FirebaseUI): Promise<UserCredential> {
224258
try {
225-
ui.setState("pending");
259+
setPendingState(ui);
226260
const result = await _signInAnonymously(ui.auth);
227261
return handlePendingCredential(ui, result);
228262
} catch (error) {
@@ -234,7 +268,7 @@ export async function signInAnonymously(ui: FirebaseUI): Promise<UserCredential>
234268

235269
export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider): Promise<UserCredential | never> {
236270
try {
237-
ui.setState("pending");
271+
setPendingState(ui);
238272
if (hasBehavior(ui, "autoUpgradeAnonymousProvider")) {
239273
const credential = await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider);
240274

@@ -267,7 +301,7 @@ export async function completeEmailLinkSignIn(ui: FirebaseUI, currentUrl: string
267301
const email = window.localStorage.getItem("emailForSignIn");
268302
if (!email) return null;
269303

270-
ui.setState("pending");
304+
setPendingState(ui);
271305
const result = await signInWithEmailLink(ui, email, currentUrl);
272306
return handlePendingCredential(ui, result);
273307
} catch (error) {
@@ -292,3 +326,44 @@ export function generateTotpQrCode(ui: FirebaseUI, secret: TotpSecret, accountNa
292326
qr.make();
293327
return qr.createDataURL();
294328
}
329+
330+
export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: MultiFactorAssertion) {
331+
try {
332+
setPendingState(ui);
333+
const result = await ui.multiFactorResolver?.resolveSignIn(assertion);
334+
ui.setMultiFactorResolver(undefined);
335+
return result;
336+
} catch (error) {
337+
handleFirebaseError(ui, error);
338+
} finally {
339+
ui.setState("idle");
340+
}
341+
}
342+
343+
export async function enrollWithMultiFactorAssertion(
344+
ui: FirebaseUI,
345+
assertion: MultiFactorAssertion,
346+
displayName?: string
347+
): Promise<void> {
348+
try {
349+
setPendingState(ui);
350+
await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName);
351+
} catch (error) {
352+
handleFirebaseError(ui, error);
353+
} finally {
354+
ui.setState("idle");
355+
}
356+
}
357+
358+
export async function generateTotpSecret(ui: FirebaseUI): Promise<TotpSecret> {
359+
try {
360+
setPendingState(ui);
361+
const mfaUser = multiFactor(ui.auth.currentUser!);
362+
const session = await mfaUser.getSession();
363+
return await TotpMultiFactorGenerator.generateSecret(session);
364+
} catch (error) {
365+
handleFirebaseError(ui, error);
366+
} finally {
367+
ui.setState("idle");
368+
}
369+
}

packages/core/src/config.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,4 +383,113 @@ describe("initializeUI", () => {
383383
ui.get().setMultiFactorResolver(undefined);
384384
expect(ui.get().multiFactorResolver).toBeUndefined();
385385
});
386+
387+
it("should have redirectError undefined by default", () => {
388+
const config = {
389+
app: {} as FirebaseApp,
390+
auth: {} as Auth,
391+
};
392+
393+
const ui = initializeUI(config);
394+
expect(ui.get().redirectError).toBeUndefined();
395+
});
396+
397+
it("should set and get redirectError correctly", () => {
398+
const config = {
399+
app: {} as FirebaseApp,
400+
auth: {} as Auth,
401+
};
402+
403+
const ui = initializeUI(config);
404+
const mockError = new Error("Test redirect error");
405+
406+
expect(ui.get().redirectError).toBeUndefined();
407+
ui.get().setRedirectError(mockError);
408+
expect(ui.get().redirectError).toBe(mockError);
409+
ui.get().setRedirectError(undefined);
410+
expect(ui.get().redirectError).toBeUndefined();
411+
});
412+
413+
it("should update redirectError multiple times", () => {
414+
const config = {
415+
app: {} as FirebaseApp,
416+
auth: {} as Auth,
417+
};
418+
419+
const ui = initializeUI(config);
420+
const mockError1 = new Error("First error");
421+
const mockError2 = new Error("Second error");
422+
423+
ui.get().setRedirectError(mockError1);
424+
expect(ui.get().redirectError).toBe(mockError1);
425+
ui.get().setRedirectError(mockError2);
426+
expect(ui.get().redirectError).toBe(mockError2);
427+
ui.get().setRedirectError(undefined);
428+
expect(ui.get().redirectError).toBeUndefined();
429+
});
430+
431+
it("should handle redirect error when getRedirectResult throws", async () => {
432+
Object.defineProperty(global, "window", {
433+
value: {},
434+
writable: true,
435+
configurable: true,
436+
});
437+
438+
const mockAuth = {
439+
currentUser: null,
440+
} as any;
441+
442+
const mockError = new Error("Redirect failed");
443+
const { getRedirectResult } = await import("firebase/auth");
444+
vi.mocked(getRedirectResult).mockClear();
445+
vi.mocked(getRedirectResult).mockRejectedValue(mockError);
446+
447+
const config = {
448+
app: {} as FirebaseApp,
449+
auth: mockAuth,
450+
};
451+
452+
const ui = initializeUI(config);
453+
454+
// Process next tick to make sure the promise is resolved
455+
await new Promise((resolve) => setTimeout(resolve, 0));
456+
457+
expect(getRedirectResult).toHaveBeenCalledTimes(1);
458+
expect(getRedirectResult).toHaveBeenCalledWith(mockAuth);
459+
expect(ui.get().redirectError).toBe(mockError);
460+
461+
delete (global as any).window;
462+
});
463+
464+
it("should convert non-Error objects to Error instances in redirect catch", async () => {
465+
Object.defineProperty(global, "window", {
466+
value: {},
467+
writable: true,
468+
configurable: true,
469+
});
470+
471+
const mockAuth = {
472+
currentUser: null,
473+
} as any;
474+
475+
const { getRedirectResult } = await import("firebase/auth");
476+
vi.mocked(getRedirectResult).mockClear();
477+
vi.mocked(getRedirectResult).mockRejectedValue("String error");
478+
479+
const config = {
480+
app: {} as FirebaseApp,
481+
auth: mockAuth,
482+
};
483+
484+
const ui = initializeUI(config);
485+
486+
// Process next tick to make sure the promise is resolved
487+
await new Promise((resolve) => setTimeout(resolve, 0));
488+
489+
expect(getRedirectResult).toHaveBeenCalledTimes(1);
490+
expect(ui.get().redirectError).toBeInstanceOf(Error);
491+
expect(ui.get().redirectError?.message).toBe("String error");
492+
493+
delete (global as any).window;
494+
});
386495
});

0 commit comments

Comments
 (0)