Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e1248bd
show cross platform (extra button)
peaceful-james Aug 31, 2025
80b9d20
improve display text stuff
peaceful-james Aug 31, 2025
beb9570
show usb icon
peaceful-james Aug 31, 2025
f1d2f13
fix tests
peaceful-james Aug 31, 2025
9d3204a
make timeout configurable
peaceful-james Aug 31, 2025
bc968b7
oops dont commit that lol
peaceful-james Aug 31, 2025
3b7a463
fix dupe event bug
peaceful-james Aug 31, 2025
a4a9cac
stop icons shrinking with long button labels
peaceful-james Aug 31, 2025
c321092
prettier
peaceful-james Aug 31, 2025
e4e9b39
remove compile-time regex
peaceful-james Sep 15, 2025
174d6ce
allow users to control which buttons are rendered
peaceful-james Sep 15, 2025
6267133
demo both buttons in the generated html
peaceful-james Sep 15, 2025
3fadb47
Update lib/webauthn_components/registration_component.ex
peaceful-james Sep 17, 2025
1170278
Update lib/wac_gen/javascript.ex
peaceful-james Sep 17, 2025
19821ab
remove opinionated button container elements
peaceful-james Sep 17, 2025
f247d11
use correct default id assign
peaceful-james Sep 17, 2025
fda12b1
move auth attach dependant assigns to own fxn
peaceful-james Sep 17, 2025
cd941b9
use normal button element
peaceful-james Sep 17, 2025
18284d3
remove unused import
peaceful-james Sep 17, 2025
4de6330
let users style the display text
peaceful-james Sep 17, 2025
72d5882
Use display_text for button title
peaceful-james Sep 17, 2025
45e691a
remove display_text_class
peaceful-james Sep 17, 2025
4069c37
fix tests
peaceful-james Sep 17, 2025
01381d7
sneak in incidental, unrelated fix
peaceful-james Sep 24, 2025
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
6 changes: 3 additions & 3 deletions lib/wac_gen/app_html.ex
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
defmodule Wac.Gen.AppHtml do
@moduledoc false

@header_regex ~r/\<header.*\<\/header\>/mis

def update_app_html(assigns) do
web_snake_case = Keyword.fetch!(assigns, :web_snake_case)
file_path = Path.join(["lib", web_snake_case, "components", "layouts", "app.html.heex"])

modified_file_contents =
file_path
|> File.read!()
|> String.replace(@header_regex, navbar_component(assigns))
|> String.replace(header_regex(), navbar_component(assigns))

File.write!(file_path, modified_file_contents)
end

defp header_regex, do: ~r/\<header.*\<\/header\>/mis

defp navbar_component(assigns) do
web_pascal_case = Keyword.fetch!(assigns, :web_pascal_case)

Expand Down
12 changes: 7 additions & 5 deletions lib/wac_gen/javascript.ex
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
defmodule Wac.Gen.Javascript do
@moduledoc false

@import_regex Regex.compile!("(import {\s?LiveSocket\s?} from \"phoenix_live_view\";?)")
@socket_regex Regex.compile!("(params: {\s?_csrf_token: csrfToken\s?})")

