Current section
Files
Jump to
Current section
Files
docs/getting_started.md
# Getting started
This page walks an empty Phoenix application from `mix phx.new` to a working sign-in: somebody
registers with a passkey, writes down their recovery codes, lands on a page only accounts can
see, and signs out again. Take the same steps in your own project and the shape is the same.
You need Elixir 1.17 or newer and Postgres. Postgres is a requirement, not a default: the
migration reads your account table's catalogue entries to check what it is about to point a
foreign key at, and no other adapter answers those questions.
For the web half, Phoenix 1.8 and LiveView 1.1. Everything from step 7 on assumes them —
`Layouts.app`, the `<.input>` and `<.button>` components, the colocated-hooks import, and `deps`
being on esbuild's `NODE_PATH`, which is what makes the bare `"ithibati"` specifier resolve. The
context half needs neither.
```console
$ mix phx.new my_app
$ cd my_app
$ mix ecto.create
```
Both example applications in the repository do everything below, and CI drives them in a real
browser. If you would rather read finished code, start there — this page is the
open-registration one, spelled out.
## 1. Add the dependency
```elixir
def deps do
[{:ithibati, "~> 0.1"}]
end
```
Phoenix, LiveView and Plug are optional dependencies, so a `phx.new` application already has
everything the web half needs. Leave them out and you get the context on its own; [Registering
and signing in](ceremonies.md#without-the-web-half) shows the ceremony as plain function calls.
## 2. Configure it
```elixir
config :ithibati,
repo: MyApp.Repo,
user_schema: MyApp.Accounts.User,
users_key_type: :id
```
Put this in `config/config.exs`, above the `import_config` line at the bottom — that line has to
stay last, and `users_key_type` and `table_prefix` are read at compile time, so neither can live
in `config/runtime.exs`.
`users_key_type` is the primary-key type of your users table, and Ithibati's foreign keys use it.
`:id` is what `mix phx.new` generates; write `:binary_id` if you asked for binary ids, which is
also Ithibati's own default. If the table disagrees with what you configured, the migration
refuses rather than building something that cannot be referenced — `users.id is bigint` against a
configured `:binary_id` is the message you get.
## 3. The account schema
You keep your own account schema and your own table. Two lines connect it to Ithibati, and both
generate code you will not see in the snippet below. `use Ithibati.Schema.User` records which
field identifies an account and adds `identifier_changeset/2`.
[`ithibati_account/0`](`Ithibati.Schema.User.ithibati_account/0`) declares that
field and the `passkeys`, `recovery_codes` and `sessions` associations.
```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
```
### The identifier is yours to choose
There is no default, because a library that never sends mail has no business insisting on an
email address. Pass the field an account is known by, and a pattern if you want one:
```elixir
use User, identifier: :email, format: Identifier.email_format()
use User, identifier: :handle
```
This walkthrough uses a username, because that is what Ithibati can support end to end. It
cannot prove that somebody owns an email address — it sends no mail — so an application keyed by
an address is trusting whatever was typed in. That is a normal thing to do, and it is why
[`email_format/0`](`Ithibati.Schema.Identifier.email_format/0`) ships; it is just not what a first
walkthrough should quietly assume.
`format:` is the regex the identifier has to match, and two come with the library. Leave the
option out and `identifier_changeset/2` still trims the value, downcases it and requires it — it
just does not check a shape.
[`username_format/0`](`Ithibati.Schema.Identifier.username_format/0`) is Mastodon's rule for a
*local* account: letters, digits and underscores, at
most thirty characters. Their looser pattern, the one that allows dots and hyphens, is for
addressing accounts on other servers rather than naming your own. It is strict on purpose: dots
and hyphens would let `alice.smith` register next to `alicesmith`, and characters outside ASCII
would let a Cyrillic а register next to a Latin a. Both pairs look identical on screen.
`email_format/0` is the pattern the HTML specification publishes for `<input type=email>`, not
RFC 5322. An identifier here is a credential rather than a mailbox, so the full grammar — quoted
local parts with spaces in them — would be a hazard rather than a feature. It accepts
`you@localhost` and refuses `"a b"@example.com`.
Your own pattern is as welcome as either of them, written inline or named:
```elixir
@handle ~r/\A[a-z][a-z0-9_]{2,29}\z/
use User, identifier: :handle, format: @handle
```
Put the attribute above the `use` line, because that is where your module body reads it. An
attribute that does not exist yet, or one you misspelled, is `nil`, and Ithibati refuses
`format: nil` instead of quietly falling back to no pattern at all. `identifier:` is the one
option you cannot write as an attribute: it has to be a literal atom, because it names the field
your schema declares and belongs where you can read it.
`identifier_changeset/2` trims and downcases what it writes, so a plain unique index already
refuses `AdaLovelace` beside `adalovelace` and you do not need a functional index. A write that
bypasses the changeset stores whatever it is given.
> #### Should the identifier column be `citext`? {: .info}
>
> You do not need it. Lowercasing happens before anything reaches the database, so an ordinary
> unique index already does the job — and `citext` is an extension, which `CREATE EXTENSION
> citext` needs rights for that a hosted database does not always grant.
>
> They are also not the same thing, and the difference is what ends up stored. `citext` keeps
> what somebody typed and compares regardless of case; lowercasing keeps only the lowered
> value. So if you want `AdaLovelace` on a page, that is display: put it in a field of your own,
> or define `passkey_display_name/1` as shown below.
>
> Choose `citext` anyway when the reason is yours rather than Ithibati's — writes that bypass
> the changeset, or rows already in mixed case. Then pass `unique_index: false` and build the
> index yourself, which is what that option is for.
If a passkey dialog should show something friendlier than the identifier, define this:
```elixir
def passkey_display_name(account), do: account.name
```
You do not need a fallback. An account that has not filled it in returns `nil`, and Ithibati
shows the identifier instead.
## 4. The migration
A `phx.new` application has no `users` table — `priv/repo/migrations/` is empty — so you write
that one first. It is entirely yours; Ithibati only needs the identifier column to be in it:
```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
```
`:name` is there because the schema in step 3 declares it; drop it if yours does not. The
timestamps match that schema, which uses `:utc_datetime_usec` — Ithibati's own tables do too, and
Ecto raises if a schema and its table disagree about the type.
Then Ithibati's own migration, which you write and it fills in:
```elixir
defmodule MyApp.Repo.Migrations.AddIthibati do
use Ecto.Migration
def up, do: Ithibati.Migration.up(version: 1)
def down, do: Ithibati.Migration.down(version: 1)
end
```
Pin the version, as above. An unpinned call creates a different set of tables depending on when
it runs, and rolls back neither. Run it after the migrations that create your own tables,
because it points foreign keys at your accounts table.
It creates `ithibati_keys`, `ithibati_recovery_codes`, `ithibati_sessions` and
`ithibati_bootstrap`, each with a foreign key to your account table. It also creates the unique
index on your identifier column, because a changeset cannot keep an identifier unique against a
concurrent insert. Only the database can.
Deleting an account takes its passkeys, its recovery codes and its sessions with it —
`on_delete: :delete_all` — so your own delete path needs no cleanup here. The bootstrap row is
the exception: its `user_id` is nilified rather than the row removed, because what it records is
that this instance *was* set up, and that stays true after the account that did it is gone.
The identifier column itself is yours. Add it in the `create table` that builds your users table,
or in an `alter` if that table already exists:
```elixir
alter table(:users) do
add :username, :string, null: false
end
```
> #### Wait — why does Ithibati index this column but not create it? {: .info}
>
> Because a rollback can take an index back and cannot take a column back.
> [`down/1`](`Ithibati.Migration.down/1`) drops the
> index and your rows are untouched; dropping the column would take the identity of every
> account with it, in a table that is yours. So Ithibati never creates one, and never has to
> decide whether to drop it.
>
> Two smaller reasons sit on top. It could not guess the type — `citext` is a normal choice
> here — and `null: false` cannot be added to a table that already has rows without a default or
> a backfill, so the column it made would be missing the one constraint you wanted.
>
> The index is worth the asymmetry because nothing else can keep the identifier unique: two
> registrations at the same moment both pass the changeset and both insert. A forgotten index is
> not a migration that fails, it is the day two accounts answer to one name.
Forget the column and the migration says so before it builds anything:
```
** (ArgumentError) MyApp.Accounts.User declares users.username, and the table has no such
column. The column is yours to add — in the migration that creates the table, or in one of its
own — and this library creates the unique index on it.
```
Before it builds anything, the migration checks `users_key_type` against your accounts table: the
column its foreign keys point at has to exist, have the type you configured, and carry a unique
index. That last one is what Postgres requires of any referenced column, so a composite primary
key with a unique index beside it is fine. A disagreement is refused, not half-applied.
Then `mix ecto.migrate`.
### Saying how the identifier index is built
Ithibati builds that index while the migration runs, but you steer it from the `use` line in your
schema rather than from the migration. Two options do it, and both may be module attributes, like
`format:`.
Ecto derives the index name from the table and the column. If that is not your convention, name
it yourself and Ithibati creates it under that name:
```elixir
use User, identifier: :username, constraint_name: :users_username_uniq
```
If you would rather build the index yourself — a partial index, an expression, `citext`, a
composite with a tenant column — say so, and Ithibati stops creating one. It then checks that a
unique index covering that column exists and refuses the migration if it does not:
```elixir
use User, identifier: :username, unique_index: false
```
### A later release that adds tables
The schema is versioned, and `version: 1` is what this release builds. When a later release of
Ithibati adds to it, the changelog says so and names the new number;
`Ithibati.Migration.current_version/0` answers with the one your installed copy knows. Until that
happens there is nothing to do — the migration you already wrote stays pinned at 1 and does not
run again.
Upgrading is a second migration of your own, saying where it starts:
```elixir
def up, do: Ithibati.Migration.up(from: 1, version: 2)
def down, do: Ithibati.Migration.down(from: 1, version: 2)
```
You never write the SQL by hand, and Ecto records what has been applied in your own
`schema_migrations`.
## 5. The handler
This is the module Ithibati asks whenever it reaches a decision it does not make: who may
register, what an account is made of, what a successful ceremony issues, and what happens when
somebody signs in with a recovery code instead. Four callbacks, and `Ithibati.Web.Handler`
documents each one.
It lives under `MyAppWeb` rather than in the context, because every callback takes a `conn` and
hands one back — it sets the session and answers the request. It is the same place
`phx.gen.auth` puts `MyAppWeb.UserAuth`. What it does *with* the account, once a credential has
verified, is context work and can move into one of yours; this walkthrough keeps it inline so
the whole flow is on one page.
```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: "/"})}
@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
```
[`recovered/3`](`c:Ithibati.Web.Handler.recovered/3`) is the one for somebody signing in with a recovery code instead of a passkey. Its two clauses are the whole of it: no fresh batch, sign them in as usual; a fresh
batch — which only happens when the code just spent was their last — show it once, on the page
step 6 builds. It is a separate callback rather than a flag on
[`authenticate/2`](`c:Ithibati.Web.Handler.authenticate/2`) because that
batch is the only copy there will ever be, and a signature with nowhere to put it is a signature
that loses it.
Three more things in there are worth pausing on.
[`registration_subject/2`](`c:Ithibati.Web.Handler.registration_subject/2`) may answer with the
identifier or with the account itself — here it is
the identifier, because the account does not exist yet. Hand back an account when there is one
(enrolling a second device), and
[`registration_options/3`](`Ithibati.Identity.Passkeys.registration_options/3`) can then list its
existing credentials
in `excludeCredentials`, so a browser does not offer an authenticator that is already enrolled.
It is asked **before** a challenge is minted, which is why an identifier your schema could never
store is refused there. Approving it would mean a passkey dialog, a
credential the authenticator then keeps, and a refusal only after all of that. It asks the
changeset rather than repeating the pattern, and answers with the normalised value, so the name
on the dialog is the name that gets stored.
[`register/4`](`c:Ithibati.Web.Handler.register/4`) composes.
[`Grant.with_key_and_codes/3`](`Ithibati.Identity.Grant.with_key_and_codes/3`) appends the passkey
and the recovery codes
to the transaction that creates the account, so an account never exists without a way back in.
Your own steps — a membership row, a default project — go into the same `Multi` and commit or
roll back with it.
It reads the account from the step that made it, expecting one called `:account`; pass
`account: :person` if yours is named something else. It adds two steps of its own, `:passkey` and
`:recovery_codes`, so do not use those names — the second is the one you destructure to show the
codes.
Its error branch asks `Ithibati.Schema.User.identifier_taken?/1`, because a name somebody else
already has and a name that is not allowed both arrive as `{:error, changeset}` and need
different words on screen. Written by hand, that check is a scan for `constraint: :unique`, which
also matches your own unique columns — a slug, a tenant key — and reports a collision on one of
those as the address being taken. Ithibati named the identifier field, so it knows which error
belongs to it. Ask after the insert: a constraint error only exists once the database has refused
a write.
Both `register/4` and `authenticate/2` answer with a redirect. Signing in renews the session and
clears the CSRF token with it, so the flow has to end in a full page load, and the hook follows
that redirect.
## 6. Showing the recovery codes
The handler put the codes in the session and redirected to `/recovery-codes`. That page is the
only time anybody will see them: the rows hold sha256 digests, and nothing recovers the plaintext
afterwards.
> #### Why a controller and not a LiveView? {: .info}
>
> The session key has to be gone once the page has been read, and a LiveView has no connection to
> delete it from. A refresh would show the codes again, which is the opposite of showing them
> once.
```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: "/")
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
```
```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>The database holds only their digests. Put these somewhere safe now.</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 navigate={~p"/"} class="link">Done</.link></p>
</Layouts.app>
```
`render/3` needs a view module, which `phx.new` did not generate for this controller. Put it in
`lib/my_app_web/controllers/session_html.ex`:
```elixir
defmodule MyAppWeb.SessionHTML do
use MyAppWeb, :html
embed_templates "session_html/*"
end
```
The template belongs at `lib/my_app_web/controllers/session_html/recovery_codes.html.heex`.
Leaving the module out compiles cleanly and passes `mix ithibati.doctor` — it only fails at the
first successful registration, when somebody has already created a passkey and their codes have
already been issued.
Twelve codes by default, each good for one sign-in. Spending the last one issues a fresh batch in
the same transaction, because an account with no passkey and no codes left is locked out for
good. [Recovery codes](recovery.md) covers redeeming one, `count:`, and how to turn the refill
off.
## 7. The routes
Four pieces go into the router `phx.new` generated: the import, the gate in `:browser`, a second
pipeline, and the scopes. Everything the generator put there — `get "/", PageController, :home`,
the `dev_routes` block with LiveDashboard and the mailbox preview — stays. Shown whole so the
order is visible:
```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
```
> #### Why a second pipeline instead of `:browser`? {: .info}
>
> These endpoints answer JSON, and `:browser`'s `accepts ["html"]` refuses the hook's
> request with a 406 before the controller is ever reached. The other two plugs are load-bearing
> as well: the challenge waits in the session between the two round-trips, and the hook answers
> `protect_from_forgery` with an `x-csrf-token` header, which a JSON body is not exempt from.
> [The routes](ceremonies.md#the-routes) has the detail.
`Ithibati.Web.Gate` answers who is signed in, in a pipeline and in a `live_session`, from one
module. `:current_account` assigns `@current_account` or `nil` and always continues.
`:require_account` refuses when there is nobody, and needs `:to` in a `live_session`, because a
LiveView that halts with nowhere to send a person is a dead end.
Signing out is a plain `delete`, for the same reason the recovery codes are a controller: a
LiveView cannot clear a session cookie.
## 8. The browser
The ceremonies need client code, because the browser's API wants buffers where Ithibati sends
unpadded base64url, and it hands back a credential that has to be serialised the way the
verifications expect. Ithibati ships it, and the specifier is bare because a Phoenix 1.8
application already has `deps` on esbuild's `NODE_PATH`, where a Hex dependency lives.
That last part is why a **path** dependency needs one more argument: Mix creates no
`deps/ithibati` for one, so there is nothing on `NODE_PATH` to resolve the name against and
esbuild stops with `Could not resolve "ithibati"`. Point it at the file instead, as another
`--alias` in the esbuild arguments `phx.new` generated in `config/config.exs`:
```elixir
config :esbuild,
version: "0.28.2",
my_app: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js
--external:/fonts/* --external:/images/* --alias:@=.
--alias:ithibati=../../ithibati/priv/static/ithibati.js),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
```
The alias path is resolved from `cd:`, which is your `assets/` directory — so it is your path to
the library with `assets/` climbed out of first. Both example applications carry exactly this
line, and say why in a comment beside it. Taking Ithibati from Hex needs none of it.
In `assets/js/app.js`:
```javascript
import {hooks as ithibatiHooks} from "ithibati"
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ...ithibatiHooks},
})
```
## 9. The page people register and sign in on
One page, two ceremonies. Registering is a form, because it needs a name. Signing in is a
button.
> #### Where is the login form? {: .info}
>
> There is not one, and this is the part that surprises people coming from passwords.
>
> A sign-in challenge names no credential — `allowCredentials` is sent empty — so the browser
> offers whichever passkeys it holds for your site and the person picks one. There is nothing to
> type, so there is nothing to submit. A button is the whole interface, and it works on a device
> where nobody has ever typed their username.
>
> Registering is the other way round: the name does not exist yet, so you have to ask for it,
> and `registration_subject/2` is where your application approves it.
The LiveView says *when* a ceremony starts; the hook does the round-trips. A ceremony ends in a
session cookie and a LiveView cannot set one, so the hook posts to the endpoints over `fetch` and
follows the redirect your handler answered with.
`phx.new` generates no `live/` directory, so `mkdir lib/my_app_web/live` first.
```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}, socket),
do: {:noreply, assign(socket, error: message(error))}
def handle_event("ithibati:done", _payload, socket), do: {:noreply, socket}
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(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
```
> #### Why does the registration form look like that? {: .info}
>
> It lives in a LiveView of yours, and here in the same one as the sign-in button, because one
> page is shorter to walk through. Two separate routes work exactly as well — `/register` and
> `/sign-in`, each with its own copy of the hook element below, and nothing else shared.
>
> What is unusual is that there is no `to_form/2` and no changeset, because `phx-submit` does
> not send this form anywhere. It hands the value to the hook, which posts it to
> `/auth/registration/challenge` and starts the ceremony; the form is never submitted in the
> Phoenix sense. Validation still happens on the server, one step later:
> `registration_subject/2` is asked before a challenge is minted, and its refusal is what the
> person sees.
>
> `phx-change="validate"` is there so you can check the field while it is typed, before anybody
> is shown a passkey dialog. This walkthrough only keeps the value; a changeset behind it works
> the same way.
The empty `<div id="passkey">` is the hook. It renders nothing, drives all three exchanges, and
reads
the endpoint paths off its own attributes, because you chose the scope they are mounted under.
Set the pair for each ceremony that page starts; a missing one is reported as
`missing_data_registration_url` rather than as a ceremony that failed.
And the page behind the gate, an ordinary LiveView that can now count on `@current_account`:
```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
```
Twelve checks in one run, so you find out now rather than at the first sign-in. Then `mix
phx.server`, open `http://localhost:4000` and register: the browser asks for a passkey, you
create one, and you land on the recovery codes. Write one down. Sign out, sign back in with the
passkey, sign out again and use that code instead — both ways in work, and the loop is closed.
[`mix ithibati.doctor`](doctor.md) has the rest of what it asks.
## Configuration
The three keys in step 2 are the ones every application sets. Everything else has a default, and
you set it only when the default does not fit:
```elixir
config :ithibati,
user_schema: MyApp.Accounts.User, # required — the module that uses Ithibati.Schema.User
repo: MyApp.Repo, # required — the repo this library reads and writes through
session_validity: {30, :day}, # default {60, :day} — how long a sign-in lasts
invitation_schema: MyApp.Accounts.Invitation, # optional — only if you use invitations
users_key_type: :id, # default :binary_id — compiled into the schemas
table_prefix: "auth" # default "ithibati" — compiled into the schemas
```
`session_validity` is how long a sign-in lasts. The units are `:second`, `:minute`, `:hour`,
`:day` and `:week`; `:month` and `:year` are missing because neither has a fixed length. A value
this library cannot read is refused rather than guessed at.
`users_key_type` and `table_prefix` are read at compile time, so changing either recompiles
Ithibati. Elixir then refuses to boot against a value it was not built for, rather than looking
for a table nobody meant.
## Where to go next
- [Registering and signing in](ceremonies.md) — the handler and the hook in full, the relying
party, and the ceremony without Phoenix.
- [Recovery codes](recovery.md) — redeeming one, and what happens when the last is spent.
- [Passkeys](passkeys.md) — enrolling a second device, and the one you cannot delete.
- [Invitations, and the first account](invitations.md) — the same application again, with
registration closed to strangers. It picks up where this page leaves off.
- [Ithibati](overview.md) — the map of every page, and the design decisions behind them.