Skip to content

Commit 7f66caf

Browse files
committed
feat(webauthn): include credentials in storageState
Capture the context's virtual WebAuthn credentials with `storageState({ credentials: true })`, and restore them (installing the authenticator) when a storage state is supplied via the `storageState` option or `setStorageState`.
1 parent dff0517 commit 7f66caf

16 files changed

Lines changed: 211 additions & 126 deletions

File tree

docs/src/api/class-browsercontext.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,7 +1549,7 @@ Whether to emulate network being offline for the browser context.
15491549
- `name` <[string]>
15501550
- `value` <[string]>
15511551

1552-
Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot.
1552+
Returns storage state for this browser context, contains current cookies, local storage snapshot, IndexedDB snapshot and virtual WebAuthn credentials.
15531553

15541554
## async method: BrowserContext.storageState
15551555
* since: v1.8
@@ -1566,10 +1566,20 @@ Returns storage state for this browser context, contains current cookies, local
15661566
Set to `true` to include [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) in the storage state snapshot.
15671567
If your application uses IndexedDB to store authentication tokens, like Firebase Authentication, enable this.
15681568

1569+
### option: BrowserContext.storageState.credentials
1570+
* since: v1.61
1571+
- `credentials` ?<boolean>
1572+
1573+
Set to `true` to include the context's virtual WebAuthn [`property: BrowserContext.credentials`] (passkeys) in the storage
1574+
state snapshot. The captured credentials carry their private keys, so they can be re-seeded into a later context via the
1575+
[`option: Browser.newContext.storageState`] option or [`method: BrowserContext.setStorageState`].
1576+
15691577
## async method: BrowserContext.setStorageState
15701578
* since: v1.59
15711579

1572-
Clears the existing cookies, local storage and IndexedDB entries for all origins and sets the new storage state.
1580+
Clears the existing cookies, local storage, IndexedDB entries and virtual WebAuthn credentials, and sets the new storage
1581+
state. When the storage state contains credentials, the virtual WebAuthn authenticator is installed (equivalent to
1582+
[`method: Credentials.install`]).
15731583

15741584
**Usage**
15751585

docs/src/api/class-credentials.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ There are two common ways to use it:
1010
- **Seed a known credential.** The passkey already exists — for example, your backend provisioned
1111
it for a test user. Import it with [`method: Credentials.create`] so the app under test can sign
1212
in right away. See the first example below.
13-
- **Capture a credential, then reuse it.** Let the app register a passkey once in a setup test,
14-
read it back with [`method: Credentials.get`], and seed it into later tests — the same way
13+
- **Capture a passkey, then reuse it.** Let the app register a passkey once in a setup test and save
14+
it as part of the [storage state](../auth.md) by passing `credentials: true` to
15+
[`method: BrowserContext.storageState`]. When that state is supplied to a later context — via the
16+
[`option: Browser.newContext.storageState`] option or [`method: BrowserContext.setStorageState`]
17+
the passkey is re-seeded and the authenticator installed automatically, the same way
1518
[`method: BrowserContext.storageState`] reuses signed-in state. See the second example below.
1619

1720
**Usage: seed a known credential**
@@ -37,25 +40,21 @@ await page.goto('https://example.com/login');
3740
**Usage: capture a passkey, then reuse it**
3841

3942
```js
40-
// setup test: let the app register a passkey, then save it.
43+
// setup test: let the app register a passkey, then save the storage state with it.
4144
const context = await browser.newContext();
4245
await context.credentials.install();
4346

4447
const page = await context.newPage();
4548
await page.goto('https://example.com/register');
4649
await page.getByRole('button', { name: 'Create a passkey' }).click();
4750

48-
// Read back the passkey the page registered — it includes the private key.
49-
const [credential] = await context.credentials.get({ rpId: 'example.com' });
50-
fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential));
51+
// The saved state includes the registered passkey, along with its private key.
52+
await context.storageState({ path: 'playwright/.auth/state.json', credentials: true });
5153
```
5254

