|
1 | 1 | # Passkeys |
2 | 2 |
|
3 | | -## Summary |
4 | | - |
5 | | -1. In `tests/db-utils.ts`, create a new utility `createPasskey`. It will only be responsible for storing the given passkey correctly in the database (that it belongs to the given user). |
6 | | -1. Install `test-passkey`. We will use it to generate test passkeys faster for tests. |
7 | | -1. Create a new test at `tests/e2e/authentication-passkeys.test.ts`. |
8 | | -1. Add a test case for successful auth using passkeys. **Navigate to `/login` first. This is important**. |
9 | | -1. Create a test passkey, create a test user. Use `createPasskey` to store the generated passkey in the database. |
10 | | -1. Now, we need to tell Playwright how to handle passkeys. In the test, create the `createWebAuthnClient` function and go step-by-step. |
11 | | -1. Use the created `createWebAuthnClient` function to tell the browser how to react to the passkey input prompt (to use our test passkey). |
12 | | -1. Continue with the login flow. Verify the auth state. |
13 | | -1. `npm run test:e2e`. |
14 | | - |
15 | | ---- |
16 | | - |
17 | | -1. Add another test case for the error scenario. |
18 | | -1. `npm run test:e2e`. |
| 3 | +## `createPasskey()` utility |
| 4 | + |
| 5 | +Let's start by arranging the server side of things, which consists of storing a passkey in the database. |
| 6 | + |
| 7 | +```ts filename=tests/db-utils.ts add=8-19 |
| 8 | +export async function createPasskey(input: { |
| 9 | + id: string |
| 10 | + userId: string |
| 11 | + aaguid: string |
| 12 | + publicKey: Uint8Array<ArrayBuffer> |
| 13 | + counter?: number |
| 14 | +}) { |
| 15 | + const passkey = await prisma.passkey.create({ |
| 16 | + data: { |
| 17 | + id: input.id, |
| 18 | + aaguid: input.aaguid, |
| 19 | + userId: input.userId, |
| 20 | + publicKey: input.publicKey, |
| 21 | + backedUp: false, |
| 22 | + webauthnUserId: input.userId, |
| 23 | + deviceType: 'singleDevice', |
| 24 | + counter: input.counter || 0, |
| 25 | + }, |
| 26 | + }) |
| 27 | + |
| 28 | + return { |
| 29 | + async [Symbol.asyncDispose]() { |
| 30 | + await prisma.passkey.deleteMany({ |
| 31 | + where: { |
| 32 | + id: passkey.id, |
| 33 | + }, |
| 34 | + }) |
| 35 | + }, |
| 36 | + ...passkey, |
| 37 | + } |
| 38 | +} |
| 39 | +``` |
| 40 | + |
| 41 | +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. |
| 42 | + |
| 43 | +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. |
| 44 | + |
| 45 | +## Virtual Web Authenticator |
| 46 | + |
| 47 | +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. |
| 48 | + |
| 49 | +We will create another helper utility called `createWebAuthnClient()` that will give us back a virtual authenticator session bound to the given page. |
| 50 | + |
| 51 | +```ts filename=tests/e2e/authentication-passkeys.test.ts |
| 52 | +async function createWebAuthnClient(page: Page) { |
| 53 | + const session = await page.context().newCDPSession(page) |
| 54 | + await session.send('WebAuthn.enable') |
| 55 | + |
| 56 | + const authenticator = await session.send('WebAuthn.addVirtualAuthenticator', { |
| 57 | + options: { |
| 58 | + protocol: 'ctap2', |
| 59 | + transport: 'internal', |
| 60 | + hasResidentKey: true, |
| 61 | + hasUserVerification: true, |
| 62 | + isUserVerified: true, |
| 63 | + automaticPresenceSimulation: true, |
| 64 | + }, |
| 65 | + }) |
| 66 | + |
| 67 | + return { |
| 68 | + session, |
| 69 | + authenticatorId: authenticator.authenticatorId, |
| 70 | + } |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +> 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. |
| 75 | +
|
| 76 | +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: |
| 77 | + |
| 78 | +- `session`, the CDP session reference we can use in the test to store our test passkey in the browser; |
| 79 | +- `authenticatorId`, the reference to the virtual authenticator we've created so the browser knows where to store our passkeys. |
| 80 | + |
| 81 | +With that, let's write our first test case that focuses on the successful authentication flow. |
| 82 | + |
| 83 | +```ts filename=tests/e2e/authentication-passkeys.test.ts highlight=9-11,13-18,20-30 |
| 84 | +import { createTestPasskey } from 'test-passkey' |
| 85 | +import { createPasskey, createUser } from '#tests/db-utils.ts' |
| 86 | + |
| 87 | +test('authenticates using an existing passkey', async ({ navigate, page }) => { |
| 88 | + await using user = await createUser() |
| 89 | + |
| 90 | + await navigate('/login') |
| 91 | + |
| 92 | + const passkey = createTestPasskey({ |
| 93 | + rpId: new URL(page.url()).hostname, |
| 94 | + }) |
| 95 | + |
| 96 | + await using _ = await createPasskey({ |
| 97 | + id: passkey.credential.credentialId, |
| 98 | + userId: user.id, |
| 99 | + aaguid: passkey.credential.aaguid || '', |
| 100 | + publicKey: passkey.publicKey, |
| 101 | + }) |
| 102 | + |
| 103 | + const { session, authenticatorId } = await createWebAuthnClient(page) |
| 104 | + await session.send('WebAuthn.addCredential', { |
| 105 | + authenticatorId, |
| 106 | + credential: { |
| 107 | + ...passkey.credential, |
| 108 | + isResidentCredential: true, |
| 109 | + userName: user.username, |
| 110 | + userHandle: btoa(user.id), |
| 111 | + userDisplayName: user.name ?? user.email, |
| 112 | + }, |
| 113 | + }) |
| 114 | + |
| 115 | + await page.getByRole('button', { name: 'Login with a passkey' }).click() |
| 116 | + |
| 117 | + await expect(page.getByRole('link', { name: user.name! })).toBeVisible() |
| 118 | +}) |
| 119 | +``` |
| 120 | + |
| 121 | +### Creating a test passkey |
| 122 | + |
| 123 | +```ts filename=tests/e2e/authentication-passkeys.test.ts |
| 124 | +await navigate('/login') |
| 125 | + |
| 126 | +const passkey = createTestPasskey({ |
| 127 | + rpId: new URL(page.url()).hostname, |
| 128 | +}) |
| 129 | +``` |
| 130 | + |
| 131 | +We are using the `createTestPasskey()` function from the [`test-passkey`](https://github.com/kettanaito/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. |
| 132 | + |
| 133 | +<callout-success>`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`.</callout-success> |
| 134 | + |
| 135 | +### Storing the public key |
| 136 | + |
| 137 | +Now that we have a test `passkey`, let's store its public part on the server, using the `createTestPasskey()` utility we've created earlier. |
| 138 | + |
| 139 | +```ts filename=tests/e2e/authentication-passkeys.test.ts highlight=8-11 |
| 140 | +const passkey = createTestPasskey({ |
| 141 | + rpId: new URL(page.url()).hostname, |
| 142 | +}) |
| 143 | + |
| 144 | +// ... |
| 145 | + |
| 146 | +await using _ = await createPasskey({ |
| 147 | + id: passkey.credential.credentialId, |
| 148 | + userId: user.id, |
| 149 | + aaguid: passkey.credential.aaguid || '', |
| 150 | + publicKey: passkey.publicKey, |
| 151 | +}) |
| 152 | +``` |
| 153 | + |
| 154 | +You can already spot the key-user collocation happening via `userId` and the passkey's `id` + `publicKey`. |
| 155 | + |
| 156 | +### Storing the private key |
| 157 | + |
| 158 | +Storing the private part of the test passkey will involve adding it to the virtual authenticator via the `createWebAuthnClient()` we've prepared. |
| 159 | + |
| 160 | +```ts filename=tests/e2e/authentication-passkeys.test.ts highlight=11,13-15 |
| 161 | +const passkey = createTestPasskey({ |
| 162 | + rpId: new URL(page.url()).hostname, |
| 163 | +}) |
| 164 | + |
| 165 | +// ... |
| 166 | + |
| 167 | +const { session, authenticatorId } = await createWebAuthnClient(page) |
| 168 | +await session.send('WebAuthn.addCredential', { |
| 169 | + authenticatorId, |
| 170 | + credential: { |
| 171 | + ...passkey.credential, |
| 172 | + isResidentCredential: true, |
| 173 | + userName: user.username, |
| 174 | + userHandle: btoa(user.id), |
| 175 | + userDisplayName: user.name ?? user.email, |
| 176 | + }, |
| 177 | +}) |
| 178 | +``` |
| 179 | + |
| 180 | +### Authenticating via passkeys |
| 181 | + |
| 182 | +All that remains now is to continue with the authentication flow, following the user's actions and describing the their expectations. |
| 183 | + |
| 184 | +```ts filename=tests/e2e/authentication-passkeys.test.ts |
| 185 | +await page.getByRole('button', { name: 'Login with a passkey' }).click() |
| 186 | + |
| 187 | +await expect(page.getByRole('link', { name: user.name! })).toBeVisible() |
| 188 | +``` |
| 189 | + |
| 190 | +Yes, this is literally it. The test case itself is a two-liner that delegates all the heavy-lifting to the test setup. |
| 191 | + |
| 192 | +## Failed authentication flow |
| 193 | + |
| 194 | +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. |
| 195 | + |
| 196 | +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. |
| 197 | + |
| 198 | +```ts filename=tests/e2e/authentication-passkeys.test.ts highlight=10 |
| 199 | +test('displays an error when authenticating via a passkey fails', async ({ |
| 200 | + navigate, |
| 201 | + page, |
| 202 | +}) => { |
| 203 | + await navigate('/login') |
| 204 | + |
| 205 | + const { session, authenticatorId } = await createWebAuthnClient(page) |
| 206 | + await session.send('WebAuthn.setUserVerified', { |
| 207 | + authenticatorId, |
| 208 | + isUserVerified: false, |
| 209 | + }) |
| 210 | + |
| 211 | + await page.getByRole('button', { name: 'Login with a passkey' }).click() |
| 212 | + |
| 213 | + await expect( |
| 214 | + page |
| 215 | + .getByRole('alert') |
| 216 | + .getByText( |
| 217 | + 'Failed to authenticate with passkey: The operation either timed out or was not allowed', |
| 218 | + ), |
| 219 | + ).toBeVisible() |
| 220 | +}) |
| 221 | +``` |
| 222 | + |
| 223 | +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. |
| 224 | + |
| 225 | +## Running the tests |
| 226 | + |
| 227 | +``` |
| 228 | +npm run test:e2e |
| 229 | +``` |
| 230 | + |
| 231 | +## Related materials |
| 232 | + |
| 233 | +- [`test-passkey`](https://github.com/kettanaito/test-passkey) |
| 234 | +- [Authentication via passkeys (MDN)](https://developer.mozilla.org/en-US/docs/Web/Security/Authentication/Passkeys) |
| 235 | +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) |
0 commit comments