Passkeys in Phoenix using SimpleWebAuthn

Implementing Passkeys in a Phoenix Elixir app seems to be cumbersome as there are no official libraries that can handle it out of the box.

Wax seems to be the closest but mostly everyone implement it using a LiveView JS hook.

A good starting point would be this barebone implementation without any Javascript library by directly calling navigator function. This is a good starting point to understand how everything works under the hood.

But today I wanted to make the implementation easier by using a front-end TypeScript library called SimpleWebAuthn that makes the whole process much easier.

0:00
/0:26

So lets get started by initializing a new Phoenix project

mix phx.new phoenix_webauthn --binary-id --live

Lets start with the required schemas, below is the ERD diagram

Passkey Ecto ERD

Once we have the schemas in place, lets move on to the implementation.

Registration

Lets start by installing the Typescript library,

cd assets
npm install @simplewebauthn/browser
npm install @simplewebauthn/server

We can now create a ts file that will handle all the passkey related stuff

touch auth.ts

Make sure to include this in the default app.js file by adding this line to the bottom of the file

import "./auth";

The registration process involves creating a new user account and associating it with a Passkey. This includes generating, encoding, and storing the public credential key. Here's a high-level overview of the registration flow:

TypeScript (Client-side)

The key part of the registration process on the client-side is the registerWebAuthnAccount function. Here's a simplified version that includes the encoding of the public key:

import { bufferToBase64URLString, startRegistration } from "@simplewebauthn/browser";

async function registerWebAuthnAccount(form: HTMLFormElement) {
  const options = await getUserOptions(form);
  const attResp = await startRegistration(options);
  
  const verification = await verifyRegistrationResponse({
    response: attResp,
    expectedChallenge: options.challenge,
    expectedOrigin: origin,
    expectedRPID: options.rp.id,
    requireUserVerification: false,
  });

  if (verification.verified && verification.registrationInfo) {
    const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
    
    const credentialPublicKeyBase64 = bufferToBase64URLString(credentialPublicKey);
    
    const newDevice = {
      credentialPublicKeyBase64,
      credentialID,
      counter,
      transports: attResp.response.transports,
    };

    // Send these details to the server
    const formData = new FormData();
    formData.append("credential_id", credentialID);
    formData.append("public_key_spki", credentialPublicKeyBase64);
    formData.append("device", JSON.stringify(newDevice));

    // Send formData to server
  }
}

This function generates registration options, starts the registration process with the browser's WebAuthn API, verifies the response, and then encodes the public key before sending it to the server.

Elixir (Server-side)

On the server-side, the UserRegistrationController handles the registration process, including decoding the public key. Here's the key part of the create function and the associated register_user function:

# In UserRegistrationController
def create(conn, %{
      "email" => email,
      "credential_id" => credential_id,
      "public_key_spki" => public_key_spki,
      "device" => device
    }) do
  case Accounts.register_user(email, credential_id, public_key_spki, device) do
    {:ok, user} ->
      # User registered successfully
      conn
      |> UserAuth.log_in_user_without_redirect(user)
      |> json(%{status: :ok})
    {:error, _changeset} ->
      json(conn, %{status: :error})
  end
end

# In Accounts context
def register_user(email, credential_id, public_key_spki, device) do
  decoded_public_key = Base.url_decode64!(public_key_spki, padding: false)
  
  %User{}
  |> User.registration_changeset(%{
    email: email,
    credential_id: credential_id,
    public_key_spki: decoded_public_key,
    device: device
  })
  |> Repo.insert()
end

This code receives the encoded credential information from the client, decodes the public key, and stores it in the database as binary data.

Part 2: Authentication

The authentication process involves verifying a user's identity using their Passkey, which includes retrieving, encoding, and decoding the public credential key. Here's a high-level overview of the authentication flow:

%%{init: {'theme':'forest'}}%% sequenceDiagram participant Client participant Server participant Database Client->>Server: Request authentication options Server->>Database: Retrieve user credentials (binary) Database->>Server: Return user credentials Server->>Server: Encode public key (Base64 URL) Server->>Client: Send authentication options with encoded key Client->>Client: Decode public key Client->>Client: Generate assertion Client->>Server: Send assertion Server->>Server: Verify assertion Server->>Client: Authentication success

TypeScript (Client-side)

The key part of the authentication process on the client-side is the loginWebAuthnAccount function. Here's a simplified version that includes decoding the public key:

import { base64URLStringToBuffer, startAuthentication } from "@simplewebauthn/browser";

async function loginWebAuthnAccount(form: HTMLFormElement) {
  const response = await axios.post<CredentialsResponse>("/users/log_in/credentials", formData);
  
  const device = response.data.credentials[0].device;
  const credential = {
    ...response.data.credentials[0],
    device: {
      ...device,
      credentialPublicKey: base64URLStringToBuffer(device.credential_public_key),
    },
  };

  const options = await generateAuthenticationOptions(/* ... */);
  const asseResp = await startAuthentication(options);
  
  const verification = await verifyAuthenticationResponse({
    response: asseResp,
    expectedChallenge: options.challenge,
    expectedOrigin: "http://localhost:4000",
    expectedRPID: rpID,
    authenticator: credential.device,
    requireUserVerification: false,
  });

  if (verification.verified) {
    // Authentication successful
  }
}

This function retrieves the user's credentials from the server (including the encoded public key), decodes the public key, generates authentication options, starts the authentication process with the browser's WebAuthn API, and then verifies the response.

Elixir (Server-side)

On the server-side, the UserRegistrationController also handles the authentication process, including encoding the public key before sending it back to the client. Here's the key part of the credentials function:

def credentials(conn, %{"email" => email}) do
  case Accounts.get_credentials_by_email(email) do
    credentials when is_list(credentials) ->
      encoded_credentials = Enum.map(credentials, fn cred ->
        %{
          id: cred.id,
          credential_id: cred.credential_id,
          public_key_spki: Base.url_encode64(cred.public_key_spki, padding: false),
          device: serialize_authenticator_device(cred.authenticator_device),
          inserted_at: NaiveDateTime.to_iso8601(cred.inserted_at),
          updated_at: NaiveDateTime.to_iso8601(cred.updated_at)
        }
      end)
      json(conn, %{credentials: encoded_credentials})
    nil ->
      json(conn, %{error: "No credentials found for this email"})
  end
end

defp serialize_authenticator_device(device) do
  %{
    id: device.id,
    credential_public_key: Base.url_encode64(device.credential_public_key, padding: false),
    counter: device.counter,
    transports: device.transports,
    inserted_at: NaiveDateTime.to_iso8601(device.inserted_at),
    updated_at: NaiveDateTime.to_iso8601(device.updated_at)
  }
end

This function retrieves the user's credentials based on their email address, encodes the public key using Base64 URL encoding, and returns the encoded credentials to the client for authentication.

Conclusion

Implementing Passkeys in a Phoenix Elixir app using SimpleWebAuthn involves coordinating between client-side TypeScript code and server-side Elixir code. The process includes generating options, creating or verifying credentials, and managing user authentication state.

A crucial part of this implementation is the proper handling of the public credential key. During registration, the key is encoded on the client-side, sent to the server, decoded, and stored in the database. During authentication, this process is reversed: the key is retrieved from the database, encoded on the server-side, sent to the client, and then decoded for use in the authentication process.

By leveraging the WebAuthn API, the SimpleWebAuthn library, and careful encoding/decoding of the public key, we can provide a secure and user-friendly authentication experience.