1/**2 * A Playwright helper that installs a Chrome DevTools Protocol virtual3 * authenticator on a page, so WebAuthn enroll/authenticate flows and RP-ID4 * isolation can be exercised headless without a physical device.5 *6 * CDP virtual authenticators do **not** reliably model the WebAuthn PRF7 * extension, so this helper is for enroll / authenticate / RP-ID coverage only.8 * PRF derivation is feature-detected in the runtime and must be validated on a9 * real device; do not assert PRF results against a virtual authenticator.10 *11 * @module12 */1314import type { Page } from "playwright";1516/** Options for {@link withVirtualAuthenticator}. */17export interface VirtualAuthenticatorOptions {18 /** CTAP2 (`ctap2`) or legacy U2F (`u2f`); defaults to `ctap2`. */19 readonly protocol?: "ctap2" | "u2f";20 /** The credential transport reported to the page; defaults to `internal`. */21 readonly transport?: "usb" | "nfc" | "ble" | "cable" | "internal";22 /** Whether the authenticator stores resident (discoverable) keys; defaults to true. */23 readonly hasResidentKey?: boolean;24 /** Whether the authenticator can perform user verification; defaults to true. */25 readonly hasUserVerification?: boolean;26 /** Whether the simulated user is already verified; defaults to true. */27 readonly isUserVerified?: boolean;28 /** Whether presence/verification is auto-satisfied without a prompt; defaults to true. */29 readonly automaticPresenceSimulation?: boolean;30}3132/** Serializable CDP credential used to copy one virtual passkey between browser profiles. */33export interface VirtualAuthenticatorCredential {34 /** Base64url credential identifier assigned by the virtual authenticator. */35 readonly credentialId: string;36 /** Whether the credential is discoverable without an allow-list. */37 readonly isResidentCredential: boolean;38 /** Relying-party ID that may request this credential. */39 readonly rpId?: string;40 /** PKCS#8 private key used only inside the virtual test authenticator. */41 readonly privateKey: string;42 /** Optional base64url user handle associated with the credential. */43 readonly userHandle?: string;44 /** Signature counter maintained by the virtual authenticator. */45 readonly signCount: number;46 /** Optional base64url large-blob payload stored by the virtual authenticator. */47 readonly largeBlob?: string;48}4950/** A handle to an installed virtual authenticator; call {@link dispose} to remove it. */51export interface VirtualAuthenticatorHandle {52 /** The CDP-assigned id of the virtual authenticator. */53 readonly authenticatorId: string;54 /** Return credentials from this virtual authenticator. Treat private keys as test secrets. */55 credentials(): Promise<VirtualAuthenticatorCredential[]>;56 /** Install a previously exported virtual credential in this test profile. */57 addCredential(credential: VirtualAuthenticatorCredential): Promise<void>;58 /**59 * Remove every credential from this virtual authenticator, simulating a lost60 * or replaced device — e.g. to prove a credential-pinned ceremony fails61 * closed instead of accepting whatever passkey remains.62 */63 clearCredentials(): Promise<void>;64 /** Remove the virtual authenticator and detach the CDP session. Idempotent. */65 dispose(): Promise<void>;66}6768/**69 * Install a CDP virtual authenticator on `page` and return a handle that removes70 * it on {@link VirtualAuthenticatorHandle.dispose}. Defaults model an internal,71 * resident, user-verifying platform authenticator that auto-satisfies presence.72 */73export async function withVirtualAuthenticator(74 page: Page,75 options: VirtualAuthenticatorOptions = {},76): Promise<VirtualAuthenticatorHandle> {77 const session = await page.context().newCDPSession(page);78 await session.send("WebAuthn.enable", { enableUI: true });79 const { authenticatorId } = await session.send("WebAuthn.addVirtualAuthenticator", {80 options: {81 protocol: options.protocol ?? "ctap2",82 transport: options.transport ?? "internal",83 hasResidentKey: options.hasResidentKey ?? true,84 hasUserVerification: options.hasUserVerification ?? true,85 isUserVerified: options.isUserVerified ?? true,86 automaticPresenceSimulation: options.automaticPresenceSimulation ?? true,87 },88 });8990 let disposed = false;91 return {92 authenticatorId,93 async credentials(): Promise<VirtualAuthenticatorCredential[]> {94 const result = await session.send("WebAuthn.getCredentials", { authenticatorId }) as {95 credentials: VirtualAuthenticatorCredential[];96 };97 return result.credentials;98 },99 async addCredential(credential: VirtualAuthenticatorCredential): Promise<void> {100 await session.send("WebAuthn.addCredential", { authenticatorId, credential });101 },102 async clearCredentials(): Promise<void> {103 await session.send("WebAuthn.clearCredentials", { authenticatorId });104 },105 async dispose(): Promise<void> {106 if (disposed) return;107 disposed = true;108 await session.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });109 await session.detach();110 },111 };112}