iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🎭

Simulating Passkey (WebAuthn) Authentication and Login in Playwright E2E Tests

に公開

What I want to achieve

  • Reproduce authentication and login using passkeys (WebAuthn) in Playwright tests

Approach

Use Playwright's CDPSession to enable Chrome's Virtual Authenticators.

https://playwright.dev/docs/api/class-cdpsession

https://developer.chrome.com/docs/devtools/webauthn?hl=en

Implementation

Create a CDP session with newCDPSession and add a Virtual Authenticator using addVirtualAuthenticator. Once added, there's no need for further manual operation; it automatically simulates a WebAuthn Authenticator during tests.

Challenges encountered

  • When you want to reproduce passkey authentication (Discoverable credentials), you must specify hasResidentKey when passing addVirtualAuthenticator to the cdpSession.
  • While Authenticator behavior is simulated automatically, it can sometimes be extremely slow, so it is necessary to include longer wait times.
    • This might be especially noticeable in resource-constrained environments like CI.

*.test.ts

import { test, expect } from "@playwright/test";

test.describe.serial("Authentications & API Calling", () => {
    let savedCookies: Cookie[] = [];

    test("Sign up => Sign in with passkey (WebAuthn)", async ({ browserName, page }, testInfo) => {
        // Currently, only Chrome supports Virtual Authenticator
        test.skip(browserName !== "chromium", "This test runs only in Chromium");
        await page.goto("/");

        // Add Virtual Authenticator
        const cdpSession = await page.context().newCDPSession(page);
        await cdpSession.send("WebAuthn.enable");
        await cdpSession.send("WebAuthn.addVirtualAuthenticator", {
            options: {
                protocol: "ctap2",
                ctap2Version: "ctap2_1",
                hasUserVerification: true,
                transport: "internal",
                automaticPresenceSimulation: true,
                isUserVerified: true,
                // Be careful: This must be enabled for passkey authentication (authentication via WebAuthn alone, without entering a username)
                hasResidentKey: true,
            }
        });

        cdpSession.on("WebAuthn.credentialAdded", () => {
            console.log("Credential Added!");
        });

        // Sign up (please modify this part to fit your environment)
        await page.click("#sign-up-button");
        await page.fill("#name", "Test User");
        await page.click("#terms");
        const beforeSignUpScreenshot = await page.screenshot();
        await testInfo.attach("Before sign-up", {
            body: beforeSignUpScreenshot,
            contentType: "image/png",
        });

        await page.click("#sign-up-submit-button");
        await page.waitForTimeout(10);
        const afterSignUpScreenshot = await page.screenshot();
        await testInfo.attach("After click sign-up button", {
            body: afterSignUpScreenshot,
            contentType: "image/png",
        });

        // Insert a longer wait time as it can be very slow
        await page.waitForTimeout(8000);
        await page.reload();

        // Sign in
        await page.click("#button-sign-in-with-passkey");
        await page.waitForTimeout(500);
        const afterSignInScreenshot = await page.screenshot();
        await testInfo.attach("After click sign-in button", {
            body: afterSignInScreenshot,
            contentType: "image/png",
        });

        // Insert a longer wait time as it can be very slow
        await page.waitForTimeout(8000);
        const appScreenshot = await page.screenshot();
        await testInfo.attach("After sign-in", {
            body: appScreenshot,
            contentType: "image/png",
        });

        // Now just verify if login was successful!
        expect(await page.locator("#my-app-sidebar").count()).toBeGreaterThan(0);

        // Save cookies
        savedCookies = await page.context().cookies();
    });

    test("Test requiring authentication", async ({ browserName, page }, testInfo) => {
        // This is also required here
        test.skip(browserName !== "chromium", "This test runs only in Chromium");

        await page.context().addInitScript(() => {
            // Set localStorage
            window.localStorage.setItem("isLoggedIn", "true");
        });

        // Restore signed-in state
        await page.context().addCookies(savedCookies);

        // ...rest omitted
    });
});

If it doesn't work well

  • Depending on the environment, registration and authentication can take a long time, so try increasing the wait times.
  • Try verifying if it works using the Chrome DevTools Virtual Authenticator.
    • Compare the parameters set there with the options passed to addVirtualAuthenticator and try adjusting the test options.
  • Check for errors on the client side by adding page.on("console", msg => console.log(msg.text()));.
  • Investigate what is happening by attaching screenshots to the report using the following code:
    const homeScreenshot = await page.screenshot();
    await testInfo.attach("Top page", {
        body: homeScreenshot,
        contentType: "image/png",
    });
    

Discussion