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.
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
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:
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.