Skip to content
Open
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
155 changes: 155 additions & 0 deletions documentation/tutorials/webauthn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<!--
SPDX-FileCopyrightText: 2026 Alembic Pty Ltd

SPDX-License-Identifier: MIT
-->

# WebAuthn / Passkey Authentication

WebAuthn lets users sign in with hardware security keys (YubiKey), platform authenticators (Touch ID, Windows Hello, Face ID), or passkeys. This guide covers end-to-end setup: backend strategy, Phoenix components, and the JavaScript hooks required for the WebAuthn ceremony.

## Overview

WebAuthn authentication has more moving parts than other strategies because the browser participates in the cryptographic ceremony. At a high level:

1. The server issues a **challenge** (registration or authentication).
2. The browser invokes `navigator.credentials.create` / `.get` with the challenge.
3. The authenticator (hardware key, platform biometric) signs the challenge.
4. The server verifies the signed response and creates/authenticates the user.

`ash_authentication_phoenix` provides the Phoenix components and JavaScript hooks that drive this flow against an `AshAuthentication.Strategy.WebAuthn` backend.

## Prerequisites

Before using the WebAuthn UI components, you need to configure the WebAuthn strategy in your user resource. See the [AshAuthentication WebAuthn guide](https://hexdocs.pm/ash_authentication/webauthn.html) for the backend setup.

At minimum, your user resource should have a WebAuthn strategy configured with a credential resource:

```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication],
domain: MyApp.Accounts

authentication do
strategies do
webauthn do
credential_resource MyApp.Accounts.WebAuthnCredential
rp_id "localhost"
rp_name "MyApp"
registration_enabled? true
end
end
end
end
```

The `credential_resource` is a separate Ash resource that stores each registered credential (public key, sign count, label, etc.).

## Router setup

WebAuthn uses the same `sign_in_route` macro as other strategies. No additional configuration is needed — the `SignIn` component auto-discovers the WebAuthn strategy and renders the appropriate child component:

```elixir
defmodule MyAppWeb.Router do
use MyAppWeb, :router
use AshAuthentication.Phoenix.Router

pipeline :browser do
# ...
end

scope "/", MyAppWeb do
pipe_through :browser

sign_in_route auth_routes_prefix: "/auth", on_mount: [{MyAppWeb.LiveUserAuth, :live_no_user}]
end
end
```

## JavaScript hooks

WebAuthn requires LiveView hooks to invoke the browser's credential APIs. You must register three hooks in your `assets/js/app.js`:

```javascript
import {
WebAuthnRegistrationHook,
WebAuthnAuthenticationHook,
WebAuthnSupportHook
} from "ash_authentication_phoenix/priv/static/webauthn_hooks.js"

const Hooks = {
WebAuthnRegistrationHook,
WebAuthnAuthenticationHook,
WebAuthnSupportHook
}

const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks
})
```

Each hook drives a different part of the ceremony:

- **`WebAuthnSupportHook`** — Detects whether the browser supports WebAuthn and conditionally shows/hides the passkey UI.
- **`WebAuthnRegistrationHook`** — Handles the `navigator.credentials.create` call during registration (new passkey).
- **`WebAuthnAuthenticationHook`** — Handles the `navigator.credentials.get` call during sign-in.

The hooks communicate with the server via `push_event` / `handle_event` — you don't need to write any custom JavaScript.

## Origin and `rp_id` configuration

The `rp_id` (Relying Party ID) must be a **hostname only** — never include the scheme or port. The browser derives the effective domain from the page origin and checks that the `rp_id` is either equal to it or a registrable suffix of it.

| Environment | Page URL | Correct `rp_id` | Common mistake |
| ----------- | ---------------------------- | -------------------- | ------------------------------ |
| Local dev | `http://localhost:4000` | `"localhost"` | `"localhost:4000"` (has port) |
| Staging | `https://staging.example.com` | `"staging.example.com"` or `"example.com"` | `"https://staging.example.com"` (has scheme) |
| Production | `https://example.com` | `"example.com"` | `"https://example.com"` |

**Local development gotcha**: Phoenix serves on `http://localhost:4000` by default, and it's tempting to set `rp_id "localhost:4000"` to "match" that. Don't — WebAuthn will reject it with a `SecurityError` because `rp_id` is a [domain string](https://www.w3.org/TR/webauthn-2/#rp-id), not an origin. The port (and scheme) are part of the origin, which the browser validates separately; `rp_id` is only the domain. Use `"localhost"` in dev and let the browser handle the port match on its own.

WebAuthn over plain HTTP is only allowed when the origin is `localhost` or `127.0.0.1` — any other hostname requires HTTPS, even in development.

Credentials registered against one `rp_id` cannot be used with a different one — changing it invalidates existing credentials. Pick your production `rp_id` carefully (bare apex like `"example.com"` is usually safer than `"www.example.com"` because it covers subdomains).

## Credential management

The library ships with a `ManageCredentials` component that lets authenticated users add, rename, and remove their passkeys:

```heex
<.live_component
module={AshAuthentication.Phoenix.Components.WebAuthn.ManageCredentials}
id="webauthn-credentials"
strategy={@webauthn_strategy}
current_user={@current_user}
/>
```

Deletion of the last credential is prevented to avoid locking users out of their accounts. All credential operations route through `AshAuthentication.Strategy.WebAuthn.Actions`, so policies, hooks, and validations defined on the credential resource are honored.

## Customization

All WebAuthn components support the standard override mechanism. You can customize button text, CSS classes, and icons via your overrides module:

```elixir
defmodule MyAppWeb.AuthOverrides do
use AshAuthentication.Phoenix.Overrides

override AshAuthentication.Phoenix.Components.WebAuthn.AuthenticationForm do
set :button_text, "Sign in with your security key"
end
end
```

See [UI Overrides](ui-overrides.md) for the full list of overridable slots.

## Troubleshooting

- **"SecurityError" / "The relying party ID is not a registrable domain suffix"** — Your `rp_id` includes a port (e.g. `"localhost:4000"`) or a scheme (e.g. `"https://example.com"`). Strip it to the bare hostname.
- **"NotAllowedError" in the browser** — Usually a mismatch between `rp_id` and the page origin, or the user cancelled the prompt.
- **WebAuthn prompt never appears in dev** — You're serving over plain HTTP from a hostname other than `localhost` / `127.0.0.1`. Either use `localhost` or run dev over HTTPS.
- **Credentials missing after deploy** — The `rp_id` likely changed. Credentials are bound to the exact `rp_id` they were registered under.
- **Hooks not firing** — Verify all three hooks are registered in `app.js` and that the LiveView is using them. Check the browser console for hook initialization errors.
- **"Failed to register new key"** — Check that the `credential_resource` exists and that the `:create` action accepts the credential attributes.
1 change: 1 addition & 0 deletions lib/ash_authentication_phoenix/components/sign_in.ex
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ defmodule AshAuthentication.Phoenix.Components.SignIn do
defp strategy_style(%Strategy.MagicLink{}), do: :link
defp strategy_style(%Strategy.Totp{}), do: :form
defp strategy_style(%Strategy.RememberMe{}), do: nil
defp strategy_style(%AshAuthentication.Strategy.WebAuthn{}), do: :form
defp strategy_style(_), do: :link

defp component_for_strategy(%{strategy_module: Strategy.Apple}), do: Components.Apple
Expand Down
202 changes: 202 additions & 0 deletions lib/ash_authentication_phoenix/components/webauthn.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT

defmodule AshAuthentication.Phoenix.Components.WebAuthn do
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element.",
hide_class: "CSS class to apply to hide an element.",
show_first:
"The form to show on first load. Either `:sign_in` or `:register`. Only relevant if paths aren't set for them in the router.",
interstitial_class: "CSS class for the `div` element between the form and the toggle.",
sign_in_toggle_text:
"Toggle text to display when the sign in form is not showing (or `nil` to disable).",
register_toggle_text:
"Toggle text to display when the register form is not showing (or `nil` to disable).",
toggler_class: "CSS class for the toggler `a` element.",
registration_form_module:
"The Phoenix component to be used for the registration form. Defaults to `AshAuthentication.Phoenix.Components.WebAuthn.RegistrationForm`.",
authentication_form_module:
"The Phoenix component to be used for the authentication form. Defaults to `AshAuthentication.Phoenix.Components.WebAuthn.AuthenticationForm`.",
slot_class: "CSS class for the `div` surrounding the slot."

@moduledoc """
Generates sign in and registration forms for WebAuthn/Passkey authentication.

## Component hierarchy

This is the top-most strategy-specific component for WebAuthn, nested below
`AshAuthentication.Phoenix.Components.SignIn`.

Children:

* `AshAuthentication.Phoenix.Components.WebAuthn.RegistrationForm`
* `AshAuthentication.Phoenix.Components.WebAuthn.AuthenticationForm`
* `AshAuthentication.Phoenix.Components.WebAuthn.Support`

## Props

* `strategy` - The WebAuthn strategy configuration. Required.
* `overrides` - A list of override modules.
* `gettext_fn` - Optional text translation function.

#{AshAuthentication.Phoenix.Overrides.Overridable.generate_docs()}
"""

use AshAuthentication.Phoenix.Web, :live_component
alias AshAuthentication.{Info, Phoenix.Components.WebAuthn, Strategy}
alias Phoenix.LiveView.{JS, Rendered, Socket}
import Slug

@doc false
@impl true
def update(assigns, socket) do
strategy = assigns.strategy

subject_name =
strategy.resource
|> Info.authentication_subject_name!()
|> to_string()
|> slugify()

strategy_name =
strategy
|> Strategy.name()
|> to_string()
|> slugify()

socket =
socket
|> assign(assigns)
|> assign(:subject_name, subject_name)
|> assign(:strategy_name, strategy_name)
|> assign(:sign_in_id, "#{subject_name}-#{strategy_name}-sign-in")
|> assign(:register_id, "#{subject_name}-#{strategy_name}-register")
|> assign_new(:overrides, fn -> [AshAuthentication.Phoenix.Overrides.Default] end)
|> assign_new(:gettext_fn, fn -> nil end)
|> assign_new(:live_action, fn -> :sign_in end)
|> assign_new(:path, fn -> "/" end)
|> assign_new(:register_path, fn -> nil end)
|> assign_new(:current_tenant, fn -> nil end)
|> assign_new(:context, fn -> %{} end)
|> assign_new(:auth_routes_prefix, fn -> nil end)

{:ok, socket}
end

@doc false
@impl true
@spec render(Socket.assigns()) :: Rendered.t() | no_return
def render(assigns) do
register_enabled? =
assigns.strategy.registration_enabled? &&
override_for(assigns.overrides, :register_toggle_text)

assigns =
assigns
|> assign(:hide_class, override_for(assigns.overrides, :hide_class))
|> assign(:register_enabled?, register_enabled?)
|> assign(:sign_in_enabled?, !is_nil(override_for(assigns.overrides, :sign_in_toggle_text)))

show =
if assigns[:live_action] == :sign_in && is_nil(assigns[:register_path]) do
assigns[:show_first] || :sign_in
else
assigns[:live_action]
end

assigns = assign(assigns, :show, show)

~H"""
<div class={override_for(@overrides, :root_class)}>
<.live_component
module={WebAuthn.Support}
id={"#{@sign_in_id}-support"}
overrides={@overrides}
/>

<div id={"#{@sign_in_id}-wrapper"} class={if @show == :sign_in, do: nil, else: @hide_class}>
<.live_component
module={
override_for(@overrides, :authentication_form_module) || WebAuthn.AuthenticationForm
}
id={@sign_in_id}
strategy={@strategy}
overrides={@overrides}
current_tenant={@current_tenant}
context={@context}
gettext_fn={@gettext_fn}
auth_routes_prefix={@auth_routes_prefix}
/>

<div class={override_for(@overrides, :interstitial_class)}>
<%= if @register_enabled? do %>
<.toggler
message={override_for(@overrides, :register_toggle_text)}
show={@register_id}
hide={[@sign_in_id]}
overrides={@overrides}
gettext_fn={@gettext_fn}
/>
<% end %>
</div>
</div>

<%= if @register_enabled? do %>
<div
id={"#{@register_id}-wrapper"}
class={if @live_action == :register, do: nil, else: @hide_class}
>
<.live_component
module={override_for(@overrides, :registration_form_module) || WebAuthn.RegistrationForm}
id={@register_id}
strategy={@strategy}
overrides={@overrides}
current_tenant={@current_tenant}
context={@context}
gettext_fn={@gettext_fn}
auth_routes_prefix={@auth_routes_prefix}
/>

<div class={override_for(@overrides, :interstitial_class)}>
<%= if @sign_in_enabled? do %>
<.toggler
message={override_for(@overrides, :sign_in_toggle_text)}
show={@sign_in_id}
hide={[@register_id]}
overrides={@overrides}
gettext_fn={@gettext_fn}
/>
<% end %>
</div>
</div>
<% end %>
</div>
"""
end

@doc false
@spec toggler(Socket.assigns()) :: Rendered.t() | no_return
def toggler(assigns) do
~H"""
<a href="#" phx-click={toggle_js(@show, @hide)} class={override_for(@overrides, :toggler_class)}>
{_gettext(@message)}
</a>
"""
end

defp toggle_js(show, hides, %JS{} = js \\ %JS{}) do
show_wrapper = "##{show}-wrapper"

js =
js
|> JS.show(to: show_wrapper)
|> JS.focus_first(to: show_wrapper)

hides
|> Enum.reject(&is_nil/1)
|> Enum.reduce(js, fn hide, js ->
JS.hide(js, to: "##{hide}-wrapper")
end)
end
end
Loading