Skip to content

Commit ad60a2c

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 15d5c66 commit ad60a2c

17 files changed

Lines changed: 233 additions & 107 deletions

File tree

docs/src/api/class-browsercontext.md

Lines changed: 13 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,21 @@ 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+
Note that restoring the storage state that contains credentials will automatically install the virtual WebAuthn authenticator (see [`method: Credentials.install`]), and prevent all real authenticators from working in this context.
1577+
15691578
## async method: BrowserContext.setStorageState
15701579
* since: v1.59
15711580

1572-
Clears the existing cookies, local storage and IndexedDB entries for all origins and sets the new storage state.
1581+
Clears the existing cookies, local storage, IndexedDB entries and virtual WebAuthn credentials, and sets the new storage
1582+
state. When the storage state contains credentials, the virtual WebAuthn authenticator is installed (equivalent to
1583+
[`method: Credentials.install`]), preventing all real authenticators from working in this context.
15731584

15741585
**Usage**
15751586

docs/src/api/class-credentials.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
register passkeys and answer `navigator.credentials.create()` / `navigator.credentials.get()`
66
ceremonies in the page, without a real authenticator or hardware security key.
77

8-
There are two common ways to use it:
8+
There are three common ways to use it:
99

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.
1313
- **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
15-
[`method: BrowserContext.storageState`] reuses signed-in state. See the second example below.
14+
read it back with [`method: Credentials.get`], and seed it into later tests. See the second example below.
15+
- **Save credentials in the storage state, restore later.** Let the app register a passkey in a
16+
setup test and save it as part of the storage state by setting [`option: BrowserContext.storageState.credentials`]. See [authentication guide](../auth.md) for examples.
1617

1718
**Usage: seed a known credential**
1819

@@ -103,10 +104,10 @@ await page.GotoAsync("https://example.com/login");
103104
// The page's navigator.credentials.get() is answered with the seeded passkey.
104105
```
105106

106-
**Usage: capture a passkey, then reuse it**
107+
**Usage: capture a credential, then reuse it**
107108

108109
```js
109-
// setup test: let the app register a passkey, then save it.
110+
// setup test: let the app register a passkey, then save the storage state with it.
110111
const context = await browser.newContext();
111112
await context.credentials.install();
112113

@@ -264,6 +265,10 @@ await page.GotoAsync("https://example.com/login");
264265
// navigator.credentials.get() resolves the captured passkey — already signed in.
265266
```
266267

268+
**Usage: save credentials in the storage state, restore later**
269+
270+
See [authentication guide](../auth.md) for examples of using saving and resotring the storage state.
271+
267272
**Defaults**
268273

269274
- The authenticator presents itself as a **platform** authenticator (`authenticatorAttachment` is

docs/src/auth.md

Lines changed: 3 additions & 71 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,74 +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('example.com', {
420-
id: process.env.PASSKEY_ID,
421-
userHandle: process.env.PASSKEY_USER_HANDLE,
422-
privateKey: process.env.PASSKEY_PRIVATE_KEY,
423-
publicKey: process.env.PASSKEY_PUBLIC_KEY,
424-
});
425-
await context.credentials.install();
426-
await use(context);
427-
},
428-
});
429-
```
430-
431-
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:
432-
433-
```js title="tests/passkey.setup.ts"
434-
import { test as setup } from '@playwright/test';
435-
import fs from 'fs';
436-
437-
setup('enroll passkey', async ({ context, page }) => {
438-
await context.credentials.install();
439-
await page.goto('https://example.com/register');
440-
// The app calls navigator.credentials.create() to register the passkey.
441-
await page.getByRole('button', { name: 'Create a passkey' }).click();
442-
443-
// Read back the registered passkey, including its private key, and save it.
444-
const [credential] = await context.credentials.get({ rpId: 'example.com' });
445-
fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential));
446-
});
447-
```
448-
449-
Then seed the captured passkey into every test's context:
450-
451-
```js title="playwright/fixtures.ts"
452-
import { test as baseTest } from '@playwright/test';
453-
import fs from 'fs';
454-
export * from '@playwright/test';
455-
456-
export const test = baseTest.extend({
457-
context: async ({ context }, use) => {
458-
const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8'));
459-
await context.credentials.create(credential.rpId, credential);
460-
await context.credentials.install();
461-
await use(context);
462-
},
463-
});
464-
```
465-
466-
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)).
467-
468400
### Multiple signed in roles
469401
* langs: js
470402

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

657589
### Session storage
658590

659-
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.
660592

