Passkeys

createPasskey() utility

Let's start by arranging the server side of things, which consists of storing a passkey in the database.
export async function createPasskey(input: {
	id: string
	userId: string
	aaguid: string
	publicKey: Uint8Array<ArrayBuffer>
	counter?: number
}) {
	const passkey = await prisma.passkey.create({
		data: {
			id: input.id,
			aaguid: input.aaguid,
			userId: input.userId,
			publicKey: input.publicKey,
			backedUp: false,
			webauthnUserId: input.userId,
			deviceType: 'singleDevice',
			counter: input.counter || 0,
		},
	})

	return {
		async [Symbol.asyncDispose]() {
			await prisma.passkey.deleteMany({
				where: {
					id: passkey.id,
				},
			})
		},
		...passkey,
	}
}
Notice how the createPasskey() utility decouples the actual passkey (the publicKey in this case) from the storage mechanism. After all, it only needs the public part and the user context (userId and aaguid) to produce a valid record.
Of course, on its own this isn't enough. Something has to actually create that passkey for us to store. Where does it get from in a regular, non-test scenario? Well, it gets provided by the user! And in our case, it will get provided by the test user during the test.

Virtual Web Authenticator

Before we get to the providing part though, we need to have something that would store our test passkeys in the browser. Luckily, we don't have to hack our way into that storage mechanism as the underlying APIs are exposed through the Chrome DevTools Protocol (CDP), which, in turn, is exposed to us by Playwright.
We will create another helper utility called createWebAuthnClient() that will give us back a virtual authenticator session bound to the given page.
async function createWebAuthnClient(page: Page) {
	const session = await page.context().newCDPSession(page)
	await session.send('WebAuthn.enable')

	const authenticator = await session.send('WebAuthn.addVirtualAuthenticator', {
		options: {
			protocol: 'ctap2',
			transport: 'internal',
			hasResidentKey: true,
			hasUserVerification: true,
			isUserVerified: true,
			automaticPresenceSimulation: true,
		},
	})

	return {
		session,
		authenticatorId: authenticator.authenticatorId,
	}
}
By setting the automaticPresenceSimulation option to true, we are selecting the added passkey in the authenticatior automatically, whereas the users would normally be presented with a blocking popup to choose their passkey.
Here, we are enabling Web Authentication ('WebAuthn.enable') and adding a new virtual authenticator (WebAuthn.addVirtualAuthenticator) by sending the respective messages to the CDP session. In return, we get:
  • session, the CDP session reference we can use in the test to store our test passkey in the browser;
  • authenticatorId, the reference to the virtual authenticator we've created so the browser knows where to store our passkeys.
With that, let's write our first test case that focuses on the successful authentication flow.
import { createTestPasskey } from 'test-passkey'
import { createPasskey, createUser } from '#tests/db-utils.ts'

test('authenticates using an existing passkey', async ({ navigate, page }) => {
	await using user = await createUser()

	await navigate('/login')

	const passkey = createTestPasskey({
		rpId: new URL(page.url()).hostname,
	})

	await using _ = await createPasskey({
		id: passkey.credential.credentialId,
		userId: user.id,
		aaguid: passkey.credential.aaguid || '',
		publicKey: passkey.publicKey,
	})

	const { session, authenticatorId } = await createWebAuthnClient(page)
	await session.send('WebAuthn.addCredential', {
		authenticatorId,
		credential: {
			...passkey.credential,
			isResidentCredential: true,
			userName: user.username,
			userHandle: btoa(user.id),
			userDisplayName: user.name ?? user.email,
		},
	})

	await page.getByRole('button', { name: 'Login with a passkey' }).click()

	await expect(page.getByRole('link', { name: user.name! })).toBeVisible()
})

Creating a test passkey

await navigate('/login')

const passkey = createTestPasskey({
	rpId: new URL(page.url()).hostname,
})
We are using the createTestPasskey() function from the test-passkey package to create a realistic passkey bound to the given rpIdβ€”relying party IDβ€”which is the hostname of the application for which the passkey is being issued.
rpId is the integral part of preventing passkeys from being spoofed. Respect that and issue the test passkey specifically for your test application. For that, make sure you have already visited the application (navigate()) so page.url() would be an actual application URL and not just about:blank.

Storing the public key

Now that we have a test passkey, let's store its public part on the server, using the createTestPasskey() utility we've created earlier.
const passkey = createTestPasskey({
	rpId: new URL(page.url()).hostname,
})

// ...

await using _ = await createPasskey({
	id: passkey.credential.credentialId,
	userId: user.id,
	aaguid: passkey.credential.aaguid || '',
	publicKey: passkey.publicKey,
})
You can already spot the key-user collocation happening via userId and the passkey's id + publicKey.

Storing the private key

Storing the private part of the test passkey will involve adding it to the virtual authenticator via the createWebAuthnClient() we've prepared.
const passkey = createTestPasskey({
	rpId: new URL(page.url()).hostname,
})

// ...

const { session, authenticatorId } = await createWebAuthnClient(page)
await session.send('WebAuthn.addCredential', {
	authenticatorId,
	credential: {
		...passkey.credential,
		isResidentCredential: true,
		userName: user.username,
		userHandle: btoa(user.id),
		userDisplayName: user.name ?? user.email,
	},
})

Authenticating via passkeys

All that remains now is to continue with the authentication flow, following the user's actions and describing the their expectations.
await page.getByRole('button', { name: 'Login with a passkey' }).click()

await expect(page.getByRole('link', { name: user.name! })).toBeVisible()
Yes, this is literally it. The test case itself is a two-liner that delegates all the heavy-lifting to the test setup.

Failed authentication flow

Unlike the basic authentication, testing a failed passkey scenario will also require some test setup. If we omit the virtual authenticator entirely, the authentication flow will, indeed, fail, but not with an error the user ever be able to reproduce.
To repvent that and tap into the actual user-facing failure, we will tap into the CDP once more and send the 'WebAuthn.setUserVerified' directive, marking our virtual authenticatior as non-verified for usage.
test('displays an error when authenticating via a passkey fails', async ({
	navigate,
	page,
}) => {
	await navigate('/login')

	const { session, authenticatorId } = await createWebAuthnClient(page)
	await session.send('WebAuthn.setUserVerified', {
		authenticatorId,
		isUserVerified: false,
	})

	await page.getByRole('button', { name: 'Login with a passkey' }).click()

	await expect(
		page
			.getByRole('alert')
			.getByText(
				'Failed to authenticate with passkey: The operation either timed out or was not allowed',
			),
	).toBeVisible()
})
This reproduces a scenario when the selected passkey cannot be used for the given rpId (application), which allows us to assert on the appropriate error message being communicated to the user.

Running the tests

npm run test:e2e

Please set the playground first

Loading "Passkeys"
Loading "Passkeys"

No tests here 😒 Sorry.