183 lines
6.8 KiB
TypeScript
183 lines
6.8 KiB
TypeScript
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,
|
|
}) => {
|
|
const requests: string[] = [];
|
|
page.on("request", (request) => requests.push(request.url()));
|
|
await page.goto("/deep/nested/auth/");
|
|
await expect(page.getByText("Sensitive session · memory only")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Credential parameters" }),
|
|
).toBeVisible();
|
|
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);
|
|
});
|
|
|
|
test("computes an RFC OCRA vector and decodes client data", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/deep/nested/auth/");
|
|
await page.getByRole("button", { name: "OCRA challenge" }).click();
|
|
await page
|
|
.getByLabel("Base32 shared secret")
|
|
.fill("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
|
|
await page.getByRole("button", { name: "Compute OCRA response" }).click();
|
|
await expect(page.getByText("237653")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /WebAuthn \/ Passkeys/ }).click();
|
|
const client = btoa(
|
|
JSON.stringify({
|
|
type: "webauthn.get",
|
|
challenge: "YQ",
|
|
origin: "https://example.test",
|
|
}),
|
|
)
|
|
.replaceAll("+", "-")
|
|
.replaceAll("/", "_")
|
|
.replace(/=+$/u, "");
|
|
await page.getByLabel("Encoded input").fill(client);
|
|
await page.getByRole("button", { name: "Decode locally" }).click();
|
|
await expect(page.locator(".diagnostic-output")).toContainText(
|
|
"webauthn.get",
|
|
);
|
|
});
|
|
|
|
test("localhost development enables live credential creation", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/deep/nested/auth/");
|
|
await page.getByRole("button", { name: /WebAuthn \/ Passkeys/ }).click();
|
|
await page.getByRole("button", { name: "Live ceremony" }).click();
|
|
await expect(page.getByText("Live enabled")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", { name: "Create test credential" }),
|
|
).toBeEnabled();
|
|
});
|
|
|
|
test("exposes the advanced OTP and WebAuthn laboratories", async ({ page }) => {
|
|
await page.goto("/deep/nested/auth/");
|
|
await page.getByRole("button", { name: "Resync & rotation" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "HOTP resynchronization" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Collection comparison" }),
|
|
).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Import & migration" }).click();
|
|
await expect(
|
|
page.getByRole("button", { name: "Scan with camera" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", { name: "Decrypt PSKC" }),
|
|
).toBeDisabled();
|
|
|
|
await page.getByRole("button", { name: /WebAuthn \/ Passkeys/ }).click();
|
|
await page.getByRole("button", { name: "Extensions & traces" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Extension configuration" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Ceremony traces" }),
|
|
).toBeVisible();
|
|
await page.getByRole("button", { name: "Attestation verifier" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Trust root and historical policy" }),
|
|
).toBeVisible();
|
|
});
|