Skip to content

Commit c071516

Browse files
committed
02/03: add exercise text
1 parent 02fafb6 commit c071516

3 files changed

Lines changed: 316 additions & 24 deletions

File tree

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
# Passkeys
22

3+
[Passkeys](https://developer.mozilla.org/en-US/docs/Web/Security/Authentication/Passkeys) is a type of a passwordless authentication that provides high security combined with great user experience. The user generates a passkey, then the browser stores its private part while the server stores the public one. Upon subsequent authentication, the browser-server exchange happens without any user involvement (e.g. having to enter a password), and as long as the user has a valid passkey on their device, they will be authenticated.
4+
5+
So given how innately secure this authentication method is, how does one even test it?
6+
37
## Your task
48

5-
- Write the `tests/e2e/authentication-passkey.test.ts` test suite.
6-
- Create a `createWebAuthnClient()` utility.
7-
- Install `test-passkey` as a dependency.
8-
- Create a `createPasskey()` utility.
9-
- Describe two test cases: successful and unsuccessul auth with passkeys.
9+
👨‍💼 Well, that is precisely your task in this exercise! And you problably already know what you're going to start with: the test setup. Passkeys introduce two additional elements to the flow:
10+
11+
- The private key (the browser part);
12+
- The public key (the server part).
13+
14+
Naturally, you'd have to create and store both of those in their correct places so the authentication could trigger as it normally does for your users.
15+
16+
🐨 Start by writing a `createPasskey()` utility. This will be your helper to generate reliastic passkeys for your test users. It will involve using the `test-passkey` library to help you out with that.
17+
18+
🐨 Then, in <InlineFile filename="./tests/e2e/authentication-passkeys.test.ts">`tests/e2e/authentication-passkeys.test.ts`</InlineFile>, work on another helper, this side for the browser side of things, called `createWebAuthnClient()`. You'll tap into Chrome DevTools Protocol and the underlying `WebAuthn` APIs to create a virtual authenticator to pair with your test passkey.
19+
20+
🐨 And, finally, having all the pieces you need, write a few test cases covering the passkey authentication flow. You are primarily interested in the following:
21+
22+
1. Successful authentication flow with a passkey;
23+
1. Failed authentication flow with a passkey (error handling).
24+
25+
Give it your best and see you on the other side!

exercises/02.authentication/03.problem.passkeys/tests/e2e/authentication-passkeys.test.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,59 @@ import { createPasskey, createUser } from '#tests/db-utils.ts'
44
import { test, expect } from '#tests/test-extend.ts'
55

66
async function createWebAuthnClient(page: Page) {
7-
/** @todo */
7+
// 🐨 💰
8+
// Create a new Chrome DevTools Protocol session on the given page
9+
// and assign it to a variable named `session`
10+
// 💰 await page.context().newCDPSession(page)
11+
//
12+
// 🐨 Create a virtual WebAuthn authenticator by sending the
13+
// "WebAuthn.addVirtualAuthenticator" messages to the session.
14+
// Store it as "authenticator".
15+
// 💰 await session.send('WebAuthn.addVirtualAuthenticator', options)
16+
//
17+
// 🐨 Provide the following options alongside the "addVirtualAuthenticator"
18+
// message:
19+
// - protocol: "ctap2"
20+
// - transport: "internal"
21+
// - hasResidentKey: true
22+
// - hasUserVerification: true
23+
// - isUserVerified: true
24+
// - automaticPresenceSimulation: true
25+
//
26+
// 🐨 Finally, return an object containing the `session` and the
27+
// `authenticatorId` (which you can get from the response of the
28+
// "addVirtualAuthenticator" message).
829
}
930

1031
test('authenticates using an existing passkey', async ({ navigate, page }) => {
32+
// 🐨 Create a test user with the "createUser" utility.
33+
// 💰 await using value = await fn()
34+
1135
await navigate('/login')
1236

13-
/** @todo */
37+
// 🐨 Create a test passkey by calling the "createTestPasskey" function
38+
// from the "test-passkey" package. Provide this page's hostname as the "rpId" option.
39+
// 💰 createTestPasskey({ rpId: new URL(page.url()).hostname })
40+
41+
// 🐨 Next, store the public part of the passkey on the server using the
42+
// "createPasskey" utility. Make sure to provide the following properties:
43+
// - id: passkey.credential.credentialId
44+
// - userId: the id of the user you just created
45+
// - aaguid: passkey.credential.aaguid
46+
// - publicKey: passkey.publicKey
47+
//
48+
// 💰 await using _ = await createPasskey({ ... })
49+
50+
// 🐨 Now, create a new virtual authenticator by calling the "createWebAuthnClient"
51+
// utility and providing it with the current "page".
52+
// 💰 const { session, authenticatorId } = await fn(args)
53+
54+
// 🐨 Locate a button with the text "Login with a passkey" and click it.
55+
// 💰 await page.getByRole(role, { name }).click()
56+
57+
// 🐨 Finally, write an expectation that a link with the text "user.name"
58+
// is visible on the page.
59+
// 💰 await expect(page.getByRole(role, { name })).toBeVisible()
1460
})
1561

1662
test('displays an error when authenticating via a passkey fails', async ({
@@ -19,5 +65,18 @@ test('displays an error when authenticating via a passkey fails', async ({
1965
}) => {
2066
await navigate('/login')
2167

22-
/** @todo */
68+
// 🐨 For this failure scenario, create a virtual authenticator by calling
69+
// the "createWebAuthnClient" utility function.
70+
// 💰 const { session, authenticatorId } = await fn(args)
71+
72+
// 🐨 Then, send the "WebAuthn.setUserVerified" message on the CDP session
73+
// with the "authenticatorId" and "isUserVerified: false" as options.
74+
// 💰 await session.send('WebAuthn.setUserVerified', options`)
75+
76+
// 🐨 Locate the "Login with a passkey" button and click it.
77+
78+
// 🐨 Add an assertion that the element with the role "alert" with a child
79+
// element containing the following text is visible on the page:
80+
// 💰 "Failed to authenticate with passkey: The operation either timed out or was not allowed"
81+
// 💰 await expect(page.getByRole(role).getByText(text)).toBeVisible()
2382
})
Lines changed: 233 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,235 @@
11
# Passkeys
22

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

Comments
 (0)