5355
```js
54-
// later test: seed the captured passkey so the app starts already enrolled.
55-
const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8'));
56-
const context = await browser.newContext();
57-
await context.credentials.create(credential);
58-
await context.credentials.install();
56+
// later test: the captured passkey is re-seeded and the authenticator installed automatically.
57+
const context = await browser.newContext({ storageState: 'playwright/.auth/state.json' });
5958

6059
const page = await context.newPage();
6160
await page.goto('https://example.com/login');

docs/src/auth.md

Lines changed: 3 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,9 @@ existing authentication state instead.
271271
Playwright provides a way to reuse the signed-in state in the tests. That way you can log
272272
in only once and then skip the log in step for all of the tests.
273273

274-
Web apps use cookie-based or token-based authentication, where authenticated state is stored as [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), in [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage) or in [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API). Playwright provides [`method: BrowserContext.storageState`] method that can be used to retrieve storage state from authenticated contexts and then create new contexts with prepopulated state.
274+
Web apps use cookie-based or token-based authentication, where authenticated state is stored as [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), in [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage), in [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API), or as passkeys ([WebAuthn](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) credentials). Playwright provides [`method: BrowserContext.storageState`] method that can be used to retrieve storage state from authenticated contexts and then create new contexts with prepopulated state.
275275

276-
Cookies, local storage and IndexedDB state can be used across different browsers. They depend on your application's authentication model which may require some combination of cookies, local storage or IndexedDB.
276+
Cookies, local storage, IndexedDB and virtual WebAuthn credentials (passkeys) can be used across different browsers. They depend on your application's authentication model which may require some combination of cookies, local storage, IndexedDB or passkeys.
277277

278278
The following code snippet retrieves state from an authenticated context and creates a new context with that state.
279279

@@ -397,75 +397,6 @@ export const test = baseTest.extend<{}, { workerStorageState: string }>({
397397
});
398398
```
399399

