diff --git a/CHANGELOG.md b/CHANGELOG.md index a52bccd..8d10e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/playwright_ex/channels/browser_context.ex b/lib/playwright_ex/channels/browser_context.ex index b355b58..7e4a8a7 100644 --- a/lib/playwright_ex/channels/browser_context.ex +++ b/lib/playwright_ex/channels/browser_context.ex @@ -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(), diff --git a/test/playwright_ex/browser_context_test.exs b/test/playwright_ex/browser_context_test.exs index b7180d1..fa02e0c 100644 --- a/test/playwright_ex/browser_context_test.exs +++ b/test/playwright_ex/browser_context_test.exs @@ -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 @@ -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: "
ok
") 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, _} =