@import_hooks """
import {
SupportHook,
Expand All @@ -22,9 +19,14 @@ defmodule Wac.Gen.Javascript do
updated_contents =
javascript_path
|> File.read!()
|> String.replace(@import_regex, "\\1\n#{@import_hooks}")
|> String.replace(@socket_regex, "\\1,\n#{@socket_hooks}")
|> String.replace(import_regex(), "\\1\n#{@import_hooks}")
|> String.replace(socket_regex(), "\\1,\n#{@socket_hooks}")

File.write!(javascript_path, updated_contents)
end

defp import_regex,
do: Regex.compile!("(import {\s?LiveSocket\s?} from \"phoenix_live_view\";?)")

defp socket_regex, do: Regex.compile!("(params: {\s?_csrf_token: csrfToken\s?})")
end
26 changes: 25 additions & 1 deletion lib/webauthn_components/icon_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ defmodule WebauthnComponents.IconComponents do
@moduledoc false
use Phoenix.Component

attr :type, :atom, required: true, values: [:key, :usb]

def icon(assigns) do
~H"""
<.icon_key :if={@type == :key} />
<.icon_usb :if={@type == :usb} />
"""
end

@doc false
def icon_key(assigns) do
~H"""
Expand All @@ -11,7 +20,7 @@ defmodule WebauthnComponents.IconComponents do
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-full h-full"
class="w-full h-full min-w-4 min-h-4"
>
<path
stroke-linecap="round"
Expand All @@ -22,6 +31,21 @@ defmodule WebauthnComponents.IconComponents do
"""
end

@doc false
def icon_usb(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
stroke="currentColor"
class="w-full h-full min-w-4 min-h-4"
>
<path d="M 23.5,12 L 21,10 L 21,11 L 8,11 L 10.5,6.5 C 10.75,6 11.125,5.5 11.5,5.5 C 12.75,5.5 13.25,5.5 13.5,5.5 C 13.75,7 14.5,8 15.5,8 C 16.75,8 17.5,7 17.5,5.5 C 17.5,4 16.75,3 15.5,3 C 14.5,3 13.75,4 13.5,5.5 L 11.5,5.5 C 11,5.5 10.25,6.25 10,6.75 L 7,11 L 5,11 C 4.75,9 3.5,7.5 2,7.5 C 0.75,7.5 0,9.25 0,12 C 0,14.75 0.75,16.5 2,16.5 C 3.5,16.5 4.75,15 5,13 L 7,13 L 10,17.25 C 10.25,17.75 11,18.5 11.5,18.5 L 13.5,18.5 L 13.5,20 L 16,20 L 16,16 L 13.5,16 L 13.5,17.5 L 11.5,17.5 C 11.125,17.5 10.75,17 10.5,16.5 L 8,13 L 21,13 L 21,14 L 23.5,12 z" />
</svg>
"""
end

