feat: display scan-ready enrollment QR codes for OTP tokens
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user