Skip to content

Commit 2f0b2c2

Browse files
authored
Merge pull request #1215 from firebase/@invertase/generate-totp-qrcode
2 parents 8e928f8 + c7e77e0 commit 2f0b2c2

File tree

5 files changed

+191
-70
lines changed

5 files changed

+191
-70
lines changed

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"dependencies": {
5252
"@firebase-ui/translations": "workspace:*",
5353
"nanostores": "catalog:",
54+
"qrcode-generator": "^2.0.4",
5455
"zod": "catalog:"
5556
},
5657
"devDependencies": {

packages/core/src/auth.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
signInWithCredential,
1111
signInAnonymously,
1212
signInWithProvider,
13+
generateTotpQrCode,
1314
} from "./auth";
1415

1516
vi.mock("firebase/auth", () => ({
@@ -56,6 +57,7 @@ import {
5657
Auth,
5758
ConfirmationResult,
5859
AuthProvider,
60+
TotpSecret,
5961
} from "firebase/auth";
6062
import { hasBehavior, getBehavior } from "./behaviors";
6163
import { handleFirebaseError } from "./errors";
@@ -1051,3 +1053,91 @@ describe("signInWithProvider", () => {
10511053
expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]);
10521054
});
10531055
});
1056+
1057+
describe("generateTotpQrCode", () => {
1058+
beforeEach(() => {
1059+
vi.clearAllMocks();
1060+
});
1061+
1062+
it("should generate QR code successfully with authenticated user", () => {
1063+
const mockUI = createMockUI({
1064+
auth: { currentUser: { email: "[email protected]" } } as Auth,
1065+
});
1066+
const mockSecret = {
1067+
generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/[email protected]?secret=ABC123&issuer=TestApp"),
1068+
} as unknown as TotpSecret;
1069+
1070+
const result = generateTotpQrCode(mockUI, mockSecret);
1071+
1072+
expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("[email protected]", undefined);
1073+
expect(result).toMatch(/^data:image\/gif;base64,/);
1074+
});
1075+
1076+
it("should generate QR code with custom account name and issuer", () => {
1077+
const mockUI = createMockUI({
1078+
auth: { currentUser: { email: "[email protected]" } } as Auth,
1079+
});
1080+
const mockSecret = {
1081+
generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/CustomAccount?secret=ABC123&issuer=CustomIssuer"),
1082+
} as unknown as TotpSecret;
1083+
1084+
const result = generateTotpQrCode(mockUI, mockSecret, "CustomAccount", "CustomIssuer");
1085+
1086+
expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("CustomAccount", "CustomIssuer");
1087+
expect(result).toMatch(/^data:image\/gif;base64,/);
1088+
});
1089+
1090+
it("should use user email as account name when no custom account name provided", () => {
1091+
const mockUI = createMockUI({
1092+
auth: { currentUser: { email: "[email protected]" } } as Auth,
1093+
});
1094+
const mockSecret = {
1095+
generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/[email protected]?secret=ABC123"),
1096+
} as unknown as TotpSecret;
1097+
1098+
generateTotpQrCode(mockUI, mockSecret);
1099+
1100+
expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("[email protected]", undefined);
1101+
});
1102+
1103+
it("should use empty string as account name when user has no email", () => {
1104+
const mockUI = createMockUI({
1105+
auth: { currentUser: { email: null } } as Auth,
1106+
});
1107+
const mockSecret = {
1108+
generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/?secret=ABC123"),
1109+
} as unknown as TotpSecret;
1110+
1111+
generateTotpQrCode(mockUI, mockSecret);
1112+
1113+
expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("", undefined);
1114+
});
1115+
1116+
it("should throw error when user is not authenticated", () => {
1117+
const mockUI = createMockUI({
1118+
auth: { currentUser: null } as Auth,
1119+
});
1120+
const mockSecret = {
1121+
generateQrCodeUrl: vi.fn(),
1122+
} as unknown as TotpSecret;
1123+
1124+
expect(() => generateTotpQrCode(mockUI, mockSecret)).toThrow(
1125+
"User must be authenticated to generate a TOTP QR code"
1126+
);
1127+
expect(mockSecret.generateQrCodeUrl).not.toHaveBeenCalled();
1128+
});
1129+
1130+
it("should throw error when currentUser is undefined", () => {
1131+
const mockUI = createMockUI({
1132+
auth: { currentUser: undefined } as unknown as Auth,
1133+
});
1134+
const mockSecret = {
1135+
generateQrCodeUrl: vi.fn(),
1136+
} as unknown as TotpSecret;
1137+
1138+
expect(() => generateTotpQrCode(mockUI, mockSecret)).toThrow(
1139+
"User must be authenticated to generate a TOTP QR code"
1140+
);
1141+
expect(mockSecret.generateQrCodeUrl).not.toHaveBeenCalled();
1142+
});
1143+
});

packages/core/src/auth.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import {
3131
PhoneAuthProvider,
3232
UserCredential,
3333
AuthCredential,
34+
TotpSecret,
3435
} from "firebase/auth";
36+
import QRCode from "qrcode-generator";
3537
import { FirebaseUIConfiguration } from "./config";
3638
import { handleFirebaseError } from "./errors";
3739
import { hasBehavior, getBehavior } from "./behaviors/index";
@@ -290,3 +292,23 @@ export async function completeEmailLinkSignIn(
290292
window.localStorage.removeItem("emailForSignIn");
291293
}
292294
}
295+
296+
export function generateTotpQrCode(
297+
ui: FirebaseUIConfiguration,
298+
secret: TotpSecret,
299+
accountName?: string,
300+
issuer?: string
301+
): string {
302+
const currentUser = ui.auth.currentUser;
303+
304+
if (!currentUser) {
305+
throw new Error("User must be authenticated to generate a TOTP QR code");
306+
}
307+
308+
const uri = secret.generateQrCodeUrl(accountName || currentUser.email || "", issuer);
309+
310+
const qr = QRCode(0, "L");
311+
qr.addData(uri);
312+
qr.make();
313+
return qr.createDataURL();
314+
}

0 commit comments

Comments
 (0)