defmodule WebAuthnLiveComponent.PasskeyComponent do
@moduledoc """
LiveComponent for interacting with Passkeys.
> #### Caution {: .warning}
>
> This component should be considered in alpha status, with changes to the API, bugs, and incomplete documentation to be expected.
>
> It is **not** advisable to use this component in a production environment at this time.
## Overview
"Passkey" is an end-user-friendly moniker for the WebAuthentication API, aka WebAuthn, where credentials may be used **across devices** through a cloud synchronization mechanism. Support for Passkeys requires integration with a user's operating system, browser, and device. It also requires Javascript to be enabled in the browser since WebAuthn is a browser API, where credential creation and verification is performed.
Cloud synchronization is currently handled by the user's operating system - [Keychain](https://support.apple.com/guide/iphone/passkeys-passwords-devices-iph82d6721b2/ios) for MacOS users and [Google Password Manager](https://developers.google.com/identity/passkeys/supported-environments) for Android users. Third party services such as [1Password](https://www.future.1password.com/passkeys/) have also announced plans for Passkey support in upcoming releases.
## Terms
- **Registration**: The process of creating a new credential to be used for a new account in your application.
- **Authentication**: The process of using an existing credential to access an existing account in your application.
## A New Paradigm
With Passkeys, it may be challenging (pardon the pun) to understand how registration and authentication are performed. The model differs from traditional username + password authentication in significant ways.
This implementation of Passkeys takes advantage of userless or loginless authentication, where the user need not provide a username, email, password, or other data required for traditional registration. Instead, a UUID is provided to to the WebAuthn API along with other challenge data.
## Customization
The registration and authentications processes are handled by this LiveComponent, which includes both a `Register` and `Authenticate` button.
## Communication
Throughout the registration and authentication process, some messages must be passed to the parent LiveView. In the parent LiveView, use `handle_info/2` to accept the following messages:
- `{:passkeys_supported, boolean}`: If false, an error should be displayed to the user.
- `{:token_exists, token: token}`: Reports an existing session token. The parent application may decide whether to redirect to another view, clear the token, or render an error.
- `{:token_stored, token: token}`: Reports a token was successfully stored in the user's browser. The user should be redirected to another view at this point. For new users, it is recommended to proceed with profile setup since email and other details are not collected during registration.
- `{:token_cleared}`: Reports that a token was successfully cleared. A message _may_ be displayed to the user if it makes sense to do so.
- `{:registration_failure, message: message}`: Reports an error which should be displayed to the user. The parent application may display human-friendly verbiage instead, logging the error for internal debugging.
- `{:find_credentials, user_handle: user_handle}`: Reports a `user_handle` is requesting authentication. The parent application must return a matching user, if one exists, in order to proceed with authentication.
- `{:authentication_successful, key_id: raw_id, auth_data: auth_data}`: Reports the user was successfully authenticated by the WebAuthn API. The parent LiveView should create a new session token and pass it back to the component for persistence in the user's browser.
- `{:authentication_failure, message: message}`: Reports the provided user could not be authenticated, with a message that may be displayed or paraphrased for the user.
- `{:error, payload}`: Reports an error to the parent LiveView to be displayed or paraphrased for the user.
Errors are reported and should typically be rendered to the user via flash messages and/or logged in the application's error tracking system for analysis. The component passes these messages to the LiveView to allow complete control over visibility, appearance, and wording of errors.
## Tokens
TODO: Document token expectations and best practices.
"""
require Logger
use Phoenix.LiveComponent
@button_class "px-2 py-1 border border-gray-300 dark:border-gray-600 hover:border-transparent bg-gray-200 hover:bg-blue-600 hover:text-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition rounded text-base shadow-sm flex gap-2 items-center hover:-translate-y-px hover:shadow-md"
@div_class "flex gap-2 flex-wrap"
@support_error_class "flex gap-2 items-center justify-center font-bold w-full border-2 border-rose-500 text-rose-600 dark:text-rose-200 bg-rose-200 dark:bg-rose-800 rounded-md shadow-md p-4 mb-4 transition"
@timeout 60_000
@doc """
Mounts the component with default assigns.
## Configurable Assigns
The following assigns may be passed to the component from your LiveView:
- `@app`: _Required_ - The name of you application. May be a string or atom.
- `@button_class`: Styles for buttons in the component.
- `@div_class`: Styles for the `<div>` container for the buttons.
- `@support_error_class`: Styles for the `<aside>` displayed when Passkeys are **not** supported.
- `@timeout`: Milliseconds until registration and authentication prompts expire. This value is passed to the WebAuthn API.
## Internal Assigns
Other assigns are set internally, but listed here for transparency:
- `@passkeys_supported`: A boolean set to `true` when the Passkey LiveView hook detects WebAuthn support. Otherwise, it is set to `false`. The initial value is `nil`.
- See [caniuse.com](https://caniuse.com/?search=webauthn) for browser support details.
"""
def mount(socket) do
{
:ok,
socket
|> assign(:passkeys_supported, fn -> nil end)
|> assign_new(:button_class, fn -> @button_class end)
|> assign_new(:div_class, fn -> @div_class end)
|> assign_new(:support_error_class, fn -> @support_error_class end)
|> assign_new(:timeout, fn -> @timeout end)
}
end
@doc """
Stores or clears a session token.
When a `:token` assign is received, this function will either clear or store the user's token.
- Assign `token: :clear` to remove a user's token.
- Assign a binary token (typically a base64-encoded crypto hash) to persist a user's token to the browser's `sessionStorage`.
- Invalid token assigns will be logged and the socket will be returned unchanged.
"""
def update(%{token: token} = _assigns, socket) do
cond do
token == :clear ->
{
:ok,
socket
|> push_event("clear-token", %{token: token})
}
is_binary(token) ->
{
:ok,
socket
|> push_event("store-token", %{token: token})
}
true ->
Logger.warn(invalid_token: token)
{:ok, socket}
end
end
def update(assigns, socket) do
{
:ok,
socket
|> assign(assigns)
}
end
def render(assigns) do
~H"""
<div id={@id} class={@div_class} phx-hook="PasskeyHook">
<aside :if={@passkeys_supported == false} class={@support_error_class}>
<span class="w-6 aspect-square opacity-70"><.icon_info_circle /></span>
<span>Sorry, Passkeys are not supported by this browser.</span>
</aside>
<button
phx-target={@myself}
type="button"
phx-click="register"
class={@button_class}
title="Create a new account"
disabled={@passkeys_supported == false}
>
<span class="w-4 opacity-70"><.icon_key /></span>
<span>Register</span>
</button>
<button
phx-target={@myself}
type="button"
phx-click="authenticate"
class={@button_class}
title="Use an existing account"
disabled={@passkeys_supported == false}
>
<span class="w-4 opacity-70"><.icon_key /></span>
<span>Authenticate</span>
</button>
</div>
"""
end
def icon_key(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-full h-full"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
"""
end
def icon_info_circle(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-full h-full"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
"""
end
def handle_event("passkeys-supported", boolean, socket) do
send(socket.root_pid, {:passkeys_supported, boolean})
{
:noreply,
socket
|> assign(:passkeys_supported, !!boolean)
}
end
def handle_event("token-exists", payload, socket) do
%{"token" => token} = payload
send(socket.root_pid, {:token_exists, token: token})
{:noreply, socket}
end
def handle_event("token-stored", payload, socket) do
%{"token" => token} = payload
send(socket.root_pid, {:token_stored, token: token})
{:noreply, socket}
end
def handle_event("token-cleared", _payload, socket) do
send(socket.root_pid, {:token_cleared})
{:noreply, socket}
end
def handle_event("register", _params, socket) do
%{endpoint: endpoint} = socket
app_name = socket.assigns[:app]
attestation = "none"
user_handle = :crypto.strong_rand_bytes(64)
user = %{
id: Base.encode64(user_handle, padding: false),
name: app_name,
displayName: app_name
}
challenge =
Wax.new_registration_challenge(
attestation: attestation,
origin: endpoint.url,
rp_id: :auto,
trusted_attestation_types: [:none, :basic]
)
challenge_data = %{
attestation: attestation,
challenge: Base.encode64(challenge.bytes, padding: false),
excludeCredentials: [],
rp: %{
id: challenge.rp_id,
name: app_name
},
timeout: 60_000,
user: user
}
{
:noreply,
socket
|> assign(:challenge, challenge)
|> assign(:user_handle, user_handle)
|> push_event("passkey-registration", challenge_data)
}
end
def handle_event("registration-attestation", payload, socket) do
%{challenge: challenge, user_handle: user_handle} = socket.assigns
%{
"attestation64" => attestation_64,
"clientData" => client_data,
"rawId64" => raw_id_64,
"type" => "public-key"
} = payload
attestation = Base.decode64!(attestation_64, padding: false)
raw_id = Base.decode64!(raw_id_64, padding: false)
wax_response = Wax.register(attestation, client_data, challenge)
case wax_response do
{:ok, {authenticator_data, _result}} ->
%{attested_credential_data: %{credential_public_key: public_key}} = authenticator_data
send(
socket.root_pid,
{:registration_successful,
key_id: raw_id, public_key: public_key, user_handle: user_handle}
)
{:error, error} ->
message = Exception.message(error)
send(socket.root_pid, {:registration_failure, message: message})
end
{:noreply, socket}
end
def handle_event("authenticate", _params, socket) do
%{endpoint: endpoint} = socket
%{timeout: timeout} = socket.assigns
challenge =
Wax.new_registration_challenge(
origin: endpoint.url,
rp_id: :auto,
user_verification: "preferred"
)
challenge_data = %{
challenge: Base.encode64(challenge.bytes, padding: false),
timeout: timeout,
rpId: challenge.rp_id,
allowCredentials: challenge.allow_credentials,
userVerification: challenge.user_verification
}
{
:noreply,
socket
|> assign(:challenge, challenge)
|> push_event("passkey-authentication", challenge_data)
}
end
def handle_event("authentication-attestation", payload, socket) do
%{
"authenticatorData64" => authenticator_data_64,
"clientDataArray" => client_data_array,
"rawId64" => raw_id_64,
"signature64" => signature_64,
"type" => type,
"userHandle64" => user_handle_64
} = payload
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)
user_handle = Base.decode64!(user_handle_64, padding: false)
attestation = %{
authenticator_data: authenticator_data,
client_data_array: client_data_array,
raw_id: raw_id,
signature: signature,
type: type,
user_handle: user_handle
}
send(socket.root_pid, {:find_credentials, user_handle: user_handle})
{
:noreply,
socket
|> assign(:attestation, attestation)
}
end
def handle_event("user_credentials", payload, socket) do
%{attestation: attestation, challenge: challenge} = socket.assigns
%{
authenticator_data: authenticator_data,
client_data_array: client_data_array,
raw_id: raw_id,
signature: signature
} = attestation
%{credentials: credentials} = payload
wax_response =
Wax.authenticate(
raw_id,
authenticator_data,
signature,
client_data_array,
challenge,
credentials
)
case wax_response do
{:ok, auth_data} ->
send(socket.root_pid, {:authentication_successful, key_id: raw_id, auth_data: auth_data})
{:error, error} ->
message = Exception.message(error)
send(socket.root_pid, {:authentication_failure, message: message})
end
{:noreply, socket}
end
def handle_event("error", payload, socket) do
send(socket.root_pid, {:error, payload})
{:noreply, socket}
end
end