home overview tags

Herman verschooten

These things I recently learned, that may be useful to myself and others in the future.

Adding Passkey support

I am not going to explain what Passkeys are, there are sufficient resources on the web already.
I have a Phoenix-based website for my invoicing and crm-related tasks.
It currently uses a login-form that posts to a session_controller.
It works fine, but the Passkey buzz has me interested in adding support for it.
I read about the WebAuthnComponent package by Owen bickford.

The package has a nice readme, and explains how to add passkey support to a new Phoenix application... but that is not what I want.
I spent a couple of hours reading the code and the accompanying demo application. I got the drift, but the UserToken took me some extra time to grok.

So how to add this to my current app?

We start out with adding the package to our mix file and running mix deps.get

     {:webauthn_components, "~> 0.8"}

A large part of the authentication flow is handled with Javascript, so let's first add these parts to our app.js


import {
  SupportHook,
  AuthenticationHook,
  RegistrationHook,
} from "webauthn_components";

let Hooks = { SupportHook, AuthenticationHook, RegistrationHook }

I already had a couple of hooks, so initializing my Hooks variable with the new hooks was straightforward.
That's all for the Javascript part, nice.

Now, I need a place where I can add my Passkey. Oh wait, I need to create a table for the keys and make some changes to my user.
Contrary to most I do not nest my schemas below my contexts, all my schemas live under Schema.

mix ecto.gen.migration create_user_keys