661593
```js
662594
// Get session storage and store as env variable

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9635,7 +9635,10 @@ export interface BrowserContext {
96359635
setOffline(offline: boolean): Promise<void>;
96369636

96379637
/**
9638-
* Clears the existing cookies, local storage and IndexedDB entries for all origins and sets the new storage state.
9638+
* Clears the existing cookies, local storage, IndexedDB entries and virtual WebAuthn credentials, and sets the new
9639+
* storage state. When the storage state contains credentials, the virtual WebAuthn authenticator is installed
9640+
* (equivalent to [credentials.install()](https://playwright.dev/docs/api/class-credentials#credentials-install)),
9641+
* preventing all real authenticators from working in this context.
96399642
*
96409643
* **Usage**
96419644
*
@@ -9700,11 +9703,24 @@ export interface BrowserContext {
97009703
}): Promise<void>;
97019704

97029705
/**
9703-
* Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
9704-
* snapshot.
9706+
* Returns storage state for this browser context, contains current cookies, local storage snapshot, IndexedDB
9707+
* snapshot and virtual WebAuthn credentials.
97059708
* @param options
97069709
*/
97079710
storageState(options?: {
9711+
/**
9712+
* Set to `true` to include the context's virtual WebAuthn
9713+
* [browserContext.credentials](https://playwright.dev/docs/api/class-browsercontext#browser-context-credentials)
9714+
* (passkeys) in the storage state snapshot. The captured credentials carry their private keys, so they can be
9715+
* re-seeded into a later context via the
9716+
* [`storageState`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-storage-state) option or
9717+
* [browserContext.setStorageState(storageState)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-storage-state).
9718+
* Note that restoring the storage state that contains credentials will automatically install the virtual WebAuthn
9719+
* authenticator (see [credentials.install()](https://playwright.dev/docs/api/class-credentials#credentials-install)),
9720+
* and prevent all real authenticators from working in this context.
9721+
*/
9722+
credentials?: boolean;
9723+
97089724
/**
97099725
* Set to `true` to include [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) in the storage
97109726
* state snapshot. If your application uses IndexedDB to store authentication tokens, like Firebase Authentication,
@@ -18888,7 +18904,7 @@ export interface Coverage {
1888818904
* `navigator.credentials.create()` / `navigator.credentials.get()` ceremonies in the page, without a real
1888918905
* authenticator or hardware security key.
1889018906
*
18891-
* There are two common ways to use it:
18907+
* There are three common ways to use it:
1889218908
*
1889318909
* **Usage: seed a known credential**
1889418910
*
@@ -18909,10 +18925,10 @@ export interface Coverage {
1890918925
* // The page's navigator.credentials.get() is answered with the seeded passkey.
1891018926
* ```
1891118927
*
18912-
* **Usage: capture a passkey, then reuse it**
18928+
* **Usage: capture a credential, then reuse it**
1891318929
*
1891418930
* ```js
18915-
* // setup test: let the app register a passkey, then save it.
18931+
* // setup test: let the app register a passkey, then save the storage state with it.
1891618932
* const context = await browser.newContext();
1891718933
* await context.credentials.install();
1891818934
*
@@ -18937,6 +18953,10 @@ export interface Coverage {
1893718953
* // navigator.credentials.get() resolves the captured passkey — already signed in.
1893818954
* ```
1893918955
*
18956+
* **Usage: save credentials in the storage state, restore later**
18957+
*
18958+
* See [authentication guide](https://playwright.dev/docs/auth) for examples of using saving and resotring the storage state.
18959+
*
1894018960
* **Defaults**
1894118961
*/
1894218962
export interface Credentials {

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

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

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

packages/playwright-core/src/client/channels.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,7 @@ export type BrowserNewContextParams = {
973973
storageState?: {
974974
cookies?: SetNetworkCookie[],
975975
origins?: SetOriginStorage[],
976+
credentials?: VirtualCredential[],
976977
},
977978
};
978979
export type BrowserNewContextOptions = {
@@ -1047,6 +1048,7 @@ export type BrowserNewContextOptions = {
10471048
storageState?: {
10481049
cookies?: SetNetworkCookie[],
10491050
origins?: SetOriginStorage[],
1051+
credentials?: VirtualCredential[],
10501052
},
10511053
};
10521054
export type BrowserNewContextResult = {
@@ -1124,6 +1126,7 @@ export type BrowserNewContextForReuseParams = {
11241126
storageState?: {
11251127
cookies?: SetNetworkCookie[],
11261128
origins?: SetOriginStorage[],
1129+
credentials?: VirtualCredential[],
11271130
},
11281131
};
11291132
export type BrowserNewContextForReuseOptions = {
@@ -1198,6 +1201,7 @@ export type BrowserNewContextForReuseOptions = {
11981201
storageState?: {
11991202
cookies?: SetNetworkCookie[],
12001203
origins?: SetOriginStorage[],
1204+
credentials?: VirtualCredential[],
12011205
},
12021206
};
12031207
export type BrowserNewContextForReuseResult = {
@@ -1594,24 +1598,29 @@ export type BrowserContextSetOfflineOptions = {
15941598
export type BrowserContextSetOfflineResult = void;
15951599
export type BrowserContextStorageStateParams = {
15961600
indexedDB?: boolean,
1601+
credentials?: boolean,
15971602
};
15981603
export type BrowserContextStorageStateOptions = {
15991604
indexedDB?: boolean,
1605+
credentials?: boolean,
16001606
};
16011607
export type BrowserContextStorageStateResult = {
16021608
cookies: NetworkCookie[],
16031609
origins: OriginStorage[],
1610+
credentials?: VirtualCredential[],
16041611
};
16051612
export type BrowserContextSetStorageStateParams = {
16061613
storageState?: {
16071614
cookies?: SetNetworkCookie[],
16081615
origins?: SetOriginStorage[],
1616+
credentials?: VirtualCredential[],
16091617
},
16101618
};
16111619
export type BrowserContextSetStorageStateOptions = {
16121620
storageState?: {
16131621
cookies?: SetNetworkCookie[],
16141622
origins?: SetOriginStorage[],
1623+
credentials?: VirtualCredential[],
16151624
},
16161625
};
16171626
export type BrowserContextSetStorageStateResult = void;

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({}));

0 commit comments

Comments
 (0)