Skip to content

Commit 32b3a41

Browse files
committed
feat(react): MFA Assertion
1 parent db6e928 commit 32b3a41

File tree

5 files changed

+548
-16
lines changed

5 files changed

+548
-16
lines changed

packages/core/src/auth.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
type TotpSecret,
3535
type MultiFactorAssertion,
3636
type MultiFactorUser,
37+
type MultiFactorInfo,
3738
} from "firebase/auth";
3839
import QRCode from "qrcode-generator";
3940
import { type FirebaseUI } from "./config";
@@ -132,21 +133,36 @@ export async function verifyPhoneNumber(
132133
ui: FirebaseUI,
133134
phoneNumber: string,
134135
appVerifier: ApplicationVerifier,
135-
mfaUser?: MultiFactorUser
136+
mfaUser?: MultiFactorUser,
137+
mfaHint?: MultiFactorInfo
136138
): Promise<string> {
137139
try {
138140
setPendingState(ui);
139141
const provider = new PhoneAuthProvider(ui.auth);
140-
const session = await mfaUser?.getSession();
141-
return await provider.verifyPhoneNumber(
142-
session
143-
? {
144-
phoneNumber,
145-
session,
146-
}
147-
: phoneNumber,
148-
appVerifier
149-
);
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+
}
150166
} catch (error) {
151167
handleFirebaseError(ui, error);
152168
} finally {
@@ -312,8 +328,16 @@ export function generateTotpQrCode(ui: FirebaseUI, secret: TotpSecret, accountNa
312328
}
313329

314330
export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: MultiFactorAssertion) {
315-
await ui.multiFactorResolver?.resolveSignIn(assertion);
316-
throw new Error("Not implemented");
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+
}
317341
}
318342