400-
### Passkeys (WebAuthn)
401-
* langs: js
402-
403-
**When to use**
404-
- Your app signs users in with passkeys (WebAuthn), and you want tests to start already enrolled.
405-
406-
**Details**
407-
408-
[`property: BrowserContext.credentials`] is a virtual WebAuthn authenticator. Unlike cookie or local storage state, a passkey is seeded **imperatively** with [`method: Credentials.create`] and [`method: Credentials.install`], so it lives in a [`context` fixture override](./test-fixtures.md#overriding-fixtures) rather than in the `storageState` config option.
409-
410-
If your backend already provisioned a passkey for the test user, seed it directly — no setup project required:
411-
412-
```js title="playwright/fixtures.ts"
413-
import { test as baseTest } from '@playwright/test';
414-
export * from '@playwright/test';
415-
416-
export const test = baseTest.extend({
417-
context: async ({ context }, use) => {
418-
// A passkey your backend provisioned for the test user.
419-
await context.credentials.create({
420-
rpId: 'example.com',
421-
id: process.env.PASSKEY_ID,
422-
userHandle: process.env.PASSKEY_USER_HANDLE,
423-
privateKey: process.env.PASSKEY_PRIVATE_KEY,
424-
publicKey: process.env.PASSKEY_PUBLIC_KEY,
425-
});
426-
await context.credentials.install();
427-
await use(context);
428-
},
429-
});
430-
```
431-
432-
Otherwise, let the app register a passkey once in a [setup project](#basic-shared-account-in-all-tests), capture it with [`method: Credentials.get`], and save it to disk:
433-
434-
```js title="tests/passkey.setup.ts"
435-
import { test as setup } from '@playwright/test';
436-
import fs from 'fs';
437-
438-
setup('enroll passkey', async ({ context, page }) => {
439-
await context.credentials.install();
440-
await page.goto('https://example.com/register');
441-
// The app calls navigator.credentials.create() to register the passkey.
442-
await page.getByRole('button', { name: 'Create a passkey' }).click();
443-
444-
// Read back the registered passkey, including its private key, and save it.
445-
const [credential] = await context.credentials.get({ rpId: 'example.com' });
446-
fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential));
447-
});
448-
```
449-
450-
Then seed the captured passkey into every test's context:
451-
452-
```js title="playwright/fixtures.ts"
453-
import { test as baseTest } from '@playwright/test';
454-
import fs from 'fs';
455-
export * from '@playwright/test';
456-
457-
export const test = baseTest.extend({
458-
context: async ({ context }, use) => {
459-
const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8'));
460-
await context.credentials.create(credential);
461-
await context.credentials.install();
462-
await use(context);
463-
},
464-
});
465-
```
466-
467-
Declare the `setup` project as a [dependency](./test-projects.md#dependencies) of your testing projects, just like in the [basic flow](#basic-shared-account-in-all-tests). The saved `passkey.json` contains a private key, so keep it under `playwright/.auth` and out of source control (see [Core concepts](#core-concepts)).
468-
469400
### Multiple signed in roles
470401
* langs: js
471402

@@ -657,7 +588,7 @@ test('admin and user', async ({ adminPage, userPage }) => {
657588

658589
### Session storage
659590

660-
Reusing authenticated state covers [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage) and [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) based authentication. Rarely, [session storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) is used for storing information associated with the signed-in state. Session storage is specific to a particular domain and is not persisted across page loads. Playwright does not provide API to persist session storage, but the following snippet can be used to save/load session storage.
591+
Reusing authenticated state covers [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage), [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) and passkey ([WebAuthn](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API)) based authentication. Rarely, [session storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) is used for storing information associated with the signed-in state. Session storage is specific to a particular domain and is not persisted across page loads. Playwright does not provide API to persist session storage, but the following snippet can be used to save/load session storage.
661592

662593
```js
663594
// Get session storage and store as env variable

packages/playwright-client/types/types.d.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9571,7 +9571,9 @@ export interface BrowserContext {
95719571
setOffline(offline: boolean): Promise<void>;
95729572

95739573
/**
9574-
* Clears the existing cookies, local storage and IndexedDB entries for all origins and sets the new storage state.
9574+
* Clears the existing cookies, local storage, IndexedDB entries and virtual WebAuthn credentials, and sets the new
9575+
* storage state. When the storage state contains credentials, the virtual WebAuthn authenticator is installed
9576+
* (equivalent to [credentials.install()](https://playwright.dev/docs/api/class-credentials#credentials-install)).
95759577
*
95769578
* **Usage**
95779579
*
@@ -9636,11 +9638,21 @@ export interface BrowserContext {
96369638
}): Promise<void>;
96379639

96389640
/**
9639-
* Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
9640-
* snapshot.
9641+
* Returns storage state for this browser context, contains current cookies, local storage snapshot, IndexedDB
9642+
* snapshot and virtual WebAuthn credentials.
96419643
* @param options
96429644
*/
96439645
storageState(options?: {
9646+
/**
9647+
* Set to `true` to include the context's virtual WebAuthn
9648+
* [browserContext.credentials](https://playwright.dev/docs/api/class-browsercontext#browser-context-credentials)
9649+
* (passkeys) in the storage state snapshot. The captured credentials carry their private keys, so they can be
9650+
* re-seeded into a later context via the
9651+
* [`storageState`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-storage-state) option or
9652+
* [browserContext.setStorageState(storageState)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-storage-state).
9653+
*/
9654+
credentials?: boolean;
9655+
96449656
/**
96459657
* Set to `true` to include [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) in the storage
96469658
* state snapshot. If your application uses IndexedDB to store authentication tokens, like Firebase Authentication,
@@ -18857,25 +18869,21 @@ export interface Coverage {
1885718869
* **Usage: capture a passkey, then reuse it**
1885818870
*
1885918871
* ```js
18860-
* // setup test: let the app register a passkey, then save it.
18872+
* // setup test: let the app register a passkey, then save the storage state with it.
1886118873
* const context = await browser.newContext();
1886218874
* await context.credentials.install();
1886318875
*
1886418876
* const page = await context.newPage();
1886518877
* await page.goto('https://example.com/register');
1886618878
* await page.getByRole('button', { name: 'Create a passkey' }).click();
1886718879
*
18868-
* // Read back the passkey the page registered — it includes the private key.
18869-
* const [credential] = await context.credentials.get({ rpId: 'example.com' });
18870-
* fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential));
18880+
* // The saved state includes the registered passkey, along with its private key.
18881+
* await context.storageState({ path: 'playwright/.auth/state.json', credentials: true });
1887118882
* ```
1887218883
*
1887318884
* ```js
18874-
* // later test: seed the captured passkey so the app starts already enrolled.
18875-
* const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8'));
18876-
* const context = await browser.newContext();
18877-
* await context.credentials.create(credential);
18878-
* await context.credentials.install();
18885+
* // later test: the captured passkey is re-seeded and the authenticator installed automatically.
18886+
* const context = await browser.newContext({ storageState: 'playwright/.auth/state.json' });
1887918887
*
1888018888
* const page = await context.newPage();
1888118889
* await page.goto('https://example.com/login');

packages/playwright-core/src/client/browserContext.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,8 +457,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
457457
});
458458
}
459459

