-
Notifications
You must be signed in to change notification settings - Fork 5.7k
feat(credentials): add context.credentials WebAuthn virtual authenticator #40849
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| # class: Credentials | ||
| * since: v1.61 | ||
|
|
||
| `Credentials` provides a virtual WebAuthn authenticator scoped to a [BrowserContext]. It lets tests | ||
| seed credentials, intercept `navigator.credentials.create()` / `navigator.credentials.get()` calls | ||
| in pages, and complete WebAuthn ceremonies without a real authenticator. | ||
|
|
||
| Implemented in userland via an injected script, so it works across Chromium, Firefox and WebKit. | ||
|
|
||
| **Usage** | ||
|
|
||
| ```js | ||
| const context = await browser.newContext(); | ||
| await context.credentials.install(); | ||
| await context.credentials.create({ rpId: 'example.com' }); | ||
| const page = await context.newPage(); | ||
| await page.goto('https://example.com/login'); | ||
| // Page's navigator.credentials.get() will be answered using the seeded credential. | ||
| ``` | ||
|
|
||
| ## async method: Credentials.install | ||
| * since: v1.61 | ||
|
|
||
| Installs the virtual WebAuthn authenticator into the context, overriding | ||
| `navigator.credentials.create()` and `navigator.credentials.get()` in all current | ||
| and future pages. Call this before the page first touches `navigator.credentials`. | ||
|
|
||
| Required: until `install()` is called, no interception is in place and the page sees | ||
| the platform's native (or absent) WebAuthn behaviour. Seeding credentials with | ||
| [`method: Credentials.create`] without `install()` populates the registry but the | ||
| page will never see those credentials. | ||
|
|
||
| ## async method: Credentials.create | ||
| * since: v1.61 | ||
| - returns: <[Object]> | ||
| * alias: VirtualCredential | ||
| - `id` <[string]> Base64url-encoded credential id. | ||
| - `rpId` <[string]> Relying party id. | ||
| - `userHandle` <[string]> Base64url-encoded user handle. | ||
| - `privateKey` <[string]> Base64url-encoded PKCS#8 (DER) private key. | ||
| - `publicKey` <[string]> Base64url-encoded SPKI (DER) public key. | ||
|
|
||
| Seeds a virtual WebAuthn credential. With only `rpId`, generates a fresh ECDSA P-256 keypair, | ||
| credential id and user handle. To import a pre-registered credential (e.g. authenticating as an | ||
| existing test user the server already knows about), supply all four of `id`, `userHandle`, | ||
| `privateKey` and `publicKey` together. Call [`method: Credentials.install`] before navigating to a | ||
| page that uses WebAuthn. | ||
|
|
||
| ### param: Credentials.create.options | ||
| * since: v1.61 | ||
| - `options` <[Object]> | ||
| - `rpId` <[string]> Relying party id (typically the site's effective domain). | ||
| - `id` ?<[string]> Base64url-encoded credential id. Auto-generated if omitted. | ||
| - `userHandle` ?<[string]> Base64url-encoded user handle. Auto-generated if omitted. | ||
| - `privateKey` ?<[string]> Base64url-encoded PKCS#8 (DER) private key. Auto-generated if omitted. | ||
| - `publicKey` ?<[string]> Base64url-encoded SPKI (DER) public key. Auto-generated if omitted. | ||
|
|
||
| ## async method: Credentials.delete | ||
| * since: v1.61 | ||
|
|
||
| Removes a previously seeded credential. | ||
|
|
||
| ### param: Credentials.delete.id | ||
| * since: v1.61 | ||
| - `id` <[string]> | ||
|
|
||
| Base64url-encoded credential id. | ||
|
|
||
| ## async method: Credentials.get | ||
| * since: v1.61 | ||
| - returns: <[Array]<[Object]>> | ||
| * alias: VirtualCredential | ||
| - `id` <[string]> | ||
| - `rpId` <[string]> | ||
| - `userHandle` <[string]> | ||
| - `privateKey` <[string]> | ||
| - `publicKey` <[string]> | ||
|
|
||
| Returns seeded credentials, optionally filtered by `rpId` or `id`. | ||
|
|
||
| ### option: Credentials.get.rpId | ||
| * since: v1.61 | ||
| - `rpId` <[string]> | ||
|
|
||
| Only return credentials for this relying party id. | ||
|
|
||
| ### option: Credentials.get.id | ||
| * since: v1.61 | ||
| - `id` <[string]> | ||
|
|
||
| Only return the credential with this base64url-encoded id. | ||
|
|
||
| ## async method: Credentials.setUserVerified | ||
| * since: v1.61 | ||
|
|
||
| Toggles whether the virtual authenticator auto-approves user-verification prompts. Useful for | ||
| simulating a user denying biometric verification. | ||
|
|
||
| ### param: Credentials.setUserVerified.value | ||
| * since: v1.61 | ||
| - `value` <[boolean]> | ||
|
|
||
| `true` to auto-approve user verification (default), `false` to refuse. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { chromium } from 'playwright'; | ||
|
|
||
| (async () => { | ||
| const browser = await chromium.launch({ headless: false, slowMo: 250 }); | ||
| const context = await browser.newContext(); | ||
| await context.credentials.install(); | ||
| const page = await context.newPage(); | ||
| await page.goto('https://webauthn.io/'); | ||
| await page.locator('#input-email').fill(`pw-demo-${Date.now()}`); | ||
| await page.locator('#register-button').click(); | ||
| await page.getByText(/success!.*try to authenticate/i).waitFor(); | ||
|
|
||
| const seeded = await context.credentials.get(); | ||
| console.log(` ✓ registered: id=${seeded[0].id.substring(0, 12)}… rpId=${seeded[0].rpId}`); | ||
|
|
||
| await page.locator('#login-button').click(); | ||
| await page.getByText(/you're logged in/i).waitFor(); | ||
| console.log(` ✓ authenticated`); | ||
| await browser.close(); | ||
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| // Demonstrates `context.credentials.create()` — seeding a pre-existing | ||
| // credential into a fresh context. | ||
| // | ||
| // In a real test suite the keypair would live in a fixture file (saved once | ||
| // after registering a stable test user). Here we synthesise it by registering | ||
| // in context A, then importing into context B to authenticate without any | ||
| // further UI interaction. | ||
|
|
||
| import { chromium } from 'playwright'; | ||
|
|
||
| (async () => { | ||
| const browser = await chromium.launch({ headless: false, slowMo: 250 }); | ||
| const username = `pw-demo-${Date.now()}`; | ||
|
|
||
| // Context A: register so webauthn.io stores the credential server-side. | ||
| const contextA = await browser.newContext(); | ||
| await contextA.credentials.install(); | ||
| const pageA = await contextA.newPage(); | ||
| await pageA.goto('https://webauthn.io/'); | ||
| await pageA.locator('#input-email').fill(username); | ||
| await pageA.locator('#register-button').click(); | ||
| await pageA.getByText(/success!.*try to authenticate/i).waitFor(); | ||
| const [registered] = await contextA.credentials.get(); | ||
| await contextA.close(); | ||
| console.log(` ✓ registered ${username}: id=${registered.id.substring(0, 12)}…`); | ||
|
|
||
| // Context B: seed the same credential. webauthn.io's home page issues a | ||
| // discoverable `navigator.credentials.get()` on load — the seeded credential | ||
| // satisfies it and the site logs us in with no clicks needed. | ||
| const contextB = await browser.newContext(); | ||
| await contextB.credentials.install(); | ||
| await contextB.credentials.create(registered); | ||
| const pageB = await contextB.newPage(); | ||
| await pageB.goto('https://webauthn.io/'); | ||
| await pageB.getByText(/you're logged in/i).waitFor(); | ||
| console.log(` ✓ authenticated with seeded credential (no UI)`); | ||
|
|
||
| await browser.close(); | ||
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| // Demonstrates `context.credentials.setUserVerified()` — simulating a user | ||
| // refusing biometric verification (e.g. a wrong fingerprint). | ||
| // | ||
| // We configure webauthn.io to require user verification, then flip the | ||
| // authenticator's UV flag off and try to log in. The relying party sees | ||
| // UV=0 in the assertion flags and rejects the login. Flipping UV back on | ||
| // makes the next attempt succeed. | ||
|
|
||
| import { chromium } from 'playwright'; | ||
|
|
||
| (async () => { | ||
| const browser = await chromium.launch({ headless: false, slowMo: 250 }); | ||
| const context = await browser.newContext(); | ||
| await context.credentials.install(); | ||
| const page = await context.newPage(); | ||
| await page.goto('https://webauthn.io/'); | ||
|
|
||
| // Force the relying party to require user verification at both ends. | ||
| await page.locator('button:has-text("Advanced Settings")').click(); | ||
| await page.locator('#optRegUserVerification').selectOption('required'); | ||
| await page.locator('#optAuthUserVerification').selectOption('required'); | ||
|
|
||
| await page.locator('#input-email').fill(`pw-demo-${Date.now()}`); | ||
| await page.locator('#register-button').click(); | ||
| await page.getByText(/success!.*try to authenticate/i).waitFor(); | ||
| console.log(` ✓ registered (UV=required)`); | ||
|
|
||
| // Simulate failed biometric — UV bit will be 0 in the next assertion. | ||
| await context.credentials.setUserVerified(false); | ||
| await page.locator('#login-button').click(); | ||
| await page.getByText(/authentication failed/i).waitFor(); | ||
| console.log(` ✓ login rejected: server got UV=0 but required UV=1`); | ||
|
|
||
| // Recovery: biometric succeeds, UV=1 in the assertion. | ||
| await context.credentials.setUserVerified(true); | ||
| await page.locator('#login-button').click(); | ||
| await page.getByText(/you're logged in/i).waitFor(); | ||
| console.log(` ✓ login succeeded after UV restored`); | ||
|
|
||
| await browser.close(); | ||
| })(); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should playwright encode it itself?