Skip to content

Commit 2d6882e

Browse files
committed
feat(react): MFA TOTP assertion flow
1 parent 32b3a41 commit 2d6882e

File tree

3 files changed

+304
-10
lines changed

3 files changed

+304
-10
lines changed
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
19+
import { render, screen, renderHook, cleanup } from "@testing-library/react";
20+
import {
21+
TotpMultiFactorAssertionForm,
22+
useTotpMultiFactorAssertionFormAction,
23+
} from "./totp-multi-factor-assertion-form";
24+
import { act } from "react";
25+
import { signInWithMultiFactorAssertion } from "@firebase-ui/core";
26+
import { createFirebaseUIProvider, createMockUI } from "~/tests/utils";
27+
import { registerLocale } from "@firebase-ui/translations";
28+
import { TotpMultiFactorGenerator } 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+
signInWithMultiFactorAssertion: vi.fn(),
35+
};
36+
});
37+
38+
vi.mock("firebase/auth", async (importOriginal) => {
39+
const mod = await importOriginal<typeof import("firebase/auth")>();
40+
return {
41+
...mod,
42+
TotpMultiFactorGenerator: {
43+
assertionForSignIn: vi.fn(),
44+
},
45+
};
46+
});
47+
48+
describe("useTotpMultiFactorAssertionFormAction", () => {
49+
beforeEach(() => {
50+
vi.clearAllMocks();
51+
});
52+
53+
it("should return a function", () => {
54+
const mockUI = createMockUI();
55+
const { result } = renderHook(() => useTotpMultiFactorAssertionFormAction(), {
56+
wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }),
57+
});
58+
59+
expect(typeof result.current).toBe("function");
60+
});
61+
62+
it("should call TotpMultiFactorGenerator.assertionForSignIn and signInWithMultiFactorAssertion", async () => {
63+
const mockUI = createMockUI();
64+
const mockAssertion = { assertion: true };
65+
const signInWithMultiFactorAssertionMock = vi.mocked(signInWithMultiFactorAssertion);
66+
const mockHint = {
67+
factorId: "totp" as const,
68+
uid: "test-uid",
69+
enrollmentTime: "2023-01-01T00:00:00Z",
70+
};
71+
72+
vi.mocked(TotpMultiFactorGenerator.assertionForSignIn).mockReturnValue(mockAssertion as any);
73+
74+
const { result } = renderHook(() => useTotpMultiFactorAssertionFormAction(), {
75+
wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }),
76+
});
77+
78+
await act(async () => {
79+
await result.current({ verificationCode: "123456", hint: mockHint });
80+
});
81+
82+
expect(TotpMultiFactorGenerator.assertionForSignIn).toHaveBeenCalledWith("test-uid", "123456");
83+
expect(signInWithMultiFactorAssertionMock).toHaveBeenCalledWith(expect.any(Object), mockAssertion);
84+
});
85+
});
86+
87+
describe("<TotpMultiFactorAssertionForm />", () => {
88+
beforeEach(() => {
89+
vi.clearAllMocks();
90+
});
91+
92+
afterEach(() => {
93+
cleanup();
94+
});
95+
96+
it("should render the form correctly", () => {
97+
const mockUI = createMockUI({
98+
locale: registerLocale("test", {
99+
labels: {
100+
verificationCode: "verificationCode",
101+
verifyCode: "verifyCode",
102+
},
103+
}),
104+
});
105+
106+
const mockHint = {
107+
factorId: "totp" as const,
108+
uid: "test-uid",
109+
enrollmentTime: "2023-01-01T00:00:00Z",
110+
};
111+
112+
const { container } = render(
113+
createFirebaseUIProvider({
114+
children: <TotpMultiFactorAssertionForm hint={mockHint} />,
115+
ui: mockUI,
116+
})
117+
);
118+
119+
const form = container.querySelectorAll("form.fui-form");
120+
expect(form.length).toBe(1);
121+
122+
expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument();
123+
124+
const verifyCodeButton = screen.getByRole("button", { name: "verifyCode" });
125+
expect(verifyCodeButton).toBeInTheDocument();
126+
expect(verifyCodeButton).toHaveAttribute("type", "submit");
127+
});
128+
129+
it("should accept onSuccess callback prop", () => {
130+
const mockUI = createMockUI({
131+
locale: registerLocale("test", {
132+
labels: {
133+
verificationCode: "verificationCode",
134+
},
135+
}),
136+
});
137+
138+
const mockHint = {
139+
factorId: "totp" as const,
140+
uid: "test-uid",
141+
enrollmentTime: "2023-01-01T00:00:00Z",
142+
};
143+
const onSuccessMock = vi.fn();
144+
145+
expect(() => {
146+
render(
147+
createFirebaseUIProvider({
148+
children: <TotpMultiFactorAssertionForm hint={mockHint} onSuccess={onSuccessMock} />,
149+
ui: mockUI,
150+
})
151+
);
152+
}).not.toThrow();
153+
});
154+
155+
it("should render form elements correctly", () => {
156+
const mockUI = createMockUI({
157+
locale: registerLocale("test", {
158+
labels: {
159+
verificationCode: "verificationCode",
160+
verifyCode: "verifyCode",
161+
},
162+
}),
163+
});
164+
165+
const mockHint = {
166+
factorId: "totp" as const,
167+
uid: "test-uid",
168+
enrollmentTime: "2023-01-01T00:00:00Z",
169+
};
170+
171+
render(
172+
createFirebaseUIProvider({
173+
children: <TotpMultiFactorAssertionForm hint={mockHint} />,
174+
ui: mockUI,
175+
})
176+
);
177+
178+
expect(screen.getByRole("textbox", { name: /verificationCode/i })).toBeInTheDocument();
179+
expect(screen.getByRole("button", { name: "verifyCode" })).toBeInTheDocument();
180+
});
181+
182+
it("should render input field for TOTP code", () => {
183+
const mockUI = createMockUI({
184+
locale: registerLocale("test", {
185+
labels: {
186+
verificationCode: "verificationCode",
187+
},
188+
}),
189+
});
190+
191+
const mockHint = {
192+
factorId: "totp" as const,
193+
uid: "test-uid",
194+
enrollmentTime: "2023-01-01T00:00:00Z",
195+
};
196+
197+
render(
198+
createFirebaseUIProvider({
199+
children: <TotpMultiFactorAssertionForm hint={mockHint} />,
200+
ui: mockUI,
201+
})
202+
);
203+
204+
const input = screen.getByRole("textbox", { name: /verificationCode/i });
205+
expect(input).toBeInTheDocument();
206+
});
207+
});
Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,90 @@
1-
export function TotpMultiFactorAssertionForm() {
2-
return <div>TODO: TotpMultiFactorAssertionForm</div>;
1+
import { useCallback } from "react";
2+
import { TotpMultiFactorGenerator, type MultiFactorInfo } from "firebase/auth";
3+
import { signInWithMultiFactorAssertion, FirebaseUIError, getTranslation } from "@firebase-ui/core";
4+
import { form } from "~/components/form";
5+
import { useMultiFactorTotpAuthVerifyFormSchema, useUI } from "~/hooks";
6+
7+
export function useTotpMultiFactorAssertionFormAction() {
8+
const ui = useUI();
9+
10+
return useCallback(
11+
async ({ verificationCode, hint }: { verificationCode: string; hint: MultiFactorInfo }) => {
12+
const assertion = TotpMultiFactorGenerator.assertionForSignIn(hint.uid, verificationCode);
13+
return await signInWithMultiFactorAssertion(ui, assertion);
14+
},
15+
[ui]
16+
);
17+
}
18+
19+
type UseTotpMultiFactorAssertionForm = {
20+
hint: MultiFactorInfo;
21+
onSuccess: () => void;
22+
};
23+
24+
export function useTotpMultiFactorAssertionForm({ hint, onSuccess }: UseTotpMultiFactorAssertionForm) {
25+
const action = useTotpMultiFactorAssertionFormAction();
26+
const schema = useMultiFactorTotpAuthVerifyFormSchema();
27+
28+
return form.useAppForm({
29+
defaultValues: {
30+
verificationCode: "",
31+
},
32+
validators: {
33+
onSubmit: schema,
34+
onBlur: schema,
35+
onSubmitAsync: async ({ value }) => {
36+
try {
37+
await action({ verificationCode: value.verificationCode, hint });
38+
return onSuccess();
39+
} catch (error) {
40+
return error instanceof FirebaseUIError ? error.message : String(error);
41+
}
42+
},
43+
},
44+
});
45+
}
46+
47+
type TotpMultiFactorAssertionFormProps = {
48+
hint: MultiFactorInfo;
49+
onSuccess?: () => void;
50+
};
51+
52+
export function TotpMultiFactorAssertionForm(props: TotpMultiFactorAssertionFormProps) {
53+
const ui = useUI();
54+
const form = useTotpMultiFactorAssertionForm({
55+
hint: props.hint,
56+
onSuccess: () => {
57+
props.onSuccess?.();
58+
},
59+
});
60+
61+
return (
62+
<form
63+
className="fui-form"
64+
onSubmit={async (e) => {
65+
e.preventDefault();
66+
e.stopPropagation();
67+
await form.handleSubmit();
68+
}}
69+
>
70+
<form.AppForm>
71+
<fieldset>
72+
<form.AppField name="verificationCode">
73+
{(field) => (
74+
<field.Input
75+
label={getTranslation(ui, "labels", "verificationCode")}
76+
type="text"
77+
placeholder="123456"
78+
maxLength={6}
79+
/>
80+
)}
81+
</form.AppField>
82+
</fieldset>
83+
<fieldset>
84+
<form.SubmitButton>{getTranslation(ui, "labels", "verifyCode")}</form.SubmitButton>
85+
<form.ErrorMessage />
86+
</fieldset>
87+
</form.AppForm>
88+
</form>
89+
);
390
}

packages/react/src/auth/forms/multi-factor-auth-assertion-form.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,17 @@ export function MultiFactorAuthAssertionForm() {
1515
}
1616

1717
// If only a single hint is provided, select it by default to improve UX.
18-
const [factor, setFactor] = useState<MultiFactorInfo | undefined>(
18+
const [hint, setHint] = useState<MultiFactorInfo | undefined>(
1919
resolver.hints.length === 1 ? resolver.hints[0] : undefined
2020
);
2121

22-
if (factor) {
23-
if (factor.factorId === PhoneMultiFactorGenerator.FACTOR_ID) {
24-
return <SmsMultiFactorAssertionForm hint={factor} />;
22+
if (hint) {
23+
if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) {
24+
return <SmsMultiFactorAssertionForm hint={hint} />;
2525
}
2626

27-
if (factor.factorId === TotpMultiFactorGenerator.FACTOR_ID) {
28-
return <TotpMultiFactorAssertionForm />;
27+
if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) {
28+
return <TotpMultiFactorAssertionForm hint={hint} />;
2929
}
3030
}
3131

@@ -34,11 +34,11 @@ export function MultiFactorAuthAssertionForm() {
3434
<p>TODO: Select a multi-factor authentication method</p>
3535
{resolver.hints.map((hint) => {
3636
if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) {
37-
return <TotpButton key={hint.factorId} onClick={() => setFactor(hint)} />;
37+
return <TotpButton key={hint.factorId} onClick={() => setHint(hint)} />;
3838
}
3939

4040
if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) {
41-
return <SmsButton key={hint.factorId} onClick={() => setFactor(hint)} />;
41+
return <SmsButton key={hint.factorId} onClick={() => setHint(hint)} />;
4242
}
4343

4444
return null;

0 commit comments

Comments
 (0)