Skip to content

Commit b96aa7e

Browse files
committed
feat(core): Allow MFA Resolver to be set when the multi-factor-auth-required error is thrown
1 parent f0f9414 commit b96aa7e

File tree

5 files changed

+143
-19
lines changed

5 files changed

+143
-19
lines changed

packages/core/src/config.test.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FirebaseApp } from "firebase/app";
2-
import { Auth } from "firebase/auth";
2+
import { Auth, MultiFactorResolver } from "firebase/auth";
33
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
44
import { initializeUI } from "./config";
55
import { enUs, registerLocale } from "@firebase-ui/translations";
@@ -121,9 +121,6 @@ describe("initializeUI", () => {
121121
const ui = initializeUI(config);
122122
expect(ui.get().behaviors).toHaveProperty("recaptchaVerification");
123123
expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable");
124-
expect(ui.get().behaviors.recaptchaVerification.handler).toBe(
125-
customRecaptchaVerification.recaptchaVerification.handler
126-
);
127124
});
128125

129126
it("should merge multiple behavior objects correctly", () => {
@@ -152,8 +149,6 @@ describe("initializeUI", () => {
152149
expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential");
153150
expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider");
154151
expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler");
155-
156-
expect(ui.get().behaviors.recaptchaVerification.handler).toBe(behavior2.recaptchaVerification.handler);
157152
});
158153

159154
it("should handle init behaviors correctly", () => {
@@ -331,4 +326,61 @@ describe("initializeUI", () => {
331326

332327
expect(ui.get().state).toBe("idle");
333328
});
329+
330+
it("should have multiFactorResolver undefined by default", () => {
331+
const config = {
332+
app: {} as FirebaseApp,
333+
auth: {} as Auth,
334+
};
335+
336+
const ui = initializeUI(config);
337+
expect(ui.get().multiFactorResolver).toBeUndefined();
338+
});
339+
340+
it("should set and get multiFactorResolver correctly", () => {
341+
const config = {
342+
app: {} as FirebaseApp,
343+
auth: {} as Auth,
344+
};
345+
346+
const ui = initializeUI(config);
347+
const mockMultiFactorResolver = {
348+
auth: {} as Auth,
349+
session: null,
350+
hints: [],
351+
} as unknown as MultiFactorResolver;
352+
353+
expect(ui.get().multiFactorResolver).toBeUndefined();
354+
ui.get().setMultiFactorResolver(mockMultiFactorResolver);
355+
expect(ui.get().multiFactorResolver).toBe(mockMultiFactorResolver);
356+
ui.get().setMultiFactorResolver(undefined);
357+
expect(ui.get().multiFactorResolver).toBeUndefined();
358+
});
359+
360+
it("should update multiFactorResolver multiple times", () => {
361+
const config = {
362+
app: {} as FirebaseApp,
363+
auth: {} as Auth,
364+
};
365+
366+
const ui = initializeUI(config);
367+
const mockResolver1 = {
368+
auth: {} as Auth,
369+
session: null,
370+
hints: [],
371+
} as unknown as MultiFactorResolver;
372+
373+
const mockResolver2 = {
374+
auth: {} as Auth,
375+
session: null,
376+
hints: [],
377+
} as unknown as MultiFactorResolver;
378+
379+
ui.get().setMultiFactorResolver(mockResolver1);
380+
expect(ui.get().multiFactorResolver).toBe(mockResolver1);
381+
ui.get().setMultiFactorResolver(mockResolver2);
382+
expect(ui.get().multiFactorResolver).toBe(mockResolver2);
383+
ui.get().setMultiFactorResolver(undefined);
384+
expect(ui.get().multiFactorResolver).toBeUndefined();
385+
});
334386
});

packages/core/src/config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { enUs, RegisteredLocale } from "@firebase-ui/translations";
1818
import type { FirebaseApp } from "firebase/app";
19-
import { Auth, getAuth, getRedirectResult } from "firebase/auth";
19+
import { Auth, getAuth, getRedirectResult, MultiFactorResolver } from "firebase/auth";
2020
import { deepMap, DeepMapStore, map } from "nanostores";
2121
import { Behavior, Behaviors, defaultBehaviors } from "./behaviors";
2222
import type { InitBehavior, RedirectBehavior } from "./behaviors/utils";
@@ -37,6 +37,8 @@ export type FirebaseUIConfiguration = {
3737
setState: (state: FirebaseUIState) => void;
3838
locale: RegisteredLocale;
3939
behaviors: Behaviors;
40+
multiFactorResolver?: MultiFactorResolver;
41+
setMultiFactorResolver: (multiFactorResolver?: MultiFactorResolver) => void;
4042
};
4143

4244
export const $config = map<Record<string, DeepMapStore<FirebaseUIConfiguration>>>({});
@@ -70,6 +72,11 @@ export function initializeUI(config: FirebaseUIConfigurationOptions, name: strin
7072
// Since we've got config.behaviors?.reduce above, we need to default to defaultBehaviors
7173
// if no behaviors are provided, as they wont be in the reducer.
7274
behaviors: behaviors ?? (defaultBehaviors as Behavior),
75+
multiFactorResolver: undefined,
76+
setMultiFactorResolver: (resolver?: MultiFactorResolver) => {
77+
const current = $config.get()[name]!;
78+
current.setKey(`multiFactorResolver`, resolver);
79+
},
7380
})
7481
);
7582

