Skip to content

Commit 7d8ad1f

Browse files
committed
feat(core): Add generateTotpQrCode function
1 parent 35b7d29 commit 7d8ad1f

File tree

3 files changed

+112
-0
lines changed

3 files changed

+112
-0
lines changed

packages/core/src/auth.test.ts

Lines changed: 87 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", () => ({
@@ -31,6 +32,7 @@ vi.mock("firebase/auth", () => ({
3132
linkWithCredential: vi.fn(),
3233
}));
3334

35+
3436
vi.mock("./behaviors", () => ({
3537
hasBehavior: vi.fn(),
3638
getBehavior: vi.fn(),
@@ -56,6 +58,7 @@ import {
5658
Auth,
5759
ConfirmationResult,
5860
AuthProvider,
61+
TotpSecret,
5962
} from "firebase/auth";
6063
import { hasBehavior, getBehavior } from "./behaviors";
6164
import { handleFirebaseError } from "./errors";
@@ -924,3 +927,87 @@ describe("signInWithProvider", () => {
924927
expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]);
925928
});
926929
});
930+
931+
describe("generateTotpQrCode", () => {
932+
beforeEach(() => {
933+
vi.clearAllMocks();
934+
});
935+
936+
it("should generate QR code successfully with authenticated user", () => {
937+
const mockUI = createMockUI({
938+
auth: { currentUser: { email: "[email protected]" } } as Auth,
939+
});
940+
const mockSecret = {
941+
generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/[email protected]?secret=ABC123&issuer=TestApp"),
942+
} as unknown as TotpSecret;
943+
944+
const result = generateTotpQrCode(mockUI, mockSecret);
945+
946+
expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("[email protected]", undefined);
947+
expect(result).toMatch(/^data:image\/gif;base64,/);
948+
});
949+
950+
it("should generate QR code with custom account name and issuer", () => {
951+
const mockUI = createMockUI({
952+
auth: { currentUser: { email: "[email protected]" } } as Auth,
953+
});
954+
const mockSecret = {
955+
generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/CustomAccount?secret=ABC123&issuer=CustomIssuer"),
956+
} as unknown as TotpSecret;
957+
958+
const result = generateTotpQrCode(mockUI, mockSecret, "CustomAccount", "CustomIssuer");
959+
960+
expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("CustomAccount", "CustomIssuer");
961+
expect(result).toMatch(/^data:image\/gif;base64,/);
962+
});
963+
964+
it("should use user email as account name when no custom account name provided", () => {
965+
const mockUI = createMockUI({
966+
auth: { currentUser: { email: "[email protected]" } } as Auth,
967+
});
968+
const mockSecret = {
969+
generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/[email protected]?secret=ABC123"),
970+
} as unknown as TotpSecret;
971+
972+
generateTotpQrCode(mockUI, mockSecret);
973+
974+
expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("[email protected]", undefined);
975+
});
976+
977+
it("should use empty string as account name when user has no email", () => {
978+
const mockUI = createMockUI({
979+
auth: { currentUser: { email: null } } as Auth,
980+
});
981+
const mockSecret = {
982+
generateQrCodeUrl: vi.fn().mockReturnValue("otpauth://totp/?secret=ABC123"),
983+
} as unknown as TotpSecret;
984+
985+
generateTotpQrCode(mockUI, mockSecret);
986+
987+
expect(mockSecret.generateQrCodeUrl).toHaveBeenCalledWith("", undefined);
988+
});
989+
990+
it("should throw error when user is not authenticated", () => {
991+
const mockUI = createMockUI({
992+
auth: { currentUser: null } as Auth,
993+
});
994+
const mockSecret = {
995+
generateQrCodeUrl: vi.fn(),
996+
} as unknown as TotpSecret;
997+
998+
expect(() => generateTotpQrCode(mockUI, mockSecret)).toThrow("User must be authenticated to generate a TOTP QR code");
999+
expect(mockSecret.generateQrCodeUrl).not.toHaveBeenCalled();
1000+
});
1001+
1002+
it("should throw error when currentUser is undefined", () => {
1003+
const mockUI = createMockUI({
1004+
auth: { currentUser: undefined } as unknown as Auth,
1005+
});
1006+
const mockSecret = {
1007+
generateQrCodeUrl: vi.fn(),
1008+
} as unknown as TotpSecret;
1009+
1010+
expect(() => generateTotpQrCode(mockUI, mockSecret)).toThrow("User must be authenticated to generate a TOTP QR code");
1011+
expect(mockSecret.generateQrCodeUrl).not.toHaveBeenCalled();
1012+
});
1013+
});

packages/core/src/auth.ts

Lines changed: 17 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";
@@ -271,3 +273,18 @@ export async function completeEmailLinkSignIn(
271273
window.localStorage.removeItem("emailForSignIn");
272274
}
273275
}
276+
277+
export function generateTotpQrCode(ui: FirebaseUIConfiguration, secret: TotpSecret, accountName?: string, issuer?: string): string {
278+
const currentUser = ui.auth.currentUser;
279+
280+
if (!currentUser) {
281+
throw new Error("User must be authenticated to generate a TOTP QR code");
282+
}
283+
284+
const uri = secret.generateQrCodeUrl(accountName || currentUser.email || "", issuer);
285+
286+
const qr = QRCode(0, 'L');
287+
qr.addData(uri);
288+
qr.make();
289+
return qr.createDataURL();
290+
}

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)