319343
export async function enrollWithMultiFactorAssertion(
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
18+
import { render, screen, renderHook, cleanup } from "@testing-library/react";
19+
import {
20+
SmsMultiFactorAssertionForm,
21+
useSmsMultiFactorAssertionPhoneFormAction,
22+
useSmsMultiFactorAssertionVerifyFormAction,
23+
} from "./sms-multi-factor-assertion-form";
24+
import { act } from "react";
25+
import { verifyPhoneNumber, signInWithMultiFactorAssertion } from "@firebase-ui/core";
26+
import { createFirebaseUIProvider, createMockUI } from "~/tests/utils";
27+
import { registerLocale } from "@firebase-ui/translations";
28+
import { PhoneAuthProvider, PhoneMultiFactorGenerator } from "firebase/auth";
29+
30+
vi.mock("@firebase-ui/core", async (importOriginal) => {
31+
const mod = await importOriginal<typeof import("@firebase-ui/core")>();
32+
return {
33+
...mod,
34+
verifyPhoneNumber: vi.fn(),
35+
signInWithMultiFactorAssertion: vi.fn(),
36+
};
37+
});
38+
39+
vi.mock("firebase/auth", async (importOriginal) => {
40+
const mod = await importOriginal<typeof import("firebase/auth")>();
41+
return {
42+
...mod,
43+
PhoneAuthProvider: {
44+
credential: vi.fn(),
45+
},
46+
PhoneMultiFactorGenerator: {
47+
assertion: vi.fn(),
48+
},
49+
};
50+
});
51+
52+
vi.mock("~/hooks", async (importOriginal) => {
53+
const mod = await importOriginal<typeof import("~/hooks")>();
54+
return {
55+
...mod,
56+
useRecaptchaVerifier: vi.fn().mockReturnValue({
57+
render: vi.fn(),
58+
clear: vi.fn(),
59+
verify: vi.fn(),
60+
}),
61+
};
62+
});
63+
64+
describe("useSmsMultiFactorAssertionPhoneFormAction", () => {
65+
beforeEach(() => {
66+
vi.clearAllMocks();
67+
});
68+
69+
it("should return a function", () => {
70+
const mockUI = createMockUI();
71+
const { result } = renderHook(() => useSmsMultiFactorAssertionPhoneFormAction(), {
72+
wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }),
73+
});
74+
75+
expect(typeof result.current).toBe("function");
76+
});
77+
78+
it("should call verifyPhoneNumber with correct parameters", async () => {
79+
const verifyPhoneNumberMock = vi.mocked(verifyPhoneNumber);
80+
const mockUI = createMockUI();
81+
const mockRecaptchaVerifier = { render: vi.fn(), clear: vi.fn(), verify: vi.fn() };
82+
const mockHint = {
83+
factorId: "phone" as const,
84+
phoneNumber: "+1234567890",
85+
uid: "test-uid",
86+
enrollmentTime: "2023-01-01T00:00:00Z",
87+
};
88+
89+
const { result } = renderHook(() => useSmsMultiFactorAssertionPhoneFormAction(), {
90+
wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }),
91+
});
92+
93+
await act(async () => {
94+
await result.current({ hint: mockHint, recaptchaVerifier: mockRecaptchaVerifier as any });
95+
});
96+
97+
expect(verifyPhoneNumberMock).toHaveBeenCalledWith(
98+
expect.any(Object), // UI object
99+
"", // empty phone number
100+
mockRecaptchaVerifier,
101+
undefined, // no mfaUser
102+
mockHint // mfaHint
103+
);
104+
});
105+
});
106+
107+
describe("useSmsMultiFactorAssertionVerifyFormAction", () => {
108+
beforeEach(() => {
109+
vi.clearAllMocks();
110+
});
111+
112+
it("should return a function", () => {
113+
const mockUI = createMockUI();
114+
const { result } = renderHook(() => useSmsMultiFactorAssertionVerifyFormAction(), {
115+
wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }),
116+
});
117+
118+
expect(typeof result.current).toBe("function");
119+
});
120+
121+
it("should call PhoneAuthProvider.credential and PhoneMultiFactorGenerator.assertion", async () => {
122+
const mockUI = createMockUI();
123+
const mockCredential = { credential: true };
124+
const mockAssertion = { assertion: true };
125+
126+
vi.mocked(PhoneAuthProvider.credential).mockReturnValue(mockCredential as any);
127+
vi.mocked(PhoneMultiFactorGenerator.assertion).mockReturnValue(mockAssertion as any);
128+
129+
const { result } = renderHook(() => useSmsMultiFactorAssertionVerifyFormAction(), {
130+
wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }),
131+
});
132+
133+
await act(async () => {
134+
await result.current({ verificationId: "test-verification-id", verificationCode: "123456" });
135+
});
136+
137+
expect(PhoneAuthProvider.credential).toHaveBeenCalledWith("test-verification-id", "123456");
138+
expect(PhoneMultiFactorGenerator.assertion).toHaveBeenCalledWith(mockCredential);
139+
});
140+
141+
it("should call signInWithMultiFactorAssertion with correct parameters", async () => {
142+
const signInWithMultiFactorAssertionMock = vi.mocked(signInWithMultiFactorAssertion);
143+
const mockUI = createMockUI();
144+
const mockCredential = { credential: true };
145+
const mockAssertion = { assertion: true };
146+
147+
vi.mocked(PhoneAuthProvider.credential).mockReturnValue(mockCredential as any);
148+
vi.mocked(PhoneMultiFactorGenerator.assertion).mockReturnValue(mockAssertion as any);
149+
150+
const { result } = renderHook(() => useSmsMultiFactorAssertionVerifyFormAction(), {
151+
wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }),
152+
});
153+
154+
await act(async () => {
155+
await result.current({ verificationId: "test-verification-id", verificationCode: "123456" });
156+
});
157+
158+
expect(signInWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion);
159+
});
160+
});
161+
162+
describe("<SmsMultiFactorAssertionForm />", () => {
163+
beforeEach(() => {
164+
vi.clearAllMocks();
165+
});
166+
167+
afterEach(() => {
168+
cleanup();
169+
});
170+
171+
it("should render the phone form initially", () => {
172+
const mockUI = createMockUI({
173+
locale: registerLocale("test", {
174+
labels: {
175+
sendCode: "sendCode",
176+
phoneNumber: "phoneNumber",
177+
},
178+
}),
179+
});
180+
181+
const mockHint = {
182+
factorId: "phone" as const,
183+
phoneNumber: "+1234567890",
184+
uid: "test-uid",
185+
enrollmentTime: "2023-01-01T00:00:00Z",
186+
};
187+
188+
const { container } = render(
189+
createFirebaseUIProvider({
190+
children: <SmsMultiFactorAssertionForm hint={mockHint} />,
191+
ui: mockUI,
192+
})
193+
);
194+
195+
const form = container.querySelectorAll("form.fui-form");
196+
expect(form.length).toBe(1);
197+
198+
expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toBeInTheDocument();
199+
expect(screen.getByRole("textbox", { name: /phoneNumber/i })).toHaveValue("+1234567890");
200+
201+
const sendCodeButton = screen.getByRole("button", { name: "sendCode" });
202+
expect(sendCodeButton).toBeInTheDocument();
203+
expect(sendCodeButton).toHaveAttribute("type", "submit");
204+
205+
expect(container.querySelector(".fui-recaptcha-container")).toBeInTheDocument();
206+
});
207+
208+
it("should display phone number from hint", () => {
209+
const mockUI = createMockUI({
210+
locale: registerLocale("test", {
211+
labels: {
212+
phoneNumber: "phoneNumber",
213+
},
214+
}),
215+
});
216+
217+
const mockHint = {
218+
factorId: "phone" as const,
219+
phoneNumber: "+1234567890",
220+
uid: "test-uid",
221+
enrollmentTime: "2023-01-01T00:00:00Z",
222+
};
223+
224+
render(
225+
createFirebaseUIProvider({
226+
children: <SmsMultiFactorAssertionForm hint={mockHint} />,
227+
ui: mockUI,
228+
})
229+
);
230+
231+
const phoneInput = screen.getByRole("textbox", { name: /phoneNumber/i });
232+
expect(phoneInput).toHaveValue("+1234567890");
233+
});
234+
235+
it("should handle missing phone number in hint", () => {
236+
const mockUI = createMockUI({
237+
locale: registerLocale("test", {
238+
labels: {
239+
phoneNumber: "phoneNumber",
240+
},
241+
}),
242+
});
243+
244+
const mockHint = {
245+
factorId: "phone" as const,
246+
uid: "test-uid",
247+
enrollmentTime: "2023-01-01T00:00:00Z",
248+
};
249+
250+
render(
251+
createFirebaseUIProvider({
252+
children: <SmsMultiFactorAssertionForm hint={mockHint} />,
253+
ui: mockUI,
254+
})
255+
);
256+
257+
const phoneInput = screen.getByRole("textbox", { name: /phoneNumber/i });
258+
expect(phoneInput).toHaveValue("");
259+
});
260+
261+
it("should accept onSuccess callback prop", () => {
262+
const mockUI = createMockUI({
263+
locale: registerLocale("test", {
264+
labels: {
265+
phoneNumber: "phoneNumber",
266+
},
267+
}),
268+
});
269+
270+
const mockHint = {
271+
factorId: "phone" as const,
272+
phoneNumber: "+1234567890",
273+
uid: "test-uid",
274+
enrollmentTime: "2023-01-01T00:00:00Z",
275+
};
276+
const onSuccessMock = vi.fn();
277+
278+
expect(() => {
279+
render(
280+
createFirebaseUIProvider({
281+
children: <SmsMultiFactorAssertionForm hint={mockHint} onSuccess={onSuccessMock} />,
282+
ui: mockUI,
283+
})
284+
);
285+
}).not.toThrow();
286+
});
287+
});

0 commit comments

Comments
 (0)