diff --git a/lib/wac_gen/app_html.ex b/lib/wac_gen/app_html.ex index 6dff38c..adf596a 100644 --- a/lib/wac_gen/app_html.ex +++ b/lib/wac_gen/app_html.ex @@ -1,8 +1,6 @@ defmodule Wac.Gen.AppHtml do @moduledoc false - @header_regex ~r/\/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"]) @@ -10,11 +8,13 @@ defmodule Wac.Gen.AppHtml do 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/\/mis + defp navbar_component(assigns) do web_pascal_case = Keyword.fetch!(assigns, :web_pascal_case) diff --git a/lib/wac_gen/javascript.ex b/lib/wac_gen/javascript.ex index 28a3f53..eb86bcb 100644 --- a/lib/wac_gen/javascript.ex +++ b/lib/wac_gen/javascript.ex @@ -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, @@ -22,9 +19,15 @@ 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\";?)") + end + + defp socket_regex, do: Regex.compile!("(params: {\s?_csrf_token: csrfToken\s?})") end diff --git a/lib/webauthn_components/authentication_component.ex b/lib/webauthn_components/authentication_component.ex index 2c7d539..96c0688 100644 --- a/lib/webauthn_components/authentication_component.ex +++ b/lib/webauthn_components/authentication_component.ex @@ -56,7 +56,7 @@ defmodule WebauthnComponents.AuthenticationComponent do { :ok, socket - |> assign(:challenge, fn -> nil end) + |> assign_new(:challenge, fn -> nil end) |> assign_new(:id, fn -> "authentication-component" end) |> assign_new(:class, fn -> "" end) |> assign_new(:disabled, fn -> nil end) diff --git a/lib/webauthn_components/icon_components.ex b/lib/webauthn_components/icon_components.ex index ce9463e..48aed29 100644 --- a/lib/webauthn_components/icon_components.ex +++ b/lib/webauthn_components/icon_components.ex @@ -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""" @@ -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" > + + + """ + end + @doc false def icon_info_circle(assigns) do ~H""" diff --git a/lib/webauthn_components/registration_component.ex b/lib/webauthn_components/registration_component.ex index 85956b2..f65a974 100644 --- a/lib/webauthn_components/registration_component.ex +++ b/lib/webauthn_components/registration_component.ex @@ -12,11 +12,15 @@ 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`. + - `@icon_type` (Optional) The icon displayed inside the button. Either `:key` or `:usb`. Defaults to `:key` if authenticator attachment is `:platform`, or `:usb` 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." @@ -61,7 +65,6 @@ defmodule WebauthnComponents.RegistrationComponent do """ use Phoenix.LiveComponent import WebauthnComponents.IconComponents - import WebauthnComponents.BaseComponents alias WebauthnComponents.WebauthnUser def mount(socket) do @@ -69,8 +72,8 @@ defmodule WebauthnComponents.RegistrationComponent do :ok, socket |> 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) @@ -78,7 +81,6 @@ defmodule WebauthnComponents.RegistrationComponent do |> 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) } @@ -98,11 +100,11 @@ defmodule WebauthnComponents.RegistrationComponent do end def update(assigns, socket) do - { - :ok, - socket - |> assign(assigns) - } + socket + |> assign(assigns) + |> assign_new(:authenticator_attachment, fn -> :platform end) + |> assign_authenticator_attachment_dependant_assigns() + |> then(&{:ok, &1}) end def render(assigns) do @@ -111,28 +113,37 @@ defmodule WebauthnComponents.RegistrationComponent do end ~H""" - - <.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} - > - <.icon_key /> - <%= @display_text %> - - + """ 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." @@ -148,19 +159,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 } { @@ -208,4 +226,28 @@ defmodule WebauthnComponents.RegistrationComponent do send(self(), {:invalid_event, event, payload}) {:noreply, socket} end + + defp assign_authenticator_attachment_dependant_assigns(socket) do + %{authenticator_attachment: authenticator_attachment} = socket.assigns + + socket + |> assign_new(:id, fn -> default_id(authenticator_attachment) end) + |> assign_new(:display_text, fn -> default_display_text(authenticator_attachment) end) + |> assign_new(:icon_type, fn -> default_icon_type(authenticator_attachment) end) + end + + defp default_id(authenticator_attachment) do + "registration-component-#{authenticator_attachment}" + end + + defp default_display_text(:platform) do + "Sign Up With Passkey" + end + + defp default_display_text(:cross_platform) do + "Sign Up With Connected Device" + end + + defp default_icon_type(:platform), do: :key + defp default_icon_type(:cross_platform), do: :usb end diff --git a/priv/static/registration_hook.ts b/priv/static/registration_hook.ts index 5ce8c7f..6c2ee0e 100644 --- a/priv/static/registration_hook.ts +++ b/priv/static/registration_hook.ts @@ -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, @@ -32,6 +34,7 @@ export const RegistrationHook = { try { const { attestation, + authenticatorAttachment, challenge, excludeCredentials, residentKey, @@ -47,7 +50,7 @@ export const RegistrationHook = { const publicKey: PublicKeyCredentialCreationOptions = { attestation, authenticatorSelection: { - authenticatorAttachment: "platform", + authenticatorAttachment: authenticatorAttachment, residentKey: residentKey, requireResidentKey: requireResidentKey, }, diff --git a/templates/live_views/registration_live.ex b/templates/live_views/registration_live.ex index f237d8d..ac6314c 100644 --- a/templates/live_views/registration_live.ex +++ b/templates/live_views/registration_live.ex @@ -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, diff --git a/templates/live_views/registration_live.html.heex b/templates/live_views/registration_live.html.heex index 9ba1f52..babdfc0 100644 --- a/templates/live_views/registration_live.html.heex +++ b/templates/live_views/registration_live.html.heex @@ -16,12 +16,31 @@ >

Create a new account:

- <.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", @@ -31,7 +50,6 @@ ]} /> - <.link navigate={~p"/sign-in"} class="underline">Sign into an existing account diff --git a/test/webauthn_components/registration_component_test.exs b/test/webauthn_components/registration_component_test.exs index fb21a45..faf44f2 100644 --- a/test/webauthn_components/registration_component_test.exs +++ b/test/webauthn_components/registration_component_test.exs @@ -50,8 +50,9 @@ defmodule WebauthnComponents.RegistrationComponentTest do 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