Ithibati.Web.Handler behaviour (Ithibati v0.1.0)

Copy Markdown View Source

The callbacks an application implements, so the ceremony can ask it what to decide.

Ithibati owns the WebAuthn ceremony: the challenge, its single use, the encoding, the verification. It owns none of what surrounds it. The application decides whether someone may register at all, what an account is made of, and what is issued once an assertion verifies. A library that answered those questions would be deciding that everyone wants a session cookie.

You name an implementation in the router:

scope "/auth" do
  ithibati_routes handler: MyAppWeb.Auth
end

Summary

Types

The account, or the identifier an invitation was addressed to before an account exists.

Callbacks

Ithibati has verified the assertion, and this is the account.

Somebody signed in with a recovery code instead of a passkey.

Ithibati has verified the credential, and the application decides what to make of it.

Who is starting this registration.

Which relying party this request belongs to, as {rp_id, origin}.

Types

subject()

@type subject() :: struct() | String.t()

The account, or the identifier an invitation was addressed to before an account exists.

Callbacks

authenticate(t, account)

@callback authenticate(Plug.Conn.t(), account :: struct()) ::
  {:ok, Plug.Conn.t()} | {:error, term()}

Ithibati has verified the assertion, and this is the account.

Ithibati has issued nothing. A session token behind a cookie, a long-lived token behind a bearer header, and a redirect to a second factor are all answers it deliberately does not pick.

Answer in JSON. %{redirect: path} is the one key the shipped hook acts on: it follows the path with a full page load, which a sign-in needs anyway, because renewing the session takes the CSRF token with it. Anything else in the body reaches the LiveView as ithibati:done.

recovered(t, account, fresh)

@callback recovered(Plug.Conn.t(), account :: struct(), fresh :: [String.t()] | nil) ::
  {:ok, Plug.Conn.t()} | {:error, term()}

Somebody signed in with a recovery code instead of a passkey.

This is the moment authenticate/2 answers, reached the other way: an account whose holder has proved who they are. It is a separate callback because it carries a third thing. Ithibati.Identity.RecoveryCodes.redeem/2 issues a fresh batch when the code just spent was the account's last unused one, and that batch is the only copy there will ever be. fresh is that list, or nil when there were codes left.

Ithibati requires this callback rather than making it optional, and that is deliberate. An application that fell back to authenticate/2 here would drop the batch in exactly the case it exists for: somebody has used their last code, nobody tells them, and the next lost passkey locks them out for good. You may delegate in one line, as long as you have made that choice deliberately.

def recovered(conn, account, nil), do: authenticate(conn, account)
def recovered(conn, account, fresh), do: # …show `fresh`, once

Ithibati does not ask you to refuse anyone here. It answers both an unknown code and a spent one with invalid_code before it calls this, and deliberately does not tell them apart.

register(t, key_attrs, subject, params)

@callback register(Plug.Conn.t(), key_attrs :: map(), subject(), params :: map()) ::
  {:ok, Plug.Conn.t()} | {:error, term()}

Ithibati has verified the credential, and the application decides what to make of it.

key_attrs is what Ithibati.Identity.Passkeys.verify_registration/2 returned. Pass it to Ithibati.Identity.Grant.with_key_and_codes/3 when you are creating an account, or to Ithibati.Identity.Passkeys.add_key/2 when the account already exists. Ithibati cannot tell whether this is the first account of an instance or the acceptance of an invitation, and it does not try: that judgement needs what the application knows about its own instance. A second passkey on an existing account is the one it can tell apart, because you returned the account itself from registration_subject/2.

Answer as you would in authenticate/2. %{redirect: path} means the same thing here.

subject is what registration_subject/2 approved. Ithibati carries it here from the challenge rather than re-reading it from params, because the browser sends the whole body again. An application that trusted params would validate an invitation for one identifier and enrol a credential against another.

registration_subject(t, params)

@callback registration_subject(Plug.Conn.t(), params :: map()) ::
  {:ok, subject()} | {:error, term()}

Who is starting this registration.

Ithibati calls this before it mints a challenge, so this is where an application refuses someone: a closed instance that already has its first account, or an invitation that was spent. It does not ask here what the dialog is called or how the ceremony is configured. Those are the same for every request a mount serves, so you give them to ithibati_routes/1 once.

Return the identifier as it will be stored, not as it was typed. The account row goes through Ithibati.Schema.Identifier.normalize/1, so approving the raw value puts Ada on the passkey dialog and ada in the table, and nothing reports the difference. A changeset answers both questions at once: whether the value is acceptable, and what it normalises to.

Return the account itself when it already has one. Adding a second device is the same ceremony with the account in hand. Ithibati.Identity.Passkeys.registration_options/3 fills excludeCredentials from an account and cannot fill it from an identifier. Approving the identifier there hands the browser an empty exclusion list, the browser re-offers a passkey that is already enrolled, and the person is refused only after completing the ceremony.

relying_party(t, default)

(optional)
@callback relying_party(Plug.Conn.t(), default :: {String.t(), String.t()}) ::
  {rp_id :: String.t(), origin :: String.t() | [String.t(), ...]}

Which relying party this request belongs to, as {rp_id, origin}.

This callback is optional. Ithibati hands you the default rather than expecting you to replace it. default is the endpoint's configured :url, which is what the browser saw rather than what this node accepted. Behind a proxy that terminates TLS the two disagree, and every ceremony fails on an origin mismatch. An implementation that dropped the default would have to re-derive exactly that, and people get that derivation wrong.

So the usual shape adds to the default instead of replacing it:

def relying_party(_conn, {rp_id, origin}), do: {rp_id, [origin | @extension_origins]}

The same passkey has to keep working in the browser. Implement this callback for a client whose origin is not that URL. Two of them: a native app's assertion arrives with the origin of an associated domain, an extension's arrives with the origin of the extension, and one relying-party id serves all of them. The application decides which origins it accepts, which is why Ithibati asks rather than reading configuration.

The origin may be a list, and for an extension it usually is. The same extension has a different stable origin in each browser, chrome-extension://<id> and moz-extension://<hash>, and an assertion carries the one it was made at.

Never from the request

Return values from a fixed set. If you read the origin request header and hand it back, the check compares the client's claim against itself, so it matches whatever arrives. A credential registered for the real site can then be asserted from any page its holder visits, and WebAuthn loses its whole anti-phishing property. Nothing fails, in production or in your own tests, because the origin always "matches".