Skip to content

Commit

Permalink
Accept User Details (#45)
Browse files Browse the repository at this point in the history
* add `WebauthnUser` struct

* use new struct in registration component

* rm `:user_handle` from registration and docs

* undo js user object manipulation

* add custom jason encoder impl to camel-case `:display_name`

* handle `webauthn_user` updates

* report invalid webauthn user to parent LV

* send `key_id` instead of `user_handle`, misc formatting

* update auth component docs

* version bump

* fix broken test

* update docs

* rename `:find_credential` message

* update readme
  • Loading branch information
type1fool authored May 5, 2023
1 parent 57781a7 commit 7ea972c
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 55 deletions.
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def deps do
end
```

### Usage
## Usage

See [USAGE.md](./USAGE.md) for detailed usage instructions.

Expand All @@ -57,6 +57,16 @@ See [USAGE.md](./USAGE.md) for detailed usage instructions.

See module documentation for each component for more detailed descriptions.

## Cross-Device Authentication

When a user attempts to authenticate on a device where their Passkey is **not** stored, they may scan a QR code to use a cloud-sync'd Passkey.

### Example

Imagine a user, Amal, registers a Passkey for example.com on their iPhone and it's stored in iCloud. When they attempt to sign into example.com on a non-Apple device or any browser which cannot access their OS keychain, they may choose to scan a QR code using their iPhone. Assuming the prompts on the iPhone are successful, the other device will be authenticated using the same web account which was initially registered on the iPhone.

While this example refers to Apple's Passkey implementation, the process on other platforms may vary. Cross-device credential managers like 1Password may provide a more seamless flow for users who are not constrained to one OS or browser.

#### Support Detection

```mermaid
Expand Down Expand Up @@ -157,10 +167,10 @@ sequenceDiagram
Client->>AuthenticationComponent: "authenticate"
AuthenticationComponent->>Client: "authentication-challenge"
Client->>AuthenticationComponent: "authentication-attestation"
AuthenticationComponent->>ParentLiveView: `{:find_credentials, ...}`
AuthenticationComponent->>ParentLiveView: `{:find_credential, ...}`
```

Once the parent LiveView receives the `{:find_credentials, ...}` message, it must lookup the user via the user's existing key. To keep the user signed in, the LiveView may [create a session token](#token-management), Base64-encode the token, and pass it to `TokenComponent` for persistence in the client's `sessionStorage`.
Once the parent LiveView receives the `{:find_credential, ...}` message, it must lookup the user via the user's existing key. To keep the user signed in, the LiveView may [create a session token](#token-management), Base64-encode the token, and pass it to `TokenComponent` for persistence in the client's `sessionStorage`.

## WebAuthn & Passkeys

Expand Down
2 changes: 1 addition & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ defmodule MyAppWeb.AuthLive do
}
end

def handle_info({:registration_successful, key_id: raw_id, public_key: public_key, user_handle: user_handle}, socket) do
def handle_info({:registration_successful, key_id: raw_id, public_key: public_key}, socket) do
# Persist the user here.
end

Expand Down
48 changes: 30 additions & 18 deletions lib/webauthn_components/authentication_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,23 @@ defmodule WebauthnComponents.AuthenticationComponent do
Authentication is the process of matching a registered key to an existing user.
With Passkeys, the user is presented with a native modal from the browser or OS.
- If the user has only one passkey registered to the application's origin URL, they will be prompted to confirm acceptance via biometric ID (touch, face, etc.), OS password, or an OS PIN.
- If multiple accounts are registered to the device for the origin URL, the user may select an account to use for the current session.
See [USAGE.md](./USAGE.md) for example code.
## Cross-Device Authentication
When a user attempts to authenticate on a device where their Passkey is **not** stored, they may scan a QR code to use a cloud-sync'd Passkey.
### Example
Imagine a user, Amal, registers a Passkey for example.com on their iPhone and it's stored in iCloud. When they attempt to sign into example.com on a non-Apple device or any browser which cannot access their OS keychain, they may choose to scan a QR code using their iPhone. Assuming the prompts on the iPhone are successful, the other device will be authenticated using the same web account which was initially registered on the iPhone.
While this example refers to Apple's Passkey implementation, the process on other platforms may vary. Cross-device credential managers like 1Password may provide a more seamless flow for users who are not constrained to one OS or browser.
## Assigns
- `@challenge`: (Internal) A `Wax.Challenge` struct created by the component, used to request an existing credential in the client.
Expand All @@ -24,8 +39,8 @@ defmodule WebauthnComponents.AuthenticationComponent do
## Messages
- `{:find_credentials, user_handle: user_handle}`
- `user_handle` is a raw binary representing the user id or random id stored in the credential during registration.
- `{:find_credential, key_id: key_id}`
- `key_id` is a raw binary representing the id stored associated with the credential in both the client and server during registration.
- The parent LiveView must successfully lookup the user with this data before storing a token and redirecting to another view.
- `{:error, payload}`
- `payload` contains the `message`, `name`, and `stack` returned by the browser upon timeout or other client-side errors.
Expand Down Expand Up @@ -73,7 +88,7 @@ defmodule WebauthnComponents.AuthenticationComponent do
%{
authenticator_data: authenticator_data,
client_data_array: client_data_array,
signature: signature,
signature: signature
} = attestation

%{
Expand All @@ -84,14 +99,15 @@ defmodule WebauthnComponents.AuthenticationComponent do

credentials = [{key_id, public_key}]

wax_response = Wax.authenticate(
key_id,
authenticator_data,
signature,
client_data_array,
challenge,
credentials
)
wax_response =
Wax.authenticate(
key_id,
authenticator_data,
signature,
client_data_array,
challenge,
credentials
)

case wax_response do
{:ok, _authenticator_data} ->
Expand All @@ -103,7 +119,6 @@ defmodule WebauthnComponents.AuthenticationComponent do
send(self(), {:authentication_failure, message: message})
{:ok, assign(socket, assigns)}
end

end

def update(assigns, socket) do
Expand Down Expand Up @@ -143,25 +158,22 @@ defmodule WebauthnComponents.AuthenticationComponent do
"clientDataArray" => client_data_array,
"rawId64" => raw_id_64,
"signature64" => signature_64,
"type" => type,
"userHandle64" => user_handle_64
"type" => type
} = 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
type: type
}

send(self(), {:find_credentials, user_handle: user_handle})
send(self(), {:find_credential, key_id: raw_id})

{
:noreply,
Expand Down
1 change: 0 additions & 1 deletion lib/webauthn_components/cose_key.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ defmodule WebauthnComponents.CoseKey do
schema "user_keys" do
field :label, :string, default: "default"
field :key_id, :binary
field :user_handle, :binary
field :public_key, CoseKey
belongs_to :user, User
field :last_used, :utc_datetime
Expand Down
70 changes: 42 additions & 28 deletions lib/webauthn_components/registration_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ defmodule WebauthnComponents.RegistrationComponent do
> Registration = Sign Up
Registration is the process of creating and associating a new key with a user account. Depending on your implementation, a new user may register a new account using only a Passkey, which does not require username or email.
Registration is the process of creating and associating a new key with a user account.
Existing users may also register additional keys for backup, survivorship, sharing, or other purposes. Your application may set limits on how many keys are associated with an account based on business concerns.
See [USAGE.md](./USAGE.md) for example code.
## Assigns
- `@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.
- `@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.
- `@require_resident_key` (Optional) Set to `false` to allow non-passkey credentials. Defaults to `true`.
- `@user`: (Optional) A map or struct containing an `id`, `username` or `email`, and `display_name`. If no user is provided, a random `id` will be generated, which will be encoded as the `user_handle` during registration.
## Events
Expand All @@ -34,10 +34,9 @@ defmodule WebauthnComponents.RegistrationComponent do
The following messages **must be handled by the parent LiveView** using [`Phoenix.LiveView.handle_info/2`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:handle_info/2):
- `{:registration_successful, key_id: raw_id, public_key: public_key, user_handle: user_handle}`
- `{:registration_successful, key_id: raw_id, public_key: public_key}`
- `:key_id` is a raw binary containing the credential id created by the browser.
- `:public_key` is a map of raw binaries which may be used later for authentication.
- `:user_handle` is a raw binary representing the provided user id, or a randomly generated uuid.
- These values must be persisted by the parent application in order to be used later during authentication.
- `{:registration_failure, message: message}`
- `:message` is an exception message returned by Wax when registration fails.
Expand All @@ -49,6 +48,7 @@ defmodule WebauthnComponents.RegistrationComponent do
use Phoenix.LiveComponent
import WebauthnComponents.IconComponents
import WebauthnComponents.BaseComponents
alias WebauthnComponents.WebauthnUser

def mount(socket) do
{
Expand All @@ -57,12 +57,33 @@ defmodule WebauthnComponents.RegistrationComponent do
|> assign(:challenge, fn -> nil end)
|> assign_new(:id, fn -> "registration-component" end)
|> assign_new(:class, fn -> "" end)
|> assign_new(:user, fn -> nil end)
|> assign_new(:webauthn_user, fn -> nil end)
|> assign_new(:disabled, fn -> false end)
|> assign_new(:require_resident_key, fn -> true end)
}
end

def update(%{webauthn_user: webauthn_user}, socket) do
if is_struct(webauthn_user, WebauthnUser) do
{
:ok,
socket
|> assign(:webauthn_user, webauthn_user)
}
else
send(self(), {:invalid_webauthn_user, webauthn_user})
{:ok, socket}
end
end

def update(assigns, socket) do
{
:ok,
socket
|> assign(assigns)
}
end

def render(assigns) do
~H"""
<span>
Expand All @@ -84,21 +105,19 @@ defmodule WebauthnComponents.RegistrationComponent do

def handle_event("register", _params, socket) do
%{assigns: assigns, endpoint: endpoint} = socket
%{app: app_name, id: id, require_resident_key: require_resident_key, user: user} = assigns
attestation = "none"

user_handle =
if user do
user[:id]
else
:crypto.strong_rand_bytes(64)
end

user = %{
id: Base.encode64(user_handle, padding: false),
name: app_name,
displayName: app_name
}
%{
app: app_name,
id: id,
require_resident_key: require_resident_key,
webauthn_user: webauthn_user
} = assigns

if not is_struct(webauthn_user, WebauthnUser) do
raise "user must be a WebauthnComponents.WebauthnUser struct."
end

attestation = "none"

challenge =
Wax.new_registration_challenge(
Expand All @@ -119,20 +138,19 @@ defmodule WebauthnComponents.RegistrationComponent do
name: app_name
},
timeout: 60_000,
user: user
user: webauthn_user
}

{
:noreply,
socket
|> assign(:challenge, challenge)
|> assign(:user_handle, user_handle)
|> push_event("registration-challenge", challenge_data)
}
end

def handle_event("registration-attestation", payload, socket) do
%{challenge: challenge, user_handle: user_handle} = socket.assigns
%{challenge: challenge, webauthn_user: webauthn_user} = socket.assigns

%{
"attestation64" => attestation_64,
Expand All @@ -148,12 +166,8 @@ defmodule WebauthnComponents.RegistrationComponent do
case wax_response do
{:ok, {authenticator_data, _result}} ->
%{attested_credential_data: %{credential_public_key: public_key}} = authenticator_data

send(
self(),
{:registration_successful,
key_id: raw_id, public_key: public_key, user_handle: user_handle}
)
key = %{key_id: raw_id, public_key: public_key}
send(self(), {:registration_successful, key: key, webauthn_user: webauthn_user})

{:error, error} ->
message = Exception.message(error)
Expand Down
25 changes: 25 additions & 0 deletions lib/webauthn_components/webauthn_user.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule WebauthnComponents.WebauthnUser do
@moduledoc """
Struct representing required fields used by the WebAuthn API.
"""
@enforce_keys [:id, :name, :display_name]
defstruct [:id, :name, :display_name]

@type t :: %__MODULE__{
id: binary(),
name: String.t(),
display_name: String.t()
}

defimpl Jason.Encoder, for: __MODULE__ do
def encode(struct, opts) do
map =
struct
|> Map.from_struct()
|> Map.put(:displayName, struct.display_name)
|> Map.delete(:display_name)

Jason.Encode.map(map, opts)
end
end
end
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule WebauthnComponents.MixProject do

# Don't forget to change the version in `package.json`
@source_url "https://github.com/liveshowy/webauthn_components"
@version "0.3.3"
@version "0.4.0"

def project do
[
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "webauthn_components",
"version": "0.3.3",
"version": "0.4.0",
"main": "./priv/static/main.js",
"repository": {},
"files": [
Expand Down
11 changes: 9 additions & 2 deletions test/webauthn_components/registration_component_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
defmodule WebauthnComponents.RegistrationComponentTest do
use ComponentCase, async: true
alias WebauthnComponents.RegistrationComponent
alias WebauthnComponents.WebauthnUser

@id "registration-component"

Expand All @@ -19,7 +20,14 @@ defmodule WebauthnComponents.RegistrationComponentTest do
end

describe "handle_event/3 - register" do
test "sends registration challenge to client", %{element: element} do
test "sends registration challenge to client", %{element: element, view: view} do
webauthn_user = %WebauthnUser{
id: :crypto.strong_rand_bytes(64),
name: "testUser",
display_name: "Test User"
}

live_assign(view, :webauthn_user, webauthn_user)
clicked_element = render_click(element)
assert clicked_element =~ "<button"
assert clicked_element =~ "phx-click=\"register\""
Expand All @@ -34,7 +42,6 @@ defmodule WebauthnComponents.RegistrationComponentTest do
test "fails registration with invalid payload", %{element: element, view: view} do
challenge = build(:registration_challenge)
live_assign(view, :challenge, challenge)
live_assign(view, :user_handle, :crypto.strong_rand_bytes(64))

attestation_64 = Base.encode64(:crypto.strong_rand_bytes(64), padding: false)
raw_id_64 = Base.encode64(:crypto.strong_rand_bytes(64), padding: false)
Expand Down

0 comments on commit 7ea972c

Please sign in to comment.