460-
async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise<StorageState> {
461-
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
460+
async storageState(options: { path?: string, indexedDB?: boolean, credentials?: boolean } = {}): Promise<StorageState> {
461+
const state = await this._channel.storageState({ indexedDB: options.indexedDB, credentials: options.credentials });
462462
if (options.path) {
463463
await mkdirIfNeeded(this._platform, options.path);
464464
await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8');

packages/playwright-core/src/client/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export type StorageState = {
4545
};
4646
export type SetStorageState = {
4747
cookies?: channels.SetNetworkCookie[],
48-
origins?: (Omit<channels.SetOriginStorage, 'indexedDB'> & { indexedDB?: unknown[] })[]
48+
origins?: (Omit<channels.SetOriginStorage, 'indexedDB'> & { indexedDB?: unknown[] })[],
49+
credentials?: unknown[],
4950
};
5051

5152
export type LifecycleEvent = channels.LifecycleEvent;

packages/playwright-core/src/protocol/validator.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ scheme.BrowserNewContextParams = tObject({
508508
storageState: tOptional(tObject({
509509
cookies: tOptional(tArray(tType('SetNetworkCookie'))),
510510
origins: tOptional(tArray(tType('SetOriginStorage'))),
511+
credentials: tOptional(tArray(tType('VirtualCredential'))),
511512
})),
512513
});
513514
scheme.BrowserNewContextResult = tObject({
@@ -585,6 +586,7 @@ scheme.BrowserNewContextForReuseParams = tObject({
585586
storageState: tOptional(tObject({
586587
cookies: tOptional(tArray(tType('SetNetworkCookie'))),
587588
origins: tOptional(tArray(tType('SetOriginStorage'))),
589+
credentials: tOptional(tArray(tType('VirtualCredential'))),
588590
})),
589591
});
590592
scheme.BrowserNewContextForReuseResult = tObject({
@@ -845,15 +847,18 @@ scheme.BrowserContextSetOfflineParams = tObject({
845847
scheme.BrowserContextSetOfflineResult = tOptional(tObject({}));
846848
scheme.BrowserContextStorageStateParams = tObject({
847849
indexedDB: tOptional(tBoolean),
850+
credentials: tOptional(tBoolean),
848851
});
849852
scheme.BrowserContextStorageStateResult = tObject({
850853
cookies: tArray(tType('NetworkCookie')),
851854
origins: tArray(tType('OriginStorage')),
855+
credentials: tOptional(tArray(tType('VirtualCredential'))),
852856
});
853857
scheme.BrowserContextSetStorageStateParams = tObject({
854858
storageState: tOptional(tObject({
855859
cookies: tOptional(tArray(tType('SetNetworkCookie'))),
856860
origins: tOptional(tArray(tType('SetOriginStorage'))),
861+
credentials: tOptional(tArray(tType('VirtualCredential'))),
857862
})),
858863
});
859864
scheme.BrowserContextSetStorageStateResult = tOptional(tObject({}));

packages/playwright-core/src/server/browserContext.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,6 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
245245
// Note: we only need to reset properties from the "paramsThatAllowContextReuse" list.
246246
// All other properties force a new context.
247247
await this.clock.uninstall(progress);
248-
await this.credentials.dispose(progress);
249248
await progress.race(this.setUserAgent(this._options.userAgent));
250249
await progress.race(this.doUpdateDefaultEmulatedMedia());
251250
await progress.race(this.doUpdateDefaultViewport());
@@ -610,11 +609,13 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
610609
this._origins.add(origin);
611610
}
612611

613-
async storageState(progress: Progress, indexedDB = false): Promise<channels.BrowserContextStorageStateResult> {
612+
async storageState(progress: Progress, indexedDB = false, credentials = false): Promise<channels.BrowserContextStorageStateResult> {
614613
const result: channels.BrowserContextStorageStateResult = {
615614
cookies: await this.cookies(progress),
616615
origins: []
617616
};
617+
if (credentials)
618+
result.credentials = await progress.race(this.credentials.get());
618619
const originsToSave = new Set(this._origins);
619620

620621
const collectScript = `(() => {
@@ -671,11 +672,21 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
671672
if (mode !== 'initial') {
672673
await progress.race(this.clearCache());
673674
await progress.race(this.doClearCookies());
675+
if (state?.credentials?.length)
676+
this.credentials.clear();
677+
else
678+
await this.credentials.dispose(progress);
674679
}
675680

676681
if (state?.cookies)
677682
await progress.race(this.addCookies(state.cookies));
678683

684+
if (state?.credentials?.length) {
685+
for (const credential of state.credentials)
686+
await progress.race(this.credentials.create(credential));
687+
await this.credentials.install(progress);
688+
}
689+
679690
const newOrigins = new Map(state?.origins?.map(p => [p.origin, p]) || []);
680691
const allOrigins = new Set([...this._origins, ...newOrigins.keys()]);
681692
if (allOrigins.size) {

0 commit comments

Comments
 (0)