packages/core/src/errors.test.ts

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { FirebaseError } from "firebase/app";
3-
import { AuthCredential } from "firebase/auth";
3+
import { Auth, AuthCredential, MultiFactorResolver } from "firebase/auth";
44
import { FirebaseUIError, handleFirebaseError } from "./errors";
55
import { createMockUI } from "~/tests/utils";
66
import { ERROR_CODE_MAP } from "@firebase-ui/translations";
77

8-
// Mock the translations module
98
vi.mock("./translations", () => ({
109
getTranslation: vi.fn(),
1110
}));
1211

12+
vi.mock("firebase/auth", () => ({
13+
getMultiFactorResolver: vi.fn(),
14+
}));
15+
1316
import { getTranslation } from "./translations";
17+
import { getMultiFactorResolver } from "firebase/auth";
1418

1519
let mockSessionStorage: { [key: string]: string };
1620

1721
beforeEach(() => {
1822
vi.clearAllMocks();
1923

20-
// Mock sessionStorage
2124
mockSessionStorage = {};
2225
Object.defineProperty(window, 'sessionStorage', {
2326
value: {
@@ -112,7 +115,6 @@ describe("handleFirebaseError", () => {
112115
try {
113116
handleFirebaseError(mockUI, mockFirebaseError);
114117
} catch (error) {
115-
// Should be an instance of both FirebaseUIError and FirebaseError
116118
expect(error).toBeInstanceOf(FirebaseUIError);
117119
expect(error).toBeInstanceOf(FirebaseError);
118120
expect((error as FirebaseUIError).code).toBe("auth/user-not-found");
@@ -168,17 +170,75 @@ describe("handleFirebaseError", () => {
168170
vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
169171

170172
expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError);
171-
172-
// Should not try to store credential if it doesn't exist
173173
expect(window.sessionStorage.setItem).not.toHaveBeenCalled();
174174
});
175+
176+
it("should call setMultiFactorResolver when auth/multi-factor-auth-required error is thrown", () => {
177+
const mockUI = createMockUI();
178+
const mockResolver = {
179+
auth: {} as Auth,
180+
session: null,
181+
hints: [],
182+
} as unknown as MultiFactorResolver;
183+
184+
const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required");
185+
const expectedTranslation = "Multi-factor authentication required (translated)";
186+
187+
vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
188+
vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver);
189+
190+
expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError);
191+
expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error);
192+
expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver);
193+
});
194+
195+
it("should still throw FirebaseUIError after setting multi-factor resolver", () => {
196+
const mockUI = createMockUI();
197+
const mockResolver = {
198+
auth: {} as Auth,
199+
session: null,
200+
hints: [],
201+
} as unknown as MultiFactorResolver;
202+
203+
const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required");
204+
const expectedTranslation = "Multi-factor authentication required (translated)";
205+
206+
vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
207+
vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver);
208+
209+
expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError);
210+
211+
expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error);
212+
expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver);
213+
214+
try {
215+
handleFirebaseError(mockUI, error);
216+
} catch (error) {
217+
expect(error).toBeInstanceOf(FirebaseUIError);
218+
expect(error).toBeInstanceOf(FirebaseError);
219+
expect((error as FirebaseUIError).code).toBe("auth/multi-factor-auth-required");
220+
expect((error as FirebaseUIError).message).toBe(expectedTranslation);
221+
}
222+
});
223+
224+
it("should not call setMultiFactorResolver for other error types", () => {
225+
const mockUI = createMockUI();
226+
const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found");
227+
const expectedTranslation = "User not found (translated)";
228+
229+
vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
230+
231+
expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError);
232+
233+
expect(getMultiFactorResolver).not.toHaveBeenCalled();
234+
expect(mockUI.setMultiFactorResolver).not.toHaveBeenCalled();
235+
});
175236
});
176237

