Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions examples/react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ function App() {
Password Reset Screen
</NavLink>
</li>
<li>
<NavLink to="/screens/mfa-enrollment-screen" className="text-blue-500 hover:underline">
MFA Enrollment Screen
</NavLink>
</li>
</ul>
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions examples/react/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ import OAuthScreenPage from "./screens/oauth-screen";
/** Password Reset */
import ForgotPasswordPage from "./screens/forgot-password-screen";

/** MFA Enrollment */
import MultiFactorAuthEnrollmentScreenPage from "./screens/mfa-enrollment-screen";

const root = document.getElementById("root")!;

ReactDOM.createRoot(root).render(
Expand All @@ -72,6 +75,7 @@ ReactDOM.createRoot(root).render(
<Route path="/screens/sign-up-auth-screen-w-oauth" element={<SignUpAuthScreenWithOAuthPage />} />
<Route path="/screens/oauth-screen" element={<OAuthScreenPage />} />
<Route path="/screens/forgot-password-screen" element={<ForgotPasswordPage />} />
<Route path="/screens/mfa-enrollment-screen" element={<MultiFactorAuthEnrollmentScreenPage />} />
</Routes>
</FirebaseUIProvider>
</BrowserRouter>
Expand Down
31 changes: 31 additions & 0 deletions examples/react/src/screens/mfa-enrollment-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Copyright 2025 Google LLC
*
* 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.
*/

"use client";

import { MultiFactorAuthEnrollmentScreen } from "@firebase-ui/react";
import { FactorId } from "firebase/auth";

export default function MultiFactorAuthEnrollmentScreenPage() {
return (
<MultiFactorAuthEnrollmentScreen
hints={[FactorId.TOTP, FactorId.PHONE]}
onEnrollment={() => {
console.log("Enrollment successful");
}}
/>
);
}
103 changes: 89 additions & 14 deletions packages/core/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ import {
EmailAuthProvider,
linkWithCredential,
PhoneAuthProvider,
TotpMultiFactorGenerator,
multiFactor,
type ActionCodeSettings,
type ApplicationVerifier,
type AuthProvider,
type UserCredential,
type AuthCredential,
type TotpSecret,
type PhoneInfoOptions,
type MultiFactorAssertion,
type MultiFactorUser,
type MultiFactorInfo,
} from "firebase/auth";
import QRCode from "qrcode-generator";
import { type FirebaseUI } from "./config";
Expand All @@ -54,13 +58,18 @@ async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): P
}
}

function setPendingState(ui: FirebaseUI) {
ui.setRedirectError(undefined);
ui.setState("pending");
}

