Current section
Files
Jump to
Current section
Files
docs/getting_started.md
# Getting started
Build a Phoenix application where someone registers a username and passkey, saves recovery
codes, opens a protected page and signs out. This guide uses open registration. Add
[invitation-only registration](invitations.md) once this flow works.
You need Elixir 1.18 or newer, PostgreSQL, Phoenix 1.8 and LiveView 1.1. The examples below use
the layouts, components and asset configuration generated by Phoenix 1.8. For SQLite, generate
the project with `--database sqlite3`; for MySQL, use `--database mysql`. Apply the corresponding
[database settings](configuration.md#databases) before migrating.
```console
$ mix phx.new my_app
$ cd my_app
$ mix ecto.create
```
Keep the files generated by Phoenix unless a step explicitly replaces them. For a complete
application to compare against, see the
[open-registration example](https://github.com/oliverandrich/ithibati/tree/main/examples/open_registration).
## 1. Add the dependency
Add Ithibati to the existing dependency list in `mix.exs`:
```elixir
{:ithibati, "~> 0.8"}
```
Then fetch it:
```console
$ mix deps.get
```
## 2. Configure it
Add this to `config/config.exs`, above its final `import_config` line:
```elixir
config :ithibati,
repo: MyApp.Repo,
user_schema: MyApp.Accounts.User,
users_key_type: :id
```
`:id` matches the account table this guide creates. If your application uses UUID primary keys,
see [Configuration and schemas](configuration.md#primary-keys-and-table-names).
`users_key_type` and `table_prefix` are compile-time settings and belong in `config/config.exs`.
### Keeping the session cookie
A session lasts `session_validity`, sixty days unless configured. The browser keeps the cookie
that carries it only as long as the cookie's max age says; without one it drops the cookie when it
closes. In `lib/my_app_web/endpoint.ex`, replace `plug Plug.Session, @session_options` with a
function plug that adds the session's validity:
```elixir
plug :session
defp session(conn, _opts) do
options = Keyword.put(@session_options, :max_age, Ithibati.Identity.Sessions.max_age())
Plug.Session.call(conn, Plug.Session.init(options))
end
```
The plug reads the validity per request, so `session_validity` may also be set in
`config/runtime.exs`. The cookie is written whenever the session changes, so it lasts at least as
long as the session; once the session expires, the server refuses it. A cookie keeps the age it
was written with: after raising `session_validity`, older cookies still end at their shorter age.
The LiveView socket keeps `@session_options`: the max age concerns writing the cookie, not reading
it.
An application that adds this later leaves members who are already signed in with a cookie
without a max age. It gains one the next time their session changes, for instance when they sign
in again.
## 3. The account schema
Create `lib/my_app/accounts/user.ex`:
```elixir
defmodule MyApp.Accounts.User do
use Ecto.Schema
alias Ithibati.Schema.Identifier
alias Ithibati.Schema.User
use User, identifier: :username, format: Identifier.username_format()
import Ecto.Changeset
schema "users" do
ithibati_account()
field :name, :string
timestamps(type: :utc_datetime_usec)
end
def changeset(user, attrs) do
user
|> identifier_changeset(attrs)
|> cast(attrs, [:name])
end
end
```
`ithibati_account()` declares `username` and the passkey, recovery-code and session associations.
The changeset trims and lowercases the username, requires it, and checks its format. The built-in
username format accepts ASCII letters, digits and underscores, up to thirty characters.
The `name` field belongs to this example application. Other identifier fields, custom formats
and display names are covered in [Configuration and schemas](configuration.md#identifiers).
## 4. The migration
Generate two migrations, in this order:
```console
$ mix ecto.gen.migration create_users
$ mix ecto.gen.migration add_ithibati
```
In the generated `*_create_users.exs` file under `priv/repo/migrations/`, create your account table:
```elixir
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :username, :string, null: false
add :name, :string
timestamps(type: :utc_datetime_usec)
end
end
end
```
In `*_add_ithibati.exs`, create Ithibati's tables and the unique index on `users.username`:
```elixir
defmodule MyApp.Repo.Migrations.AddIthibati do
use Ecto.Migration
def up, do: Ithibati.Migration.up(version: 5)
def down, do: Ithibati.Migration.down(version: 5)
end
```
Keep `version: 5` pinned. The account table must exist before this migration runs.
Ithibati checks its identifier and primary-key columns before creating its own tables.
```console
$ mix ecto.migrate
```
You now have the account table, the identifier's unique index, and Ithibati's passkey,
recovery-code, session and bootstrap tables. See [Migration behaviour](configuration.md#migrations)
for ownership, deletion and upgrade details.
## 5. The handler
Create `lib/my_app_web/auth.ex`. This handler approves registration, creates the account and its
credentials in one transaction, and signs in verified accounts:
```elixir
defmodule MyAppWeb.Auth do
@behaviour Ithibati.Web.Handler
import Phoenix.Controller, only: [json: 2]
import Plug.Conn, only: [put_session: 3]
alias Ecto.Multi
alias Ithibati.Identity.Grant
alias Ithibati.Web.Gate
alias MyApp.Accounts.User
alias MyApp.Repo
@impl true
def registration_subject(_conn, %{"username" => username}) do
%User{}
|> User.changeset(%{"username" => username})
|> Ecto.Changeset.apply_action(:insert)
|> case do
{:ok, account} -> {:ok, account.username}
{:error, _changeset} -> {:error, :invalid_username}
end
end
def registration_subject(_conn, _params), do: {:error, :username_required}
@impl true
def register(conn, key_attrs, username, _params) do
Multi.new()
|> Multi.insert(:account, User.changeset(%User{}, %{"username" => username}))
|> Grant.with_key_and_codes(key_attrs)
|> Repo.transaction()
|> case do
{:ok, %{account: account, recovery_codes: codes}} ->
{:ok,
conn
|> Gate.log_in(account)
|> put_session(:recovery_codes, codes)
|> json(%{redirect: "/recovery-codes"})}
{:error, :account, %Ecto.Changeset{} = changeset, _changes} ->
if Ithibati.Schema.User.identifier_taken?(changeset),
do: {:error, :username_taken},
else: {:error, :invalid_username}
{:error, _step, reason, _changes} ->
{:error, reason}
end
end
@impl true
def authenticate(conn, account),
do: {:ok, conn |> Gate.log_in(account) |> json(%{redirect: "/inside"})}
@impl true
def recovered(conn, account, nil), do: authenticate(conn, account)
def recovered(conn, account, fresh) do
{:ok,
conn
|> Gate.log_in(account)
|> put_session(:recovery_codes, fresh)
|> json(%{redirect: "/recovery-codes"})}
end
end
```
`registration_subject/2` validates the identifier before the browser opens its passkey dialog.
`register/4` receives that approved identifier as its third argument. Use it when creating the
account; request parameters are sent again and may have changed.
`Grant.with_key_and_codes/3` expects an account step named `:account` and adds `:passkey` and
`:recovery_codes`. Application steps that can refuse should go before the grant. Avoid logging
whole transaction results: they can contain plaintext recovery codes.
`recovered/3` receives a fresh batch only when the last unused recovery code was spent. This
handler sends it to the same page used after registration. After `Gate.log_in/2`, finish with a
full page load so the browser receives the renewed session and CSRF token. The hook follows
the JSON `redirect` value for you.
## 6. Showing the recovery codes
Create `lib/my_app_web/controllers/session_controller.ex`:
```elixir
defmodule MyAppWeb.SessionController do
use MyAppWeb, :controller
alias Ithibati.Web.Gate
def recovery_codes(conn, _params) do
case get_session(conn, :recovery_codes) do
nil -> redirect(conn, to: "/inside")
codes -> conn |> delete_session(:recovery_codes) |> render(:recovery_codes, codes: codes)
end
end
def sign_out(conn, _params), do: conn |> Gate.log_out() |> redirect(to: "/")
end
```
The controller removes the temporary session entry when it renders the codes. The database
stores only their digests, so the application cannot retrieve the batch later. This is an HTTP
controller because it must update the session cookie.
Until this page renders, the codes travel in the session cookie. Phoenix signs that cookie but
does not encrypt it, so anyone holding a copy can read them. Recovery codes stay valid after a
session is revoked. Add an `encryption_salt` to `@session_options` in
`lib/my_app_web/endpoint.ex`:
```elixir
@session_options [
store: :cookie,
key: "_my_app_key",
signing_salt: "...",
encryption_salt: "...",
same_site: "Lax"
]
```
`mix phx.gen.secret 8` generates a salt. Adding it invalidates existing signed
cookies, so people who are signed in have to sign in again.
Create its HTML module at `lib/my_app_web/controllers/session_html.ex`:
```elixir
defmodule MyAppWeb.SessionHTML do
use MyAppWeb, :html
embed_templates "session_html/*"
end
```
Create `lib/my_app_web/controllers/session_html/recovery_codes.html.heex`:
```heex
<Layouts.app flash={%{}}>
<.header>
Your recovery codes
<:subtitle>
Twelve, each good for one sign-in, and this is the only time they are shown.
</:subtitle>
</.header>
<div class="alert alert-warning mt-6">
<span>Save these codes somewhere safe. Use one if you lose access to your passkeys.</span>
</div>
<ul class="mt-6 grid grid-cols-2 gap-2 font-mono text-sm">
<li :for={code <- @codes} class="rounded bg-base-200 px-3 py-2">{code}</li>
</ul>
<p class="mt-6"><.link href={~p"/inside"} class="link">Done</.link></p>
</Layouts.app>
```
The default batch contains twelve codes, each good for one sign-in. [Recovery codes](recovery.md)
explains regeneration and automatic refill.
## 7. The routes
In `lib/my_app_web/router.ex`, import `Ithibati.Web.Router`, add the gate to the existing browser
pipeline, and add the ceremony pipeline and routes below.
Replace the generated `get "/", PageController, :home` route with the public LiveView route.
Keep any other generated scopes, including the `dev_routes` block; they are omitted here.
```elixir
defmodule MyAppWeb.Router do
use MyAppWeb, :router
import Ithibati.Web.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug Ithibati.Web.Gate, :current_account
end
pipeline :ceremony do
plug :accepts, ["json"]
plug :fetch_session
plug :protect_from_forgery
end
scope "/auth" do
pipe_through :ceremony
ithibati_routes handler: MyAppWeb.Auth, rp_name: "MyApp"
end
scope "/", MyAppWeb do
pipe_through :browser
live_session :public, on_mount: [{Ithibati.Web.Gate, :current_account}] do
live "/", SignInLive
end
live_session :members, on_mount: [{Ithibati.Web.Gate, {:require_account, to: "/"}}] do
live "/inside", InsideLive
end
get "/recovery-codes", SessionController, :recovery_codes
delete "/session", SessionController, :sign_out
end
end
```
The ceremony pipeline accepts JSON, fetches the session that holds the challenge, and checks
CSRF protection. Reusing the HTML-only browser pipeline would reject the hook's requests.
`Gate` assigns `@current_account` in both HTTP requests and LiveView mounts. The members session
requires an account and redirects visitors to `/` when none is present.
## 8. The browser
In `assets/js/app.js`, import Ithibati's hooks and merge them into the existing `LiveSocket`
configuration. Preserve the other generated options and hooks:
```javascript
import {hooks as ithibatiHooks} from "ithibati"
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ...ithibatiHooks},
})
```
The package import works with the Hex dependency and Phoenix 1.8's generated esbuild setup.
For a local path dependency or the colocated manifest, see
[Browser imports](ceremonies.md#browser-imports).
## 9. The page people register and sign in on
Create `lib/my_app_web/live/sign_in_live.ex` and its parent directory. The registration form
collects a username. Sign-in needs only a button: the browser lets the person choose a passkey.
The recovery form sends a code to the recovery endpoint.
```elixir
defmodule MyAppWeb.SignInLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket), do: {:ok, assign(socket, username: "", error: nil)}
@impl true
def handle_event("validate", %{"username" => username}, socket),
do: {:noreply, assign(socket, username: username, error: nil)}
def handle_event("register", %{"username" => username}, socket),
do:
{:noreply,
socket |> assign(error: nil) |> push_event("ithibati:register", %{username: username})}
def handle_event("sign-in", _params, socket),
do: {:noreply, socket |> assign(error: nil) |> push_event("ithibati:authenticate", %{})}
def handle_event("recover", %{"code" => code}, socket),
do: {:noreply, socket |> assign(error: nil) |> push_event("ithibati:recover", %{code: code})}
# A successful ceremony ends in the redirect the handler answered with, so only failures arrive
# back here.
def handle_event("ithibati:failed", %{"error" => error} = payload, socket),
do: {:noreply, assign(socket, error: message(error, payload["exception"]))}
def handle_event("ithibati:done", _payload, socket), do: {:noreply, socket}
defp message("ceremony_failed", name) when is_binary(name), do: "Your browser refused: #{name}."
defp message(error, _name), do: message(error)
defp message("username_taken"), do: "That username is taken."
defp message("invalid_username"),
do: "A username is letters, digits and underscores, up to thirty characters."
defp message("username_required"), do: "Pick a username to register."
defp message("no_credentials"), do: "No passkey is registered here yet."
defp message("invalid_code"), do: "That recovery code is not one we can use."
defp message("ceremony_cancelled"), do: "The passkey prompt was dismissed."
defp message("already_enrolled"), do: "That device already holds a passkey for this site."
defp message("no_challenge"), do: "That took too long. Start again."
defp message("malformed_credential"), do: "Your browser sent something this site cannot read."
defp message("not_discoverable"), do: "That device will not store a passkey this site can find."
defp message("unknown_credential"), do: "That passkey is not one this site knows."
defp message("no_attested_credential"), do: "Your browser sent no passkey to store."
defp message("credential_id_too_long"), do: "That passkey is bigger than this site can store."
defp message("verification_failed"), do: "That did not check out. Start again."
defp message("ceremony_failed"), do: "Your browser stopped partway through."
defp message("recovery_failed"), do: "Recovery did not finish. Please try again."
defp message("unknown"), do: "That request failed without saying why."
defp message(other), do: "Something went wrong: #{other}"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div :if={@error} class="alert alert-error"><span>{@error}</span></div>
<form phx-change="validate" phx-submit="register">
<.input name="username" value={@username} label="Username" required placeholder="ada_lovelace" />
<.button variant="primary">Register</.button>
</form>
<.button phx-click="sign-in" class="btn mt-4">Sign in with a passkey</.button>
<form phx-submit="recover" class="mt-8">
<.input name="code" value="" label="Lost your passkey? Use a recovery code" required />
<.button class="btn">Sign in with a code</.button>
</form>
<div
id="passkey"
phx-hook="Ithibati.Web.Hooks.PasskeyCeremony"
data-registration-challenge-url={~p"/auth/registration/challenge"}
data-registration-url={~p"/auth/registration"}
data-authentication-challenge-url={~p"/auth/authentication/challenge"}
data-authentication-url={~p"/auth/authentication"}
data-recovery-url={~p"/auth/recovery"}
>
</div>
</Layouts.app>
"""
end
end
```
The hook reads its endpoint URLs from the empty `div`. `push_event/3` starts the exchange;
`ithibati:failed` returns an error code for your page to explain. The `validate` event in this
example only retains the username; the handler performs validation before issuing a challenge.
Create the protected page at `lib/my_app_web/live/inside_live.ex`:
```elixir
defmodule MyAppWeb.InsideLive do
use MyAppWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<p>
Signed in as <strong>{@current_account.username}</strong>
— <.link href={~p"/session"} method="delete" class="link">sign out</.link>.
</p>
</Layouts.app>
"""
end
end
```
## 10. Check it, then run it
```console
$ mix ithibati.doctor
$ mix phx.server
```
Open `http://localhost:4000` and try the complete flow:
1. Register a username and passkey.
2. Save the recovery codes and follow **Done** to `/inside`.
3. Sign out, then open `/inside` directly. It should redirect to `/`.
4. Sign in with the passkey. You should return to `/inside`.
5. Sign out and sign in with one recovery code.
6. Sign out and try that same code again. It should be refused.
The doctor checks setup; this browser pass checks the pages, assets and redirects as well.
See [Setup checks](doctor.md) if configuration or migrations fail.
Before deploying, set the endpoint's public URL as described in
[The relying party](ceremonies.md#the-relying-party), and add application-level rate limiting
to sign-in and recovery requests.
Force HTTPS in `config/prod.exs`, so the session cookie is never sent over plain HTTP:
```elixir
config :my_app, MyAppWeb.Endpoint,
force_ssl: [hsts: true, rewrite_on: [:x_forwarded_proto]]
```
`rewrite_on` is for a proxy that terminates TLS. Plug then trusts the proxy's
`X-Forwarded-Proto` header, so the application must be reachable only through the proxy.
Behind HTTPS, Plug marks the session cookie `Secure`.
## Where to go next
- [Configuration and schemas](configuration.md): identifiers, indexes, table names and migration upgrades.
- [Registering and signing in](ceremonies.md): callbacks, browser events, sessions and direct ceremonies.
- [Passkeys](passkeys.md): enrol and manage additional credentials.
- [Recovery codes](recovery.md): issue, redeem and replace codes.
- [Invitations and the first account](invitations.md): restrict who can register.