177238
describe("isFirebaseError utility", () => {
178239
it("should identify FirebaseError objects", () => {
179240
const firebaseError = new FirebaseError("auth/user-not-found", "User not found");
180241

181-
// We can't directly test the private function, but we can test it through handleFirebaseError
182242
const mockUI = createMockUI();
183243
vi.mocked(getTranslation).mockReturnValue("translated message");
184244

@@ -187,7 +247,7 @@ describe("isFirebaseError utility", () => {
187247

188248
it("should reject non-FirebaseError objects", () => {
189249
const mockUI = createMockUI();
190-
const nonFirebaseError = { code: "test", message: "test" }; // Missing proper structure
250+
const nonFirebaseError = { code: "test", message: "test" };
191251

192252
expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow();
193253
});
@@ -218,7 +278,6 @@ describe("errorContainsCredential utility", () => {
218278

219279
expect(() => handleFirebaseError(mockUI, firebaseErrorWithCredential)).toThrowError(FirebaseUIError);
220280

221-
// Should have stored the credential
222281
expect(window.sessionStorage.setItem).toHaveBeenCalledWith(
223282
"pendingCred",
224283
JSON.stringify(mockCredential.toJSON())
@@ -236,8 +295,6 @@ describe("errorContainsCredential utility", () => {
236295

237296
expect(() => handleFirebaseError(mockUI, firebaseErrorWithoutCredential)).toThrowError(FirebaseUIError);
238297

239-
// Should not have stored any credential
240298
expect(window.sessionStorage.setItem).not.toHaveBeenCalled();
241299
});
242300
});
243-

packages/core/src/errors.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { ERROR_CODE_MAP, ErrorCode } from "@firebase-ui/translations";
1818
import { FirebaseError } from "firebase/app";
19-
import { AuthCredential } from "firebase/auth";
19+
import { AuthCredential, getMultiFactorResolver, MultiFactorError } from "firebase/auth";
2020
import { FirebaseUIConfiguration } from "./config";
2121
import { getTranslation } from "./translations";
2222
export class FirebaseUIError extends FirebaseError {
@@ -44,6 +44,12 @@ export function handleFirebaseError(
4444
window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON()));
4545
}
4646

47+
// Update the UI with the multi-factor resolver if the error is thrown.
48+
if (error.code === "auth/multi-factor-auth-required") {
49+
const resolver = getMultiFactorResolver(ui.auth, error as MultiFactorError);
50+
ui.setMultiFactorResolver(resolver);
51+
}
52+
4753
throw new FirebaseUIError(ui, error);
4854
}
4955

packages/core/tests/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export function createMockUI(overrides?: Partial<FirebaseUIConfiguration>): Fire
1414
setState: vi.fn(),
1515
locale: enUs,
1616
behaviors: {},
17+
multiFactorResolver: undefined,
18+
setMultiFactorResolver: vi.fn(),
1719
...overrides,
1820
};
1921
}

0 commit comments

Comments
 (0)