export async function signInWithEmailAndPassword(
ui: FirebaseUI,
email: string,
password: string
): Promise<UserCredential> {
try {
ui.setState("pending");
setPendingState(ui);
const credential = EmailAuthProvider.credential(email, password);

if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) {
Expand All @@ -87,7 +96,7 @@ export async function createUserWithEmailAndPassword(
displayName?: string
): Promise<UserCredential> {
try {
ui.setState("pending");
setPendingState(ui);
const credential = EmailAuthProvider.credential(email, password);

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

export async function verifyPhoneNumber(
ui: FirebaseUI,
phoneNumber: PhoneInfoOptions | string,
appVerifier: ApplicationVerifier
phoneNumber: string,
appVerifier: ApplicationVerifier,
mfaUser?: MultiFactorUser,
mfaHint?: MultiFactorInfo
): Promise<string> {
try {
ui.setState("pending");
setPendingState(ui);
const provider = new PhoneAuthProvider(ui.auth);
return await provider.verifyPhoneNumber(phoneNumber, appVerifier);

if (mfaHint && ui.multiFactorResolver) {
// MFA assertion flow
return await provider.verifyPhoneNumber(
{
multiFactorHint: mfaHint,
session: ui.multiFactorResolver.session,
},
appVerifier
);
} else if (mfaUser) {
// MFA enrollment flow
const session = await mfaUser.getSession();
return await provider.verifyPhoneNumber(
{
phoneNumber,
session,
},
appVerifier
);
} else {
// Regular phone auth flow
return await provider.verifyPhoneNumber(phoneNumber, appVerifier);
}
} catch (error) {
handleFirebaseError(ui, error);
} finally {
Expand All @@ -142,7 +176,7 @@ export async function confirmPhoneNumber(
verificationCode: string
): Promise<UserCredential> {
try {
ui.setState("pending");
setPendingState(ui);
const currentUser = ui.auth.currentUser;
const credential = PhoneAuthProvider.credential(verificationId, verificationCode);

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

export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Promise<void> {
try {
ui.setState("pending");
setPendingState(ui);
await _sendPasswordResetEmail(ui.auth, email);
} catch (error) {
handleFirebaseError(ui, error);
Expand All @@ -176,7 +210,7 @@ export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Pro

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

export async function signInWithCredential(ui: FirebaseUI, credential: AuthCredential): Promise<UserCredential> {
try {
ui.setState("pending");
setPendingState(ui);
if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) {
const userCredential = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential);

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

export async function signInAnonymously(ui: FirebaseUI): Promise<UserCredential> {
try {
ui.setState("pending");
setPendingState(ui);
const result = await _signInAnonymously(ui.auth);
return handlePendingCredential(ui, result);
} catch (error) {
Expand All @@ -234,7 +268,7 @@ export async function signInAnonymously(ui: FirebaseUI): Promise<UserCredential>

export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider): Promise<UserCredential | never> {
try {
ui.setState("pending");
setPendingState(ui);
if (hasBehavior(ui, "autoUpgradeAnonymousProvider")) {
const credential = await getBehavior(ui, "autoUpgradeAnonymousProvider")(ui, provider);

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

ui.setState("pending");
setPendingState(ui);
const result = await signInWithEmailLink(ui, email, currentUrl);
return handlePendingCredential(ui, result);
} catch (error) {
Expand All @@ -292,3 +326,44 @@ export function generateTotpQrCode(ui: FirebaseUI, secret: TotpSecret, accountNa
qr.make();
return qr.createDataURL();
}

export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: MultiFactorAssertion) {
try {
setPendingState(ui);
const result = await ui.multiFactorResolver?.resolveSignIn(assertion);
ui.setMultiFactorResolver(undefined);
return result;
} catch (error) {
handleFirebaseError(ui, error);
} finally {
ui.setState("idle");
}
}

export async function enrollWithMultiFactorAssertion(
ui: FirebaseUI,
assertion: MultiFactorAssertion,
displayName?: string
): Promise<void> {
try {
setPendingState(ui);
await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName);
} catch (error) {
handleFirebaseError(ui, error);
} finally {
ui.setState("idle");
}
}

export async function generateTotpSecret(ui: FirebaseUI): Promise<TotpSecret> {
try {
setPendingState(ui);
const mfaUser = multiFactor(ui.auth.currentUser!);
const session = await mfaUser.getSession();
return await TotpMultiFactorGenerator.generateSecret(session);
} catch (error) {
handleFirebaseError(ui, error);
} finally {
ui.setState("idle");
}
}
109 changes: 109 additions & 0 deletions packages/core/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,4 +383,113 @@ describe("initializeUI", () => {
ui.get().setMultiFactorResolver(undefined);
expect(ui.get().multiFactorResolver).toBeUndefined();
});

it("should have redirectError undefined by default", () => {
const config = {
app: {} as FirebaseApp,
auth: {} as Auth,
};

const ui = initializeUI(config);
expect(ui.get().redirectError).toBeUndefined();
});

it("should set and get redirectError correctly", () => {
const config = {
app: {} as FirebaseApp,
auth: {} as Auth,
};

const ui = initializeUI(config);
const mockError = new Error("Test redirect error");

expect(ui.get().redirectError).toBeUndefined();
ui.get().setRedirectError(mockError);
expect(ui.get().redirectError).toBe(mockError);
ui.get().setRedirectError(undefined);
expect(ui.get().redirectError).toBeUndefined();
});

it("should update redirectError multiple times", () => {
const config = {
app: {} as FirebaseApp,
auth: {} as Auth,
};

const ui = initializeUI(config);
const mockError1 = new Error("First error");
const mockError2 = new Error("Second error");

ui.get().setRedirectError(mockError1);
expect(ui.get().redirectError).toBe(mockError1);
ui.get().setRedirectError(mockError2);
expect(ui.get().redirectError).toBe(mockError2);
ui.get().setRedirectError(undefined);
expect(ui.get().redirectError).toBeUndefined();
});

it("should handle redirect error when getRedirectResult throws", async () => {
Object.defineProperty(global, "window", {
value: {},
writable: true,
configurable: true,
});

const mockAuth = {
currentUser: null,
} as any;

const mockError = new Error("Redirect failed");
const { getRedirectResult } = await import("firebase/auth");
vi.mocked(getRedirectResult).mockClear();
vi.mocked(getRedirectResult).mockRejectedValue(mockError);

const config = {
app: {} as FirebaseApp,
auth: mockAuth,
};

const ui = initializeUI(config);

// Process next tick to make sure the promise is resolved
await new Promise((resolve) => setTimeout(resolve, 0));

expect(getRedirectResult).toHaveBeenCalledTimes(1);
expect(getRedirectResult).toHaveBeenCalledWith(mockAuth);
expect(ui.get().redirectError).toBe(mockError);

delete (global as any).window;
});

it("should convert non-Error objects to Error instances in redirect catch", async () => {
Object.defineProperty(global, "window", {
value: {},
writable: true,
configurable: true,
});

const mockAuth = {
currentUser: null,
} as any;

const { getRedirectResult } = await import("firebase/auth");
vi.mocked(getRedirectResult).mockClear();
vi.mocked(getRedirectResult).mockRejectedValue("String error");

const config = {
app: {} as FirebaseApp,
auth: mockAuth,
};

const ui = initializeUI(config);

// Process next tick to make sure the promise is resolved
await new Promise((resolve) => setTimeout(resolve, 0));

expect(getRedirectResult).toHaveBeenCalledTimes(1);
expect(ui.get().redirectError).toBeInstanceOf(Error);
expect(ui.get().redirectError?.message).toBe("String error");

delete (global as any).window;
});
});
Loading