@doc false
def icon_info_circle(assigns) do
~H"""
Expand Down
107 changes: 72 additions & 35 deletions lib/webauthn_components/registration_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ defmodule WebauthnComponents.RegistrationComponent do

- `@user`: (**Required**) A `WebauthnComponents.WebauthnUser` struct.
- `@challenge`: (Internal) A `Wax.Challenge` struct created by the component, used to create a new credential request in the client.
- `@display_text` (Optional) The text displayed inside the button. Defaults to "Sign Up".
- `@app`: (**Required**) The name of your application or service. This is displayed to the user during registration.
- `@authenticator_attachment` (Optional) The type of authenticator to use. Either `:platform` or `:cross_platform`. Defaults to `:platform`.
- `@display_text` (Optional) The text displayed inside the "platform" button. Defaults to "Sign Up" if authenticator attachment is `:platform`, or "Sign Up With Connected Device" if `:cross_platform`.
- `@show_icon?` (Optional) Controls visibility of the key icon. Defaults to `true`.
- `@class` (Optional) CSS classes for overriding the default button style.
- `@disabled` (Optional) Set to `true` when the `SupportHook` indicates WebAuthn is not supported or enabled by the browser. Defaults to `false`.
- `@id` (Optional) An HTML element ID.
- `@timeout` (Optional) The timeout in milliseconds for the registration operation. Defaults to `60_000` (60 seconds).
- `@resident_key` (Optional) Set to `:preferred` or `:discouraged` to allow non-passkey credentials. Defaults to `:required`.
- `@check_uvpa_available` (Optional) Set to `true` to check if the user has a platform authenticator available. Defaults to `false`. See the User Verifying Platform Authenticator section for more information.
- `@uvpa_error_message` (Optional) The message displayed when the user does not have a UVPA available. Defaults to "Registration unavailable. Your device does not support passkeys. Please install a passkey authenticator."
Expand Down Expand Up @@ -71,14 +74,14 @@ defmodule WebauthnComponents.RegistrationComponent do
|> assign(:challenge, fn -> nil end)
|> assign_new(:id, fn -> "registration-component" end)
|> assign_new(:class, fn -> "" end)
|> assign_new(:timeout, fn -> 60_000 end)
|> assign_new(:webauthn_user, fn -> nil end)
|> assign_new(:disabled, fn -> false end)
|> assign_new(:resident_key, fn -> :required end)
|> assign_new(:check_uvpa_available, fn -> false end)
|> assign_new(:uvpa_error_message, fn ->
"Registration unavailable. Your device does not support passkeys. Please install a passkey authenticator."
end)
|> assign_new(:display_text, fn -> "Sign Up" end)
|> assign_new(:show_icon?, fn -> true end)
|> assign_new(:relying_party, fn -> nil end)
}
Expand All @@ -98,11 +101,26 @@ defmodule WebauthnComponents.RegistrationComponent do
end

def update(assigns, socket) do
{
:ok,
socket
|> assign(assigns)
}
socket
|> assign(assigns)
|> assign_new(:authenticator_attachment, fn -> :platform end)
|> then(fn socket ->
assign_new(socket, :display_text, fn ->
case socket.assigns.authenticator_attachment do
:platform -> "Sign Up"
:cross_platform -> "Sign Up With Connected Device"
end
end)
end)
|> then(fn socket ->
assign_new(socket, :icon_type, fn ->
case socket.assigns.authenticator_attachment do
:platform -> :key
:cross_platform -> :usb
end
end)
end)
|> then(&{:ok, &1})
end

def render(assigns) do
Expand All @@ -111,28 +129,40 @@ defmodule WebauthnComponents.RegistrationComponent do
end

~H"""
<span>
<.button
id={@id}
phx-hook="RegistrationHook"
phx-target={@myself}
phx-click="register"
data-check_uvpa_available={if @check_uvpa_available, do: "true"}
data-uvpa_error_message={@uvpa_error_message}
class={@class}
title="Create a new account"
disabled={@disabled}
>
<span :if={@show_icon?} class="w-4 aspect-square opacity-70"><.icon_key /></span>
<span><%= @display_text %></span>
</.button>
</span>
<div class="flex flex-col space-y-4">
<span>
<.button
id={@id}
phx-hook="RegistrationHook"
phx-target={@myself}
phx-click="register"
data-check_uvpa_available={if @check_uvpa_available, do: "true"}
data-uvpa_error_message={@uvpa_error_message}
class={@class}
title="Create a new account"
disabled={@disabled}
>
<span :if={@show_icon?} class="w-4 aspect-square opacity-70">
<.icon type={@icon_type} />
</span>
<span><%= @display_text %></span>
</.button>
</span>
</div>
"""
end

def handle_event("register", _params, socket) do
%{assigns: assigns, endpoint: endpoint} = socket
%{app: app_name, id: id, resident_key: resident_key, webauthn_user: webauthn_user} = assigns

%{
app: app_name,
authenticator_attachment: authenticator_attachment,
id: id,
resident_key: resident_key,
webauthn_user: webauthn_user,
timeout: timeout
} = assigns

if not is_struct(webauthn_user, WebauthnUser) do
raise "user must be a WebauthnComponents.WebauthnUser struct."
Expand All @@ -148,19 +178,26 @@ defmodule WebauthnComponents.RegistrationComponent do
trusted_attestation_types: [:none, :basic]
)

authenticator_attachment_string =
case authenticator_attachment do
:platform -> "platform"
:cross_platform -> "cross-platform"
end

challenge_data = %{
attestation: attestation,
challenge: Base.encode64(challenge.bytes, padding: false),
excludeCredentials: [],
id: id,
residentKey: resident_key,
requireResidentKey: resident_key == :required,
rp: %{
id: challenge.rp_id,
name: app_name
"attestation" => attestation,
"authenticatorAttachment" => authenticator_attachment_string,
"challenge" => Base.encode64(challenge.bytes, padding: false),
"excludeCredentials" => [],
"id" => id,
"residentKey" => resident_key,
"requireResidentKey" => resident_key == :required,
"rp" => %{
"id" => challenge.rp_id,
"name" => app_name
},
timeout: 60_000,
user: webauthn_user
"timeout" => timeout,
"user" => webauthn_user
}

{
Expand Down
11 changes: 7 additions & 4 deletions priv/static/registration_hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ export const RegistrationHook = {
});
}

this.handleEvent("registration-challenge", (event) =>
this.handleRegistration(event, this)
);
this.handleEvent("registration-challenge", (event) => {
if (event.id == this.el.id) {
this.handleRegistration(event, this);
}
});
},
async checkUserVerifyingPlatformAuthenticatorAvailable(
context,
Expand All @@ -32,6 +34,7 @@ export const RegistrationHook = {
try {
const {
attestation,
authenticatorAttachment,
challenge,
excludeCredentials,
residentKey,
Expand All @@ -47,7 +50,7 @@ export const RegistrationHook = {
const publicKey: PublicKeyCredentialCreationOptions = {
attestation,
authenticatorSelection: {
authenticatorAttachment: "platform",
authenticatorAttachment: authenticatorAttachment,
residentKey: residentKey,
requireResidentKey: requireResidentKey,
},
Expand Down
7 changes: 6 additions & 1 deletion templates/live_views/registration_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ defmodule <%= inspect @web_pascal_case %>.RegistrationLive do
|> Map.put(:name, email)
|> Map.put(:display_name, email)

send_update(RegistrationComponent, id: "registration-component", webauthn_user: webauthn_user)
for authenticator_attachment <- [:platform, :cross_platform] do
send_update(RegistrationComponent,
id: "registration-component-#{authenticator_attachment}",
webauthn_user: webauthn_user
)
end

{
:noreply,
Expand Down
26 changes: 22 additions & 4 deletions templates/live_views/registration_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,31 @@
>
<p>Create a <strong>new</strong> account:</p>

<.input type="email" field={form[:email]} label="Email" phx-debounce="250" autocomplete="username webauthn" />
<.input
type="email"
field={form[:email]}
label="Email"
phx-debounce="250"
autocomplete="username webauthn"
/>
<.live_component
:for={
{authenticator_attachment, display_text} <- [
platform: dpgettext("registration", "buttons", "Register using a passkey"),
cross_platform:
dpgettext(
"registration",
"buttons",
"Register using a passkey with an external device (e.g. phone, security key, etc.)"
)
]
}
authenticator_attachment={authenticator_attachment}
display_text={display_text}
id={"registration-component-#{authenticator_attachment}"}
disabled={@form.source.valid? == false}
module={RegistrationComponent}
id="registration-component"
app={<%= inspect @app_pascal_case %>}
app={inspect(@app_pascal_case)}
check_uvpa_available={false}
class={[
"bg-gray-200 hover:bg-gray-300 hover:text-black rounded transition",
Expand All @@ -31,7 +50,6 @@
]}
/>


<.link navigate={~p"/sign-in"} class="underline">Sign into an existing account</.link>
</fieldset>

Expand Down
19 changes: 12 additions & 7 deletions test/webauthn_components/registration_component_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,38 @@
assigns = %{app: @app, id: @id}
{:ok, view, _html} = live_isolated_component(RegistrationComponent, assigns)
live_assign(view, app: assigns.app, id: assigns.id)
element = element(view, "##{assigns.id}")
element = element(view, "##{assigns.id}-platform")
%{view: view, element: element, default_assigns: assigns}
end

describe "render/1" do
test "returns element with id and phx hook", %{view: view} do

Check failure on line 18 in test/webauthn_components/registration_component_test.exs

View workflow job for this annotation

GitHub Actions / Build & Test

test render/1 returns element with id and phx hook (WebauthnComponents.RegistrationComponentTest)
assert has_element?(view, "##{@id}[phx-hook='RegistrationHook']")
assert has_element?(view, "##{@id}-platform[phx-hook='RegistrationHook']")
assert has_element?(view, "##{@id}-cross-platform[phx-hook='RegistrationHook']")
end
end

describe "update/2" do
test "updates `disabled` assign", setup_attrs do

Check failure on line 25 in test/webauthn_components/registration_component_test.exs

View workflow job for this annotation

GitHub Actions / Build & Test

test update/2 updates `disabled` assign (WebauthnComponents.RegistrationComponentTest)
%{default_assigns: default_assigns} = setup_attrs
assigns = Map.merge(default_assigns, %{disabled: true})
{:ok, view, _html} = live_isolated_component(RegistrationComponent, assigns)
assert has_element?(view, "##{assigns.id}[disabled]")
assert has_element?(view, "##{assigns.id}-platform[disabled]")
assert has_element?(view, "##{assigns.id}-cross-platform[disabled]")
end

test "is not `disabled` by default", setup_attrs do

Check failure on line 33 in test/webauthn_components/registration_component_test.exs

View workflow job for this annotation

GitHub Actions / Build & Test

test update/2 is not `disabled` by default (WebauthnComponents.RegistrationComponentTest)
%{default_assigns: assigns} = setup_attrs
{:ok, view, _html} = live_isolated_component(RegistrationComponent, assigns)
assert has_element?(view, "##{assigns.id}")
refute has_element?(view, "##{assigns.id}[disabled]")
assert has_element?(view, "##{assigns.id}-platform")
assert has_element?(view, "##{assigns.id}-cross-platform")
refute has_element?(view, "##{assigns.id}-platform[disabled]")
refute has_element?(view, "##{assigns.id}-cross-platform[disabled]")
end
end

describe "handle_event/3 - register" do
test "sends registration challenge to client", %{element: element, view: view} do

Check failure on line 44 in test/webauthn_components/registration_component_test.exs

View workflow job for this annotation

GitHub Actions / Build & Test

test handle_event/3 - register sends registration challenge to client (WebauthnComponents.RegistrationComponentTest)
webauthn_user = %WebauthnUser{
id: :crypto.strong_rand_bytes(64),
name: "testUser",
Expand All @@ -50,14 +54,15 @@
assert clicked_element =~ "phx-click=\"register\""

assert_push_event(view, "registration-challenge", %{
id: "registration-component",
user: ^webauthn_user
"id" => "registration-component",
"user" => ^webauthn_user,
"authenticatorAttachment" => "platform"
})
end
end

describe "handle_event/3 - registration-attestation" do
test "fails registration with invalid payload", %{element: element, view: view} do

Check failure on line 65 in test/webauthn_components/registration_component_test.exs

View workflow job for this annotation

GitHub Actions / Build & Test

test handle_event/3 - registration-attestation fails registration with invalid payload (WebauthnComponents.RegistrationComponentTest)
challenge = build(:registration_challenge)
live_assign(view, :challenge, challenge)

Expand All @@ -79,7 +84,7 @@
end

describe "handle_event/3 - error" do
test "accepts valid payload", %{element: element, view: view} do

Check failure on line 87 in test/webauthn_components/registration_component_test.exs

View workflow job for this annotation

GitHub Actions / Build & Test

test handle_event/3 - error accepts valid payload (WebauthnComponents.RegistrationComponentTest)
error = %{
"message" => "test message",
"name" => "test name",
Expand All @@ -92,7 +97,7 @@
end

describe "handle_event/3 - fallback" do
test "sends invalid event to parent view", %{element: element, view: view} do

Check failure on line 100 in test/webauthn_components/registration_component_test.exs

View workflow job for this annotation

GitHub Actions / Build & Test

test handle_event/3 - fallback sends invalid event to parent view (WebauthnComponents.RegistrationComponentTest)
assert render_hook(element, "invalid", %{"invalid_key" => "invalid value"})
assert_handle_info(view, {:invalid_event, "invalid", %{"invalid_key" => "invalid value"}})
end
Expand Down
Loading