diff --git a/site/posts/2023-10-19-phoenix-passkeys.md b/site/posts/2023-10-19-phoenix-passkeys.md new file mode 100644 index 0000000..0a00609 --- /dev/null +++ b/site/posts/2023-10-19-phoenix-passkeys.md @@ -0,0 +1,915 @@ +``` +title = "Passkey Sign In with Elixir and Phoenix" +tags = ["elixir"] +date = "2023-10-19 11:00:42 -0400" +short_desc = "" +slug = "phoenix-passkeys" +``` + +Passkeys are a replacement for passwords that use public/private cryptographic key pairs for login in a way that can be more user-friendly and resistant to a number of kinds of attacks. Let's implement account registration and login with passkeys in a simple Phoenix web app. + +This work is heavily based on [this article](https://www.imperialviolet.org/2022/09/22/passkeys.html) by Adam Langley, which provides a great deal of information about implementing passkeys. My goal here is to fill in some more of the details, and provide some Elixir-specific information. As with Adam's article, I'm not going to use any WebAuthn libraries (even though that may be advisable from a security/maintenance perspective) since I think it's interesting and helpful to understand how things actually work. + +Providing an exhaustive, production-ready implementation is a non-goal of this post. I'm going to make some slightly odd decisions for pedagogical reasons, and leave some things incomplete. That said, I'll try to note when I'm doing so. + + + +To start, I'm using the default Phoenix template app (less Tailwind) in which I've also generated the default, controller-based [authentication system](https://hexdocs.pm/phoenix/mix_phx_gen_auth.html). Some parts of the password-specific stuff have been stripped out altogether, others will get changed to fit with the passkey authentication setup we're going to build. + +## Database schema + +The first thing we'll need is a backend schema for passkeys. The only data we need to store for a particular passkey is a unique identifier, and the public key itself. We also want users to be able to have multiple passkeys associated with their account (since they may want to login from multiple platforms that don't cross-sync passkeys), so the users schema will have a one-to-many relationship with the passkeys. + + + +The actual model I'll call `UserCredential`, since that's closer to what the WebAuthn spec calls them. Here's the schema, it's pretty simple: + +```elixir +defmodule PhoenixPasskeys.Accounts.UserCredential do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary, []} + + schema "users_credentials" do + # DER-encoded Subject Public Key Info: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.7 + field :public_key_spki, :binary + belongs_to :user, PhoenixPasskeys.Accounts.User + timestamps() + end + + def changeset(credential, attrs) do + credential + |> cast(attrs, [:id, :public_key_spki, :user_id]) + end +end +``` + +There are a couple things to note here: +1. First, we're explicitly specifying that the primary key is a binary, since that's what the WebAuthn API provides as the credential ID. +2. The `public_key_spki` field contains the data for the public key itself and the algorithm being used. We don't care about the specific format, though, since we don't have to parse it ourselves. + +To the generated `User` schema, I also added the `has_many` side of the relationship. There's also a migration to create the credentials table. I won't show it here, since it's exactly what you'd expect—just make sure the `id` column is a binary. + +## Registration JavaScript +WebAuthn, being a modern web API, is a JavaScript API. This is a distinct disadvantage, if your users expect to be able to login from JavaScript-less browsers. Nonetheless, we proceed with the JS. Here's the first bit: + +```js +document.addEventListener("DOMContentLoaded", () => { + const registrationForm = document.getElementById("registration-form"); + if (registrationForm) { + registrationForm.addEventListener("submit", (event) => { + event.preventDefault(); + registerWebAuthnAccount(registrationForm); + }); + } +}); +``` + +If we find the registration `