defmodule WebAuthnLiveComponent do
@moduledoc """
A LiveComponent for passwordless authentication via WebAuthn.
"""
use Phoenix.LiveComponent
import Phoenix.HTML.Form
alias Ecto.Changeset
alias WebAuthnLiveComponent.Button
# prop app, :atom, required: true
# prop changeset, :struct, default: build_changeset()
# prop username, :string
# prop params, :map
# prop css_class, :css_class
# prop register_label, :string
# prop authenticate_label, :string
# prop user, :struct
@doc """
Ensure required assigns are present, falling back to default values where necessary.
"""
def mount(socket) do
{
:ok,
socket
|> assign_new(:changeset, &build_changeset/1)
|> assign_new(:css_class, fn -> "grid gap-2 grid-cols-2" end)
|> assign_new(:register_label, fn -> "Sign Up" end)
|> assign_new(:authenticate_label, fn -> "Sign In" end)
}
end
@doc """
Render the WebAuthn form.
"""
def render(assigns) do
~H"""
<div class="contents">
<div class="block w-full">
<Button.render phx-click="discoverCredentials" />
</div>
<.form
:let={form}
for={@changeset}
as={:auth}
id={@id}
class={@css_class}
phx-change="update_changeset"
phx-submit="start_authentication"
phx-target={@myself}
phx-hook="WebAuthn"
>
<%= if !Enum.empty?(@changeset.errors) do %>
<h2>Errors</h2>
<ul>
<%= for {field, {error, _meta}} <- @changeset.errors do %>
<li>
<strong><%= field %></strong> <%= error %>
</li>
<% end %>
</ul>
<% end %>
<%= label(form, :username, class: "col-span-full") %>
<%= text_input(
form,
:username,
class: "col-span-full",
"phx-debounce": 250,
autofocus: true
) %>
<button type="button" phx-click="start_authentication" phx-target={@myself}>
<%= @authenticate_label %>
</button>
<button type="button" phx-click="start_registration" phx-target={@myself}>
<%= @register_label %>
</button>
</.form>
</div>
"""
end
@doc """
Handlers for server and client events.
## Server-Side Events
The following events are triggered by the rendered form:
- `"update_changeset"` - Form data has changed.
- `"start_registration"` - The user wants to create a new account.
- `"start_authentication"` - The user wants to sign in as an existing user.
While the `update_changeset` event handler extracts data from the params argument, `start_registration` and `start_authentication` ignore the params argument, pulling state from the socket assigns instead.
## Client-Side Events
`WebAuthnLiveComponent` uses a Javascript (JS) hook to interact with the client-side WebAuthn API.
The following events are triggered by the WebAuthn JS hook:
- `"webauthn_supported"` - The JS hook as reported whether webauthn is supported.
- `"user_token"` - A token stored in the client's `sessionStorage`.
- `"register_attestation"` - A WebAuthn registration attestation created by the client.
- `"authenticate_attestation"` - A WebAuthn authentication attestation created by the client.
"""
def handle_event(event, params, socket)
def handle_event("webauthn_supported", boolean, socket) do
send(socket.root_pid, {:webauthn_supported, boolean})
{:noreply, socket}
end
def handle_event("user_token", token, socket) do
send(socket.root_pid, {:user_token, token: token})
{:noreply, socket}
end
def handle_event("update_changeset", params, socket) do
%{"auth" => %{"username" => username}} = params
changeset =
%{params: %{username: username}}
|> build_changeset()
|> add_changeset_requirements()
{
:noreply,
socket
|> assign(:changeset, changeset)
}
end
def handle_event("start_registration", _params, socket) do
%{assigns: %{changeset: changeset, app: app}} = socket
%{changes: %{username: username}} = changeset
new_changeset =
%{params: %{username: username}}
|> build_changeset()
|> add_changeset_requirements()
challenge =
socket
|> get_origin()
|> build_registration_challenge()
challenge_data = map_registration_challenge_data(challenge, app: app, username: username)
{
:noreply,
socket
|> assign(:changeset, new_changeset)
|> assign(:challenge, challenge)
|> push_event("registration_challenge", challenge_data)
}
end
def handle_event("registration_credentials", params, socket) do
%{assigns: %{changeset: changeset, challenge: challenge}} = socket
%{
"attestation64" => attestation_64,
"clientData" => client_data,
"rawId64" => raw_id_64,
"type" => "public-key"
} = params
user = changeset.changes
attestation = Base.decode64!(attestation_64, padding: false)
{:ok, {authenticator_data, _result}} = Wax.register(attestation, client_data, challenge)
%{attested_credential_data: %{credential_public_key: public_key}} = authenticator_data
user_key = %{key_id: raw_id_64, public_key: public_key}
send(socket.root_pid, {:register_user, user: user, key: user_key})
{:noreply, socket}
end
def handle_event("start_authentication", _params, socket) do
%{assigns: %{changeset: changeset}} = socket
%{changes: %{username: username}} = changeset
new_changeset =
%{params: %{username: username}}
|> build_changeset()
|> add_changeset_requirements()
# TODO: await user search response from parent live view
send(socket.root_pid, {:find_user_by_username, username: username})
{
:noreply,
socket
|> assign(:changeset, new_changeset)
}
end
def handle_event("authentication_attestation", params, socket) do
challenge = socket.assigns.challenge
%{
"authenticatorData64" => authenticator_data_64,
"clientDataArray" => client_data_raw,
"rawId64" => raw_id_64,
"signature64" => signature_64,
"type" => "public-key"
} = params
authenticator_data = Base.decode64!(authenticator_data_64, padding: false)
raw_id = Base.decode64!(raw_id_64, padding: false)
signature = Base.decode64!(signature_64, padding: false)
{:ok, _wax_auth} =
Wax.authenticate(raw_id, authenticator_data, signature, client_data_raw, challenge)
send(socket.root_pid, {:authentication_successful, key_id: raw_id})
{:noreply, socket}
rescue
error ->
send(socket.root_pid, {:invalid_attestation, error})
end
def handle_event("token_stored", %{"token" => _token}, socket) do
send(socket.root_pid, {:redirect})
{:noreply, socket}
end
def handle_event("error", params, socket) do
send(socket.root_pid, {:error, {params}})
{:noreply, socket}
end
def handle_event(event, payload, socket) do
send(socket.root_pid, {:unhandled_event, event: event, payload: payload})
{:noreply, socket}
end
@doc """
`update/2` is used here to catch the `found_user` assign once it's placed by the parent LiveView.
"""
def update(%{found_user: user}, socket) do
%{
allowed_credentials: allowed_credentials,
key_ids: key_ids
} = get_credential_map(user)
challenge_opts = [
attestation: "none",
origin: get_origin(socket),
rp_id: :auto,
allowed_credentials: allowed_credentials
]
challenge = build_authentication_challenge(challenge_opts)
challenge_data = map_authentication_challenge_data(challenge, key_ids: key_ids)
{
:ok,
socket
|> assign(:challenge, challenge)
|> push_event("authentication_challenge", challenge_data)
}
end
def update(assigns, socket) do
{
:ok,
socket
|> assign(assigns)
}
end
defp build_changeset(assigns) do
user = Map.get(assigns, :user, %{})
types = %{username: :string}
params = Map.get(assigns, :params, %{username: ""})
{user, types}
|> Changeset.cast(params, Map.keys(types))
end
defp add_changeset_requirements(changeset) do
changeset
|> Changeset.validate_required([:username])
|> Changeset.validate_length(:username, min: 3, max: 40)
end
defp build_registration_challenge(origin) do
Wax.new_registration_challenge(
attestation: "none",
origin: origin,
rp_id: :auto
)
end
defp map_registration_challenge_data(%Wax.Challenge{} = challenge, opts) do
[app: app, username: username] = opts
authenticator_attachment = Keyword.get(opts, :authenticator_attachment)
%{
appName: app,
attestation: challenge.attestation,
authenticator_attachment: authenticator_attachment,
challenge_64: Base.encode64(challenge.bytes, padding: false),
rp_id: challenge.rp_id,
user: %{name: username, displayName: username},
user_verification: challenge.user_verification
}
end
defp build_authentication_challenge(challenge_opts) do
Wax.new_authentication_challenge(challenge_opts)
end
defp map_authentication_challenge_data(%Wax.Challenge{} = challenge, key_ids: key_ids) do
%{
attestation: challenge.attestation,
challenge_64: Base.encode64(challenge.bytes, padding: false),
key_ids_64: key_ids,
user_verification: challenge.user_verification
}
end
defp get_origin(socket) do
socket.endpoint.url()
end
defp get_credential_map(user) do
for key <- user.keys, reduce: %{key_ids: [], allowed_credentials: []} do
result ->
result
|> Map.update!(:allowed_credentials, &List.insert_at(&1, 0, {key.key_id, key.public_key}))
|> Map.update!(
:key_ids,
&List.insert_at(&1, 0, Base.encode64(key.key_id, padding: false))
)
end
end
end