diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index c5e504b484c49..e6c201b41bdc7 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1549,7 +1549,7 @@ Whether to emulate network being offline for the browser context. - `name` <[string]> - `value` <[string]> -Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot. +Returns storage state for this browser context, contains current cookies, local storage snapshot, IndexedDB snapshot and virtual WebAuthn credentials. ## async method: BrowserContext.storageState * since: v1.8 @@ -1566,10 +1566,20 @@ Returns storage state for this browser context, contains current cookies, local Set to `true` to include [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) in the storage state snapshot. If your application uses IndexedDB to store authentication tokens, like Firebase Authentication, enable this. +### option: BrowserContext.storageState.credentials +* since: v1.61 +- `credentials` ? + +Set to `true` to include the context's virtual WebAuthn [`property: BrowserContext.credentials`] (passkeys) in the storage +state snapshot. The captured credentials carry their private keys, so they can be re-seeded into a later context via the +[`option: Browser.newContext.storageState`] option or [`method: BrowserContext.setStorageState`]. + ## async method: BrowserContext.setStorageState * since: v1.59 -Clears the existing cookies, local storage and IndexedDB entries for all origins and sets the new storage state. +Clears the existing cookies, local storage, IndexedDB entries and virtual WebAuthn credentials, and sets the new storage +state. When the storage state contains credentials, the virtual WebAuthn authenticator is installed (equivalent to +[`method: Credentials.install`]). **Usage** diff --git a/docs/src/api/class-credentials.md b/docs/src/api/class-credentials.md index a99def42cc10f..98f4d641b80a5 100644 --- a/docs/src/api/class-credentials.md +++ b/docs/src/api/class-credentials.md @@ -10,8 +10,11 @@ There are two common ways to use it: - **Seed a known credential.** The passkey already exists — for example, your backend provisioned it for a test user. Import it with [`method: Credentials.create`] so the app under test can sign in right away. See the first example below. -- **Capture a credential, then reuse it.** Let the app register a passkey once in a setup test, - read it back with [`method: Credentials.get`], and seed it into later tests — the same way +- **Capture a passkey, then reuse it.** Let the app register a passkey once in a setup test and save + it as part of the [storage state](../auth.md) by passing `credentials: true` to + [`method: BrowserContext.storageState`]. When that state is supplied to a later context — via the + [`option: Browser.newContext.storageState`] option or [`method: BrowserContext.setStorageState`] — + the passkey is re-seeded and the authenticator installed automatically, the same way [`method: BrowserContext.storageState`] reuses signed-in state. See the second example below. **Usage: seed a known credential** @@ -37,7 +40,7 @@ await page.goto('https://example.com/login'); **Usage: capture a passkey, then reuse it** ```js -// setup test: let the app register a passkey, then save it. +// setup test: let the app register a passkey, then save the storage state with it. const context = await browser.newContext(); await context.credentials.install(); @@ -45,17 +48,13 @@ const page = await context.newPage(); await page.goto('https://example.com/register'); await page.getByRole('button', { name: 'Create a passkey' }).click(); -// Read back the passkey the page registered — it includes the private key. -const [credential] = await context.credentials.get({ rpId: 'example.com' }); -fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential)); +// The saved state includes the registered passkey, along with its private key. +await context.storageState({ path: 'playwright/.auth/state.json', credentials: true }); ``` ```js -// later test: seed the captured passkey so the app starts already enrolled. -const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8')); -const context = await browser.newContext(); -await context.credentials.create(credential); -await context.credentials.install(); +// later test: the captured passkey is re-seeded and the authenticator installed automatically. +const context = await browser.newContext({ storageState: 'playwright/.auth/state.json' }); const page = await context.newPage(); await page.goto('https://example.com/login'); diff --git a/docs/src/auth.md b/docs/src/auth.md index 36da17f1170d0..20d23d0247c9c 100644 --- a/docs/src/auth.md +++ b/docs/src/auth.md @@ -271,9 +271,9 @@ existing authentication state instead. Playwright provides a way to reuse the signed-in state in the tests. That way you can log in only once and then skip the log in step for all of the tests. -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. +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. -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. +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. The following code snippet retrieves state from an authenticated context and creates a new context with that state. @@ -397,75 +397,6 @@ export const test = baseTest.extend<{}, { workerStorageState: string }>({ }); ``` -### Passkeys (WebAuthn) -* langs: js - -**When to use** -- Your app signs users in with passkeys (WebAuthn), and you want tests to start already enrolled. - -**Details** - -[`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. - -If your backend already provisioned a passkey for the test user, seed it directly — no setup project required: - -```js title="playwright/fixtures.ts" -import { test as baseTest } from '@playwright/test'; -export * from '@playwright/test'; - -export const test = baseTest.extend({ - context: async ({ context }, use) => { - // A passkey your backend provisioned for the test user. - await context.credentials.create({ - rpId: 'example.com', - id: process.env.PASSKEY_ID, - userHandle: process.env.PASSKEY_USER_HANDLE, - privateKey: process.env.PASSKEY_PRIVATE_KEY, - publicKey: process.env.PASSKEY_PUBLIC_KEY, - }); - await context.credentials.install(); - await use(context); - }, -}); -``` - -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: - -```js title="tests/passkey.setup.ts" -import { test as setup } from '@playwright/test'; -import fs from 'fs'; - -setup('enroll passkey', async ({ context, page }) => { - await context.credentials.install(); - await page.goto('https://example.com/register'); - // The app calls navigator.credentials.create() to register the passkey. - await page.getByRole('button', { name: 'Create a passkey' }).click(); - - // Read back the registered passkey, including its private key, and save it. - const [credential] = await context.credentials.get({ rpId: 'example.com' }); - fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential)); -}); -``` - -Then seed the captured passkey into every test's context: - -```js title="playwright/fixtures.ts" -import { test as baseTest } from '@playwright/test'; -import fs from 'fs'; -export * from '@playwright/test'; - -export const test = baseTest.extend({ - context: async ({ context }, use) => { - const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8')); - await context.credentials.create(credential); - await context.credentials.install(); - await use(context); - }, -}); -``` - -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)). - ### Multiple signed in roles * langs: js @@ -657,7 +588,7 @@ test('admin and user', async ({ adminPage, userPage }) => { ### Session storage -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. +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. ```js // Get session storage and store as env variable diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index f03a833351206..6e389cf420fb2 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9571,7 +9571,9 @@ export interface BrowserContext { setOffline(offline: boolean): Promise; /** - * Clears the existing cookies, local storage and IndexedDB entries for all origins and sets the new storage state. + * Clears the existing cookies, local storage, IndexedDB entries and virtual WebAuthn credentials, and sets the new + * storage state. When the storage state contains credentials, the virtual WebAuthn authenticator is installed + * (equivalent to [credentials.install()](https://playwright.dev/docs/api/class-credentials#credentials-install)). * * **Usage** * @@ -9636,11 +9638,21 @@ export interface BrowserContext { }): Promise; /** - * Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB - * snapshot. + * Returns storage state for this browser context, contains current cookies, local storage snapshot, IndexedDB + * snapshot and virtual WebAuthn credentials. * @param options */ storageState(options?: { + /** + * Set to `true` to include the context's virtual WebAuthn + * [browserContext.credentials](https://playwright.dev/docs/api/class-browsercontext#browser-context-credentials) + * (passkeys) in the storage state snapshot. The captured credentials carry their private keys, so they can be + * re-seeded into a later context via the + * [`storageState`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-storage-state) option or + * [browserContext.setStorageState(storageState)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-storage-state). + */ + credentials?: boolean; + /** * Set to `true` to include [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) in the storage * state snapshot. If your application uses IndexedDB to store authentication tokens, like Firebase Authentication, @@ -18857,7 +18869,7 @@ export interface Coverage { * **Usage: capture a passkey, then reuse it** * * ```js - * // setup test: let the app register a passkey, then save it. + * // setup test: let the app register a passkey, then save the storage state with it. * const context = await browser.newContext(); * await context.credentials.install(); * @@ -18865,17 +18877,13 @@ export interface Coverage { * await page.goto('https://example.com/register'); * await page.getByRole('button', { name: 'Create a passkey' }).click(); * - * // Read back the passkey the page registered — it includes the private key. - * const [credential] = await context.credentials.get({ rpId: 'example.com' }); - * fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential)); + * // The saved state includes the registered passkey, along with its private key. + * await context.storageState({ path: 'playwright/.auth/state.json', credentials: true }); * ``` * * ```js - * // later test: seed the captured passkey so the app starts already enrolled. - * const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8')); - * const context = await browser.newContext(); - * await context.credentials.create(credential); - * await context.credentials.install(); + * // later test: the captured passkey is re-seeded and the authenticator installed automatically. + * const context = await browser.newContext({ storageState: 'playwright/.auth/state.json' }); * * const page = await context.newPage(); * await page.goto('https://example.com/login'); diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 31accbf0cb8cf..4fb2e591d19e7 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -457,8 +457,8 @@ export class BrowserContext extends ChannelOwner }); } - async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise { - const state = await this._channel.storageState({ indexedDB: options.indexedDB }); + async storageState(options: { path?: string, indexedDB?: boolean, credentials?: boolean } = {}): Promise { + const state = await this._channel.storageState({ indexedDB: options.indexedDB, credentials: options.credentials }); if (options.path) { await mkdirIfNeeded(this._platform, options.path); await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8'); diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index cb90ec932f987..40161f7d32da8 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -45,7 +45,8 @@ export type StorageState = { }; export type SetStorageState = { cookies?: channels.SetNetworkCookie[], - origins?: (Omit & { indexedDB?: unknown[] })[] + origins?: (Omit & { indexedDB?: unknown[] })[], + credentials?: unknown[], }; export type LifecycleEvent = channels.LifecycleEvent; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 523e59a669eb2..a1516feca870a 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -508,6 +508,7 @@ scheme.BrowserNewContextParams = tObject({ storageState: tOptional(tObject({ cookies: tOptional(tArray(tType('SetNetworkCookie'))), origins: tOptional(tArray(tType('SetOriginStorage'))), + credentials: tOptional(tArray(tType('VirtualCredential'))), })), }); scheme.BrowserNewContextResult = tObject({ @@ -585,6 +586,7 @@ scheme.BrowserNewContextForReuseParams = tObject({ storageState: tOptional(tObject({ cookies: tOptional(tArray(tType('SetNetworkCookie'))), origins: tOptional(tArray(tType('SetOriginStorage'))), + credentials: tOptional(tArray(tType('VirtualCredential'))), })), }); scheme.BrowserNewContextForReuseResult = tObject({ @@ -845,15 +847,18 @@ scheme.BrowserContextSetOfflineParams = tObject({ scheme.BrowserContextSetOfflineResult = tOptional(tObject({})); scheme.BrowserContextStorageStateParams = tObject({ indexedDB: tOptional(tBoolean), + credentials: tOptional(tBoolean), }); scheme.BrowserContextStorageStateResult = tObject({ cookies: tArray(tType('NetworkCookie')), origins: tArray(tType('OriginStorage')), + credentials: tOptional(tArray(tType('VirtualCredential'))), }); scheme.BrowserContextSetStorageStateParams = tObject({ storageState: tOptional(tObject({ cookies: tOptional(tArray(tType('SetNetworkCookie'))), origins: tOptional(tArray(tType('SetOriginStorage'))), + credentials: tOptional(tArray(tType('VirtualCredential'))), })), }); scheme.BrowserContextSetStorageStateResult = tOptional(tObject({})); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 788179e985121..d341267fc9579 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -245,7 +245,6 @@ export abstract class BrowserContext extends Sdk // Note: we only need to reset properties from the "paramsThatAllowContextReuse" list. // All other properties force a new context. await this.clock.uninstall(progress); - await this.credentials.dispose(progress); await progress.race(this.setUserAgent(this._options.userAgent)); await progress.race(this.doUpdateDefaultEmulatedMedia()); await progress.race(this.doUpdateDefaultViewport()); @@ -610,11 +609,13 @@ export abstract class BrowserContext extends Sdk this._origins.add(origin); } - async storageState(progress: Progress, indexedDB = false): Promise { + async storageState(progress: Progress, indexedDB = false, credentials = false): Promise { const result: channels.BrowserContextStorageStateResult = { cookies: await this.cookies(progress), origins: [] }; + if (credentials) + result.credentials = await progress.race(this.credentials.get()); const originsToSave = new Set(this._origins); const collectScript = `(() => { @@ -671,11 +672,21 @@ export abstract class BrowserContext extends Sdk if (mode !== 'initial') { await progress.race(this.clearCache()); await progress.race(this.doClearCookies()); + if (state?.credentials?.length) + this.credentials.clear(); + else + await this.credentials.dispose(progress); } if (state?.cookies) await progress.race(this.addCookies(state.cookies)); + if (state?.credentials?.length) { + for (const credential of state.credentials) + await progress.race(this.credentials.create(credential)); + await this.credentials.install(progress); + } + const newOrigins = new Map(state?.origins?.map(p => [p.origin, p]) || []); const allOrigins = new Set([...this._origins, ...newOrigins.keys()]); if (allOrigins.size) { diff --git a/packages/playwright-core/src/server/credentials.ts b/packages/playwright-core/src/server/credentials.ts index 1f70392e588cd..c4535ad18e1cd 100644 --- a/packages/playwright-core/src/server/credentials.ts +++ b/packages/playwright-core/src/server/credentials.ts @@ -20,7 +20,7 @@ import * as rawWebAuthnSource from '../generated/webAuthnSource'; import { nullProgress } from './progress'; import type { BrowserContext } from './browserContext'; -import type { InitScript } from './page'; +import type { InitScript, PageBinding } from './page'; import type { Progress } from '@protocol/progress'; const kBindingName = '__pwWebAuthnBinding'; @@ -41,8 +41,8 @@ type CredentialRecord = VirtualCredential & { export class Credentials { private _browserContext: BrowserContext; - private _initScripts: InitScript[] = []; - private _installed = false; + private _initScript: InitScript | undefined; + private _binding: PageBinding | undefined; private _registry = new Map(); constructor(browserContext: BrowserContext) { @@ -90,18 +90,26 @@ export class Credentials { this._registry.delete(id); } + clear() { + this._registry.clear(); + } + async dispose(progress: Progress) { - await progress.race(Promise.all(this._initScripts.map(s => s.dispose()))); - this._initScripts = []; - this._installed = false; + if (this._initScript) { + await progress.race(this._initScript.dispose()); + this._initScript = undefined; + } + if (this._binding) { + await progress.race(this._binding.dispose()); + this._binding = undefined; + } this._registry.clear(); } async install(progress: Progress) { - if (this._installed) + if (this._binding) return; - this._installed = true; - await this._browserContext.exposeBinding(progress, kBindingName, async (_source, payload: any) => { + this._binding = await this._browserContext.exposeBinding(progress, kBindingName, async (_source, payload: any) => { try { if (payload?.type === 'create') return await this._handleCreate(payload); @@ -117,8 +125,7 @@ export class Credentials { ${rawWebAuthnSource.source} module.exports.inject()(globalThis); })();`; - const initScript = await this._browserContext.addInitScript(nullProgress, script); - this._initScripts.push(initScript); + this._initScript = await this._browserContext.addInitScript(nullProgress, script); await progress.race(this._browserContext.safeNonStallingEvaluateInAllFrames(script, 'main', { throwOnJSErrors: false })); } diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 4db2f8adfd955..932c84cad7738 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -336,7 +336,7 @@ export class BrowserContextDispatcher extends Dispatcher { - return await this._context.storageState(progress, params.indexedDB); + return await this._context.storageState(progress, params.indexedDB, params.credentials); } async setStorageState(params: channels.BrowserContextSetStorageStateParams, progress: Progress): Promise { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index f03a833351206..6e389cf420fb2 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9571,7 +9571,9 @@ export interface BrowserContext { setOffline(offline: boolean): Promise; /** - * Clears the existing cookies, local storage and IndexedDB entries for all origins and sets the new storage state. + * Clears the existing cookies, local storage, IndexedDB entries and virtual WebAuthn credentials, and sets the new + * storage state. When the storage state contains credentials, the virtual WebAuthn authenticator is installed + * (equivalent to [credentials.install()](https://playwright.dev/docs/api/class-credentials#credentials-install)). * * **Usage** * @@ -9636,11 +9638,21 @@ export interface BrowserContext { }): Promise; /** - * Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB - * snapshot. + * Returns storage state for this browser context, contains current cookies, local storage snapshot, IndexedDB + * snapshot and virtual WebAuthn credentials. * @param options */ storageState(options?: { + /** + * Set to `true` to include the context's virtual WebAuthn + * [browserContext.credentials](https://playwright.dev/docs/api/class-browsercontext#browser-context-credentials) + * (passkeys) in the storage state snapshot. The captured credentials carry their private keys, so they can be + * re-seeded into a later context via the + * [`storageState`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-storage-state) option or + * [browserContext.setStorageState(storageState)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-storage-state). + */ + credentials?: boolean; + /** * Set to `true` to include [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) in the storage * state snapshot. If your application uses IndexedDB to store authentication tokens, like Firebase Authentication, @@ -18857,7 +18869,7 @@ export interface Coverage { * **Usage: capture a passkey, then reuse it** * * ```js - * // setup test: let the app register a passkey, then save it. + * // setup test: let the app register a passkey, then save the storage state with it. * const context = await browser.newContext(); * await context.credentials.install(); * @@ -18865,17 +18877,13 @@ export interface Coverage { * await page.goto('https://example.com/register'); * await page.getByRole('button', { name: 'Create a passkey' }).click(); * - * // Read back the passkey the page registered — it includes the private key. - * const [credential] = await context.credentials.get({ rpId: 'example.com' }); - * fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential)); + * // The saved state includes the registered passkey, along with its private key. + * await context.storageState({ path: 'playwright/.auth/state.json', credentials: true }); * ``` * * ```js - * // later test: seed the captured passkey so the app starts already enrolled. - * const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8')); - * const context = await browser.newContext(); - * await context.credentials.create(credential); - * await context.credentials.install(); + * // later test: the captured passkey is re-seeded and the authenticator installed automatically. + * const context = await browser.newContext({ storageState: 'playwright/.auth/state.json' }); * * const page = await context.newPage(); * await page.goto('https://example.com/login'); diff --git a/packages/protocol/spec/browser.yml b/packages/protocol/spec/browser.yml index d3c292b68fff8..e4bf036c470a6 100644 --- a/packages/protocol/spec/browser.yml +++ b/packages/protocol/spec/browser.yml @@ -79,6 +79,9 @@ Browser: origins: type: array? items: SetOriginStorage + credentials: + type: array? + items: VirtualCredential returns: context: BrowserContext @@ -102,6 +105,9 @@ Browser: origins: type: array? items: SetOriginStorage + credentials: + type: array? + items: VirtualCredential returns: context: BrowserContext diff --git a/packages/protocol/spec/browserContext.yml b/packages/protocol/spec/browserContext.yml index b930f2a6bc3ba..99ef56f71c996 100644 --- a/packages/protocol/spec/browserContext.yml +++ b/packages/protocol/spec/browserContext.yml @@ -180,6 +180,7 @@ BrowserContext: group: configuration parameters: indexedDB: boolean? + credentials: boolean? returns: cookies: type: array @@ -187,6 +188,9 @@ BrowserContext: origins: type: array items: OriginStorage + credentials: + type: array? + items: VirtualCredential setStorageState: title: Set storage state @@ -201,6 +205,9 @@ BrowserContext: origins: type: array? items: SetOriginStorage + credentials: + type: array? + items: VirtualCredential pause: title: Pause diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 9a19765c00ecb..1b0444141ce8e 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -977,6 +977,7 @@ export type BrowserNewContextParams = { storageState?: { cookies?: SetNetworkCookie[], origins?: SetOriginStorage[], + credentials?: VirtualCredential[], }, }; export type BrowserNewContextOptions = { @@ -1051,6 +1052,7 @@ export type BrowserNewContextOptions = { storageState?: { cookies?: SetNetworkCookie[], origins?: SetOriginStorage[], + credentials?: VirtualCredential[], }, }; export type BrowserNewContextResult = { @@ -1128,6 +1130,7 @@ export type BrowserNewContextForReuseParams = { storageState?: { cookies?: SetNetworkCookie[], origins?: SetOriginStorage[], + credentials?: VirtualCredential[], }, }; export type BrowserNewContextForReuseOptions = { @@ -1202,6 +1205,7 @@ export type BrowserNewContextForReuseOptions = { storageState?: { cookies?: SetNetworkCookie[], origins?: SetOriginStorage[], + credentials?: VirtualCredential[], }, }; export type BrowserNewContextForReuseResult = { @@ -1598,24 +1602,29 @@ export type BrowserContextSetOfflineOptions = { export type BrowserContextSetOfflineResult = void; export type BrowserContextStorageStateParams = { indexedDB?: boolean, + credentials?: boolean, }; export type BrowserContextStorageStateOptions = { indexedDB?: boolean, + credentials?: boolean, }; export type BrowserContextStorageStateResult = { cookies: NetworkCookie[], origins: OriginStorage[], + credentials?: VirtualCredential[], }; export type BrowserContextSetStorageStateParams = { storageState?: { cookies?: SetNetworkCookie[], origins?: SetOriginStorage[], + credentials?: VirtualCredential[], }, }; export type BrowserContextSetStorageStateOptions = { storageState?: { cookies?: SetNetworkCookie[], origins?: SetOriginStorage[], + credentials?: VirtualCredential[], }, }; export type BrowserContextSetStorageStateResult = void; diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index b8b03daeceb1c..3ec7d90436d1f 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -540,6 +540,47 @@ it('should support empty indexedDB', { annotation: { type: 'issue', description: expect(await context.storageState({ indexedDB: true })).toEqual(storageState); }); +it('should round-trip WebAuthn credentials with storageState', async ({ contextFactory, server }) => { + const context = await contextFactory(); + const credential = await context.credentials.create({ rpId: server.HOSTNAME }); + + // Credentials are opt-in, omitted by default. + expect(await context.storageState()).toEqual({ cookies: [], origins: [] }); + + const storageState = await context.storageState({ credentials: true }); + expect(storageState).toEqual({ cookies: [], origins: [], credentials: [credential] }); + + // A fresh context seeded from the storage state holds the same credential and round-trips equal. + const context2 = await contextFactory({ storageState }); + expect(await context2.credentials.get()).toEqual([credential]); + expect(await context2.storageState({ credentials: true })).toEqual(storageState); +}); + +it('setStorageState should replace credentials', async ({ contextFactory }) => { + const ctxA = await contextFactory(); + const credA = await ctxA.credentials.create({ rpId: 'a.example.com' }); + const stateA = await ctxA.storageState({ credentials: true }); + + const ctxB = await contextFactory(); + const credB = await ctxB.credentials.create({ rpId: 'b.example.com' }); + const stateB = await ctxB.storageState({ credentials: true }); + + const context = await contextFactory({ storageState: stateA }); + expect(await context.credentials.get()).toEqual([credA]); + + // Replacing the storage state swaps in the new credentials. + await context.setStorageState(stateB); + expect(await context.credentials.get()).toEqual([credB]); + + // A storage state without credentials clears them. + await context.setStorageState({ cookies: [], origins: [] }); + expect(await context.credentials.get()).toEqual([]); + + // Credentials can be installed again afterwards. + await context.setStorageState(stateA); + expect(await context.credentials.get()).toEqual([credA]); +}); + it('setStorageState should handle missing file', async ({ contextFactory }, testInfo) => { const context = await contextFactory(); const file = testInfo.outputPath('does-not-exist.json'); diff --git a/tests/library/browsercontext-webauthn.spec.ts b/tests/library/browsercontext-webauthn.spec.ts index 61409c5a27b77..189c9eda69528 100644 --- a/tests/library/browsercontext-webauthn.spec.ts +++ b/tests/library/browsercontext-webauthn.spec.ts @@ -157,3 +157,45 @@ it('should capture a page-created credential and reuse it in another context', a expect(gotId).toBe(createdId); }); + +it('should reuse a page-created credential via the storageState option', async ({ contextFactory, server }) => { + // Setup context: the app registers a passkey via navigator.credentials.create(). + const setupContext = await contextFactory(); + await setupContext.credentials.install(); + const setupPage = await setupContext.newPage(); + await setupPage.goto(server.EMPTY_PAGE); + + const createdId = await setupPage.evaluate(async ({ rpId }) => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const created = await navigator.credentials.create({ + publicKey: { + challenge, + rp: { id: rpId, name: 'Test RP' }, + user: { id: new Uint8Array([1, 2, 3, 4]), name: 'u', displayName: 'User' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, + }, + }) as PublicKeyCredential; + return created.id; + }, { rpId: server.HOSTNAME }); + + // Capture the passkey as part of the storage state. + const storageState = await setupContext.storageState({ credentials: true }); + + // A context created from the storage state has the authenticator installed and signs in with + // the captured passkey — without calling install() again. + const context = await contextFactory({ storageState }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + + const gotId = await page.evaluate(async ({ rpId }) => { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + // No allowCredentials — relies on the re-seeded credential being discoverable. + const cred = await navigator.credentials.get({ + publicKey: { challenge, rpId, userVerification: 'preferred' }, + }) as PublicKeyCredential; + return cred.id; + }, { rpId: server.HOSTNAME }); + + expect(gotId).toBe(createdId); +});