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);