Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- `BrowserContext.credentials_install/2`, `credentials_create/3`, `credentials_get/2` and
`credentials_delete/3` wrap Playwright's virtual WebAuthn authenticator (`context.credentials`).

## [0.15.0] 2026-10-17

### Added
Expand Down
140 changes: 140 additions & 0 deletions lib/playwright_ex/channels/browser_context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,146 @@ defmodule PlaywrightEx.BrowserContext do
|> ChannelResponse.unwrap(& &1)
end

schema =
NimbleOptions.new!(
connection: PlaywrightEx.Channel.connection_opt(),
timeout: PlaywrightEx.Channel.timeout_opt()
)

@doc """
Installs the virtual WebAuthn authenticator into the context.

It overrides `navigator.credentials.create()` and `navigator.credentials.get()` in all current
and future pages. Playwright recommends calling it before a page first uses them. Credentials
seeded with `credentials_create/3` reach a page only once the authenticator is installed.

Reference: https://playwright.dev/docs/api/class-credentials#credentials-install

## Options
#{NimbleOptions.docs(schema)}
"""
@schema schema
@type credentials_install_opt :: unquote(NimbleOptions.option_typespec(schema))
@spec credentials_install(PlaywrightEx.guid(), [credentials_install_opt() | PlaywrightEx.unknown_opt()]) ::
{:ok, any()} | {:error, any()}
def credentials_install(context_id, opts \\ []) do
{connection, opts} = opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection)
{timeout, opts} = Keyword.pop!(opts, :timeout)

connection
|> Connection.send(%{guid: context_id, method: :credentials_install, params: Map.new(opts)}, timeout)
|> ChannelResponse.unwrap(& &1)
end

schema =
NimbleOptions.new!(
connection: PlaywrightEx.Channel.connection_opt(),
timeout: PlaywrightEx.Channel.timeout_opt(),
id: [type: :string, doc: "Base64url-encoded credential id. Generated if omitted."],
user_handle: [type: :string, doc: "Base64url-encoded user handle. Generated if omitted."],
private_key: [
type: :string,
doc:
"Base64url-encoded PKCS#8 (DER) private key. Pass it with `:public_key`; if either is missing, both are generated."
],
public_key: [
type: :string,
doc:
"Base64url-encoded SPKI (DER) public key. Pass it with `:private_key`; if either is missing, both are generated."
],
sign_count: [
type: {:in, 0..4_294_967_295},
doc: "Initial signature counter. Each successful assertion increments it. Defaults to `0`."
]
)

@doc """
Seeds a virtual WebAuthn credential for the relying party `rp_id` and returns it.

With no other options, generates an ECDSA P-256 key pair, a credential id and a user handle.
The credential is discoverable. To import a known credential, pass `:id`, `:user_handle`,
`:private_key` and `:public_key` together. The returned map carries the keys, so it can be stored
and seeded again.

Reference: https://playwright.dev/docs/api/class-credentials#credentials-create

## Options
#{NimbleOptions.docs(schema)}
"""
@schema schema
@type credentials_create_opt :: unquote(NimbleOptions.option_typespec(schema))
@spec credentials_create(PlaywrightEx.guid(), String.t(), [credentials_create_opt() | PlaywrightEx.unknown_opt()]) ::
{:ok, map()} | {:error, any()}
def credentials_create(context_id, rp_id, opts \\ []) do
{connection, opts} = opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection)
{timeout, opts} = Keyword.pop!(opts, :timeout)
params = opts |> Map.new() |> Map.put(:rp_id, rp_id)

connection
|> Connection.send(%{guid: context_id, method: :credentials_create, params: params}, timeout)
|> ChannelResponse.unwrap(& &1.credential)
end

schema =
NimbleOptions.new!(
connection: PlaywrightEx.Channel.connection_opt(),
timeout: PlaywrightEx.Channel.timeout_opt(),
id: [type: :string, doc: "Only return the credential with this base64url-encoded id."],
rp_id: [type: :string, doc: "Only return credentials for this relying party id."]
)

@doc """
Returns the credentials the virtual authenticator holds.

This includes credentials seeded with `credentials_create/3` and credentials a page registered
through `navigator.credentials.create()`.

Reference: https://playwright.dev/docs/api/class-credentials#credentials-get

## Options
#{NimbleOptions.docs(schema)}
"""
@schema schema
@type credentials_get_opt :: unquote(NimbleOptions.option_typespec(schema))
@spec credentials_get(PlaywrightEx.guid(), [credentials_get_opt() | PlaywrightEx.unknown_opt()]) ::
{:ok, [map()]} | {:error, any()}
def credentials_get(context_id, opts \\ []) do
{connection, opts} = opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection)
{timeout, opts} = Keyword.pop!(opts, :timeout)

connection
|> Connection.send(%{guid: context_id, method: :credentials_get, params: Map.new(opts)}, timeout)
|> ChannelResponse.unwrap(& &1.credentials)
end

schema =
NimbleOptions.new!(
connection: PlaywrightEx.Channel.connection_opt(),
timeout: PlaywrightEx.Channel.timeout_opt()
)

@doc """
Removes the credential with the base64url-encoded `id` from the virtual authenticator.

Reference: https://playwright.dev/docs/api/class-credentials#credentials-delete

## Options
#{NimbleOptions.docs(schema)}
"""
@schema schema
@type credentials_delete_opt :: unquote(NimbleOptions.option_typespec(schema))
@spec credentials_delete(PlaywrightEx.guid(), String.t(), [credentials_delete_opt() | PlaywrightEx.unknown_opt()]) ::
{:ok, any()} | {:error, any()}
def credentials_delete(context_id, id, opts \\ []) do
{connection, opts} = opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection)
{timeout, opts} = Keyword.pop!(opts, :timeout)
params = opts |> Map.new() |> Map.put(:id, id)

