feat: display scan-ready enrollment QR codes for OTP tokens

This commit is contained in:
2026-10-04 12:14:26 +02:00
parent 12696657a0
commit f309809eba
16 changed files with 334 additions and 30 deletions
+89
View File
@@ -1,4 +1,90 @@
import { expect, test } from "@playwright/test";
import jsQR from "jsqr";
import { bytesToBase32 } from "../../src/crypto/encoding";
import { parseOtpAuth } from "../../src/otp/profile";
test("displays scannable imported TOTP and HOTP tokens on a dark mobile screen", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ colorScheme: "dark" });
const requests: string[] = [];
page.on("request", (request) => requests.push(request.url()));
await page.goto("/deep/nested/auth/");
await page.getByRole("button", { name: "Import & migration" }).click();
await page
.getByPlaceholder("Paste migration data. It never leaves this tab.")
.fill(
"otpauth://totp/Ex%C3%A4mple:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=Ex%C3%A4mple&algorithm=SHA256&digits=8&period=45\n" +
"otpauth://hotp/Other:bob?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Other&counter=18446744073709551615",
);
await page.getByRole("button", { name: "Inspect import" }).click();
for (const account of ["Exämple: alice@example.test", "Other: bob"]) {
const trigger = page.getByRole("button", {
name: `Show QR code for ${account}`,
exact: true,
});
await trigger.click();
const dialog = page.getByRole("dialog", { name: "OTP token QR code" });
await expect(dialog).toBeVisible();
const svg = dialog.getByRole("img", { name: "OTP provisioning QR code" });
await expect(svg).toBeVisible();
const bounds = await svg.boundingBox();
expect(bounds!.width).toBeGreaterThan(250);
expect(bounds!.height).toBeCloseTo(bounds!.width, 0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
const pixels = await svg.evaluate(async (element) => {
const source = new XMLSerializer().serializeToString(element);
const url = URL.createObjectURL(
new Blob([source], { type: "image/svg+xml" }),
);
try {
const image = new Image();
image.src = url;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 640;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0, 640, 640);
return Array.from(context.getImageData(0, 0, 640, 640).data);
} finally {
URL.revokeObjectURL(url);
}
});
expect(pixels.slice(0, 4)).toEqual([255, 255, 255, 255]);
const decoded = jsQR(new Uint8ClampedArray(pixels), 640, 640);
expect(decoded).not.toBeNull();
const { profile } = parseOtpAuth(decoded!.data);
if (account.startsWith("Exämple")) {
expect(profile).toMatchObject({
kind: "totp",
issuer: "Exämple",
account: "alice@example.test",
algorithm: "SHA-256",
digits: 8,
period: 45,
});
expect(bytesToBase32(profile.secret)).toBe("JBSWY3DPEHPK3PXP");
} else {
expect(profile).toMatchObject({
kind: "hotp",
issuer: "Other",
account: "bob",
counter: 18446744073709551615n,
});
expect(bytesToBase32(profile.secret)).toBe(
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
);
}
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();
}
expect(
requests.every((url) => new URL(url).origin === "http://127.0.0.1:4173"),
).toBe(true);
});
test("runs from a nested path and keeps authentication material local", async ({
page,
@@ -13,7 +99,10 @@ test("runs from a nested path and keeps authentication material local", async ({
await page.getByLabel("Issuer").fill("Example");
await page.getByLabel("Account").fill("alice@example.test");
await expect(page.getByText("TOTP code")).toBeVisible();
await page.getByRole("button", { name: "Show QR code", exact: true }).click();
await expect(page.locator(".qr svg")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
expect(
requests.every((url) => new URL(url).origin === "http://127.0.0.1:4173"),
).toBe(true);
+29
View File
@@ -0,0 +1,29 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TokenQrDialog } from "../../src/components/otp/TokenQrDialog";
import { parseOtpAuth } from "../../src/otp/profile";
describe("token QR display", () => {
it("reports encoder limits without displaying an empty QR or allowing download", () => {
const { profile } = parseOtpAuth(
`otpauth://totp/${"a".repeat(500)}?secret=JBSWY3DPEHPK3PXP`,
);
render(<TokenQrDialog profile={profile} onClose={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent(/version 10/iu);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Download QR" })).toBeDisabled();
});
it("identifies nonportable time parameters without showing the plaintext seed", () => {
const { profile } = parseOtpAuth(
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example&t0=100",
);
render(<TokenQrDialog profile={profile} onClose={vi.fn()} />);
expect(
screen.getByText(/Some authenticators ignore/iu),
).toBeInTheDocument();
expect(screen.getByRole("dialog").textContent).not.toContain(
"JBSWY3DPEHPK3PXP",
);
});
});