defmodule MyApp.Repo.Migrations.CreateUserKeys do
  use Ecto.Migration

  def change do
    create table(:user_keys, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :label, :string, null: false
      add :key_id, :binary, null: false
      add :public_key, :binary, null: false
      add :last_used_at, :utc_datetime, null: false, default: fragment("now()")

      timestamps()
    end

    create index(:user_keys, [:user_id])
    create unique_index(:user_keys, [:key_id])
    create unique_index(:user_keys, [:user_id, :label])
  end
end

defmodule Schema.UserKey do
  @moduledoc """
  Schema representing a `User`'s Passkey / Webauthn credential.

  ## Considerations

  - A user may have multiple keys.
  - Each key must have a unique label.
  - `:last_used_at` is set when the key is created and updated, and this value cannot be cast through the changesets.
  """
  use Ecto.Schema
  import Ecto.Changeset
  alias WebauthnComponents.CoseKey

  @type t :: %__MODULE__{
          id: binary(),
          label: String.t(),
          key_id: binary(),
          public_key: map(),
          last_used_at: NaiveDateTime.t(),
          inserted_at: NaiveDateTime.t(),
          updated_at: NaiveDateTime.t()
        }

  @primary_key {:id, Ecto.ULID, autogenerate: true}
  @derive {Jason.Encoder, only: [:key_id, :public_key, :label, :last_used_at]}
  schema "user_keys" do
    field :label, :string, default: "default"
    field :key_id, :binary
    field :public_key, CoseKey
    belongs_to :user, Schema.User
    field :last_used_at, :naive_datetime

    timestamps()
  end
end

The above schema was copied almost literally from the demo app, I just removed all the changeset functions, as I prefer to keep them where needed.
Now add a reference in Schema.User, again like in the demo app.

 :keys, Schema.UserKey, preload_order: [desc: :last_used_at]

Generate the table with mix ecto.migrate, and we are ready for the next part.

Registration

I recently added a Profile page, so that seems to be the best place to add the Passkey registration.
We add some aliases for convenience.

  alias WebauthnComponents.RegistrationComponent
  alias WebauthnComponents.SupportComponent
  alias WebauthnComponents.WebauthnUser

We set a passkey assign to false to begin with, this will tell us if passkey authentication is supported in this browser.

    {:ok,
     socket
     ...
     |> assign(passkey: false)}

Then in our render/1 function we add 2 live components.

  def render(assigns) do
    ~H"""
    <.live_component module={SupportComponent} id="support-component" />
    <h1 class="mt-3 text-xl font-semibold">Profile</h1>
    <.simple_form for={@form} phx-change="validate" phx-submit="save">
    ...
      <:actions>
        <.button>Save</.button>
        <div :if={@passkey}>
          <.live_component
            module={RegistrationComponent}
            id="registration-component"
            app={MyApp}
            display_text="Passkey"
            class={[
              "bg-gray-200 hover:bg-gray-300 hover:text-black rounded transition",
              "ring ring-transparent focus:ring-gray-400 focus:outline-none",
              "flex gap-2 items-center justify-center px-4 py-2 w-full",
              "disabled:cursor-not-allowed disabled:opacity-25"
            ]}
          />
        </div>
      </:actions>
    </.simple_form>
    """
  end

The SupportComponent checks if Passkey support is available, which we need to handle.

  def handle_info({:passkeys_supported, true}, socket) do
    current_user = socket.assigns.current_user

    webauthn_user = %WebauthnUser{
      id: generate_encoded_id(),
      name: current_user.login,
      display_name: current_user.firstname <> " " <> current_user.name
    }

    send_update(RegistrationComponent, id: "registration-component", webauthn_user: webauthn_user)

    {:noreply,
     socket
     |> assign(webauthn_user: webauthn_user)
     |> assign(passkey: true)}
  end

  def handle_info({:passkeys_supported, false}, socket) do
    {:noreply,
     socket
     |> assign(passkey: false)}
  end
  ...
  defp generate_encoded_id do
    :crypto.strong_rand_bytes(64)
    |> Base.encode64(padding: false)
  end

If available we initialize the WebauthnUser with the current user's information and send this to the RegistrationComponent.
The RegistrationComponent renders a button that starts the registration process, but all we need to handle is...


  def handle_info({:registration_successful, params}, socket) do
    with {:ok, _key} <- MyApp.User.add_passkey(socket.assigns.current_user, params[:key]) do
      {:noreply, socket |> put_flash(:info, "Passkey added")}
    else
      {:error, changeset} ->
        {:noreply,
         socket |> put_flash(:error, "Failed to add passkey, #{inspect(changeset.errors)}")}
    end
  end

  def handle_info(msg, socket) do
    {:noreply, socket |> put_flash(:error, inspect(msg))}
  end

The add_passkey/2 function inserts the key into the user_keys table.

And that completes the registration part. Phew!

Authentication

For my authentication I decided to create a new live_view page.

defmodule MyAppWeb.LoginLive do
  use MyAppWeb, :live_view
  alias WebauthnComponents.AuthenticationComponent
  alias WebauthnComponents.SupportComponent

  def mount(_params, _session, socket) do
    form = %{"login" => "", "password" => ""} |> to_form()

    {:ok,
     socket
     |> assign(form: form)
     |> assign(passkey: false)}
  end

  def render(assigns) do
    ~H"""
    <.live_component module={SupportComponent} id="support-component" />
    <.flash_group flash={@flash} />
    <div class="w-screen h-screen flex justify-center items-center">
      <div class="p-4 border rounded bg-[#cccccc] w-full sm:w-1/2 xl:w-1/3 flex flex-col sm:flex-row items-center gap-2">
        <img src={~p"/images/logo.png"} class="self-center w-32 md:mr-4 md:pr-3 bg-transparent" />
        <.simple_form
          :let={f}
          for={@form}
          phx-change="validate"
          phx-submit="login"
          class="flex flex-col gap-2 flex-1 md:border-l md:pl-4 min-w-min bg-transparent"
        >
          <input
            type="text"
            name="login"
            value={f[:login].value}
            placeholder="login"
            class="rounded w-full mb-2"
          />
          <input type="password" name="password" placeholder="password" class="rounded w-full" />
          <:actions>
            <.button>Logon</.button>
            <.live_component
              :if={@passkey}
              module={AuthenticationComponent}
              id="authentication-component"
              display_text="Passkey"
              class={[
                "bg-gray-200 hover:bg-gray-300 hover:text-black rounded transition",
                "ring ring-transparent focus:ring-gray-400 focus:outline-none",
                "flex gap-2 items-center justify-center px-4 py-2 w-full",
                "disabled:cursor-not-allowed disabled:opacity-25"
              ]}
            />
          </:actions>
        </.simple_form>
      </div>
    </div>
    """
  end

  def handle_event("validate", params, socket) do
    {:noreply, socket |> assign(form: to_form(params))}
  end

  def handle_event("login", %{"login" => login, "password" => password}, socket) do
    with {:ok, user} <- MyApp.Authentication.authenticate(login, password) do
      redirect_to_session(user, socket)
    else
      {:error, _} ->
        {:noreply,
         socket
         |> put_flash(:error, "There was an issue with your credentials!")}
    end
  end

  def handle_info({:passkeys_supported, supported?}, socket) do
    {:noreply,
     socket
     |> assign(passkey: supported?)}
  end

  def handle_info({:find_credential, [key_id: key_id]}, socket) do
    case MyApp.User.get_by_key_id(key_id) do
      {:ok, user} ->
        send_update(AuthenticationComponent, id: "authentication-component", user_keys: user.keys)

        {
          :noreply,
          socket
          |> assign(:user, user)
          |> assign(:key_id, key_id)
        }

      {:error, :not_found} ->
        {
          :noreply,
          socket
          |> put_flash(:error, "Failed to sign in")
        }
    end
  end

  def handle_info({:authentication_successful, _auth_data}, socket) do
    %{user: user, key_id: key_id} = socket.assigns

    MyApp.User.touch_key(key_id)

    redirect_to_session(user, socket)
  end

  def handle_info({:error, %{"name" => "AbortError"}}, socket) do
    {:noreply,
     socket
     |> put_flash(:warning, "Passkey aborted, please login with credentials")}
  end

  def handle_info({:error, _}, socket) do
    {:noreply,
     socket
     |> put_flash(:error, "Passkey failed, please login with credentials")}
  end

  defp redirect_to_session(user, socket) do
    token =
      Phoenix.Token.sign(MyAppWeb.Endpoint, "Some very secret salt", %{
        user_id: user.id
      })

    {:noreply,
     socket
     |> redirect(to: "/session/#{token}")}
  end
end

As before we add the SupportComponent and the necessary handle_info/3 callbacks to know if passkey support is available.
The AuthenticationComponent adds a button, but I haven't needed it so far, my browser (Firefox Developper Edition) immediately prompts me to use the Passkey.
When we have selected the correct passkey, the handle_info/3 callback is called with :find_credentials and the supplied :key_id, these we need to get the user from the database.
When found we update the component, and assign both the found user and key_id to the socket.
Upon successful authentication, we touch the key to update it's :last_updated_at field and redirect to our SessionController.
We still need this controller to put our :user_id in the session, this is not possible from live_view.
To do this, we protect the :user_id with a Phoenix.Token that we put in the url.
When we decide not to use the passkey for authentication the AuthenticationComponent sends an :error tuple with AbortError.

If we use the normal password authentication, the same session redirect is used.

In the SessionController the create/2 function is used to complete the flow.


  def create(conn, %{"token" => token}) do
    with {:ok, %{user_id: user_id}} <-
           Phoenix.Token.verify(MyAppWeb.Endpoint, "Some very secret salt", token, max_age: 5),
         {:ok, user} <- MyApp.User.fetch(user_id) do
      Sentry.Context.set_user_context(%{id: user.id, username: user.name, email: user.email})

      conn
      |> put_session(:user_id, user.id)
      |> redirect(to: ~p"/")
    end
    else
      _ ->
        conn
        |> put_flash(:error, "There was an issue with your credentials!")
        |> redirect(to: ~p"/login")
    end
  end

We fetch the :user_id from the token, fetch the user, update the session and go to the dashboard.
In case something goes wrong, it's back to the login page.

Conclusion

At first I thought it would be much more difficult to add Passkey support to my existing Phoenix app, but it turned out okay.
Some things I glossed over are the context functions, the :on_mount callback that checks for the :user_id and adds the user to the assigns,...
these should not be to difficult.
I do recommend to check out the demo repo, as it contains some extra functionality concerning UserTokens, but I decided not to use this for my application.

Caveat !!!

When I was initially testing the registration it would fail every time with either an :origin error or some message about relying party, or the operation is insecure, it took me some serious time to find the reason.
The package uses Wax for the serious stuff and in one of the calls :origin is set to endpoint.url()
This url/0 function in your Phoenix Endpoint, contains in :dev something like http://localhost:4000.
The Wax code checks for localhost and changes some settings to allow it.
But in my dev setup I use urls with a .test extension over https, this was causing the url to contain https://localhost:4443 which is not valid for passkey authentication.
The :origin is not allowed to have a port attached to it. The solution was to add url: [host: "my_app.test", port: 443] to my endpoint in config/dev.exs.

For more information about the library, check out the post on the Elixir Forum.