connection
|> Connection.send(%{guid: context_id, method: :credentials_delete, params: params}, timeout)
|> ChannelResponse.unwrap(& &1)
end

schema =
NimbleOptions.new!(
connection: PlaywrightEx.Channel.connection_opt(),
Expand Down
99 changes: 99 additions & 0 deletions test/playwright_ex/browser_context_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ defmodule PlaywrightEx.BrowserContextTest do
alias PlaywrightEx.BrowserContext
alias PlaywrightEx.Dialog
alias PlaywrightEx.Frame
alias PlaywrightEx.Page
alias PlaywrightEx.Route

describe "add_init_script/2" do
test "applies script to newly created pages", %{browser_context: browser_context} do
Expand Down Expand Up @@ -140,6 +142,103 @@ defmodule PlaywrightEx.BrowserContextTest do
end
end

describe "credentials" do
@credential %{
id: "Y3JlZGVudGlhbC1pZA",
rp_id: "playwright.example",
user_handle: "dXNlci1oYW5kbGU",
private_key:
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgd7APaIOC9SaWeXcnLaJvUwOtVFRIJEtAn7_XRNca-iehRANCAAR_QQZB2zOOZQEFMYs7F3fzikNpyFXuoNPyyqiJrhEpRrH4DgSS0IofC3rz5UiguK1yHJ_bh-hhSHA1lEQQO57j",
public_key:
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEf0EGQdszjmUBBTGLOxd384pDachV7qDT8sqoia4RKUax-A4EktCKHwt68-VIoLitchyf24foYUhwNZREEDue4w",
sign_count: 3
}

@register """
async () => {
const credential = await navigator.credentials.create({publicKey: {
challenge: new Uint8Array(16),
rp: {id: "playwright.example", name: "Example"},
user: {id: new Uint8Array(8), name: "ada", displayName: "Ada"},
pubKeyCredParams: [{type: "public-key", alg: -7}],
authenticatorSelection: {residentKey: "required"}
}})
return credential.type
}
"""

test "create seeds a credential that get returns and delete removes", %{browser_context: browser_context} do
assert {:ok, %{id: id, rp_id: "playwright.example", sign_count: 0} = credential} =
BrowserContext.credentials_create(browser_context.guid, "playwright.example", timeout: @timeout)

assert {:ok, [^credential]} = BrowserContext.credentials_get(browser_context.guid, timeout: @timeout)
assert {:ok, [^credential]} = BrowserContext.credentials_get(browser_context.guid, id: id, timeout: @timeout)

assert {:ok, []} =
BrowserContext.credentials_get(browser_context.guid, rp_id: "other.example", timeout: @timeout)

assert {:ok, _} = BrowserContext.credentials_delete(browser_context.guid, id, timeout: @timeout)
assert {:ok, []} = BrowserContext.credentials_get(browser_context.guid, timeout: @timeout)
end

test "create imports a known credential", %{browser_context: browser_context} do
{rp_id, known} = Map.pop(@credential, :rp_id)

assert {:ok, @credential} =
BrowserContext.credentials_create(browser_context.guid, rp_id, Keyword.new(known) ++ [timeout: @timeout])
end

test "install answers the page's navigator.credentials.create", %{browser_context: browser_context} = context do
assert {:ok, _} = BrowserContext.credentials_install(browser_context.guid, timeout: @timeout)
serve_secure_page(context)

assert {:ok, "public-key"} = eval(context.frame.guid, @register)

assert {:ok, [%{rp_id: "playwright.example"}]} =
BrowserContext.credentials_get(browser_context.guid, timeout: @timeout)
end

test "install answers the page's navigator.credentials.get with a seeded credential", context do
%{browser_context: browser_context} = context
{rp_id, known} = Map.pop(@credential, :rp_id)

assert {:ok, _} =
BrowserContext.credentials_create(browser_context.guid, rp_id, Keyword.new(known) ++ [timeout: @timeout])

assert {:ok, _} = BrowserContext.credentials_install(browser_context.guid, timeout: @timeout)
serve_secure_page(context)

assert {:ok, "Y3JlZGVudGlhbC1pZA"} =
eval(context.frame.guid, """
async () => {
const assertion = await navigator.credentials.get({publicKey: {
challenge: new Uint8Array(16),
rpId: "playwright.example"
}})
return assertion.id
}
""")

assert {:ok, [%{sign_count: 4}]} = BrowserContext.credentials_get(browser_context.guid, timeout: @timeout)
end

# Without an authenticator a headless ceremony never settles, so the test asks whether the
# function was replaced instead of waiting for it.
test "without install the page keeps the browser's own WebAuthn", context do
serve_secure_page(context)

assert {:ok, true} =
eval(context.frame.guid, "() => navigator.credentials.create.toString().includes('[native code]')")
end

# `navigator.credentials` exists only in a secure context, so the page needs an https origin.
defp serve_secure_page(%{page: page, frame: frame}) do
fulfill = fn route, _request -> Route.fulfill(route, content_type: "text/html", body: "<p>ok</p>") end
assert {:ok, _} = Page.route(page.guid, "**/*", fulfill, timeout: @timeout)
assert {:ok, _} = Frame.goto(frame.guid, url: "https://playwright.example/", timeout: @timeout)
end
end

describe "cookies" do
test "normalizes SameSite and regex clear filters", %{browser_context: browser_context} do
assert {:ok, _} =
Expand Down
Loading