Ithibati.Identity.Passkeys (Ithibati v0.6.2)

Copy Markdown View Source

Registers and verifies passkeys and manages an account's stored credentials.

Registration uses registration_challenge/3, registration_options/3 and verify_registration/2. Authentication uses authentication_challenge/3, authentication_options/1 and verify_authentication/2.

Registration verification returns attributes for a new credential; authentication verification returns the account that owns it. The caller decides what to store or issue afterwards. Neither verification function creates a session.

Use add_key/2, list_keys/1, rename_key/3 and delete_key/3 for an existing account's passkeys. Create an account with its first passkey and recovery codes through Ithibati.Identity.Grant.with_key_and_codes/3.

Registering and signing in shows both the Phoenix integration and direct calls.

Summary

Functions

Stores a verified passkey on an existing account.

Returns {:ok, challenge} for authentication, or {:error, :no_credentials} if no passkey exists.

Returns PublicKeyCredentialRequestOptions with binary fields encoded as unpadded base64url.

Deletes a passkey belonging to the account and returns {:ok, key}.

Adds label to verified credential attributes.

Returns the account's passkeys as a list of Ithibati.UserKey structs.

Returns PublicKeyCredentialCreationOptions with binary fields encoded as unpadded base64url.

Renames a passkey belonging to the account and returns {:ok, key}.

Verifies an assertion and returns {:ok, account} or {:error, reason}.

Verifies a registration credential and returns attributes ready to store.

Functions

add_key(account, key_attrs)

Stores a verified passkey on an existing account.

account must be a struct of the configured account schema. key_attrs must contain the verified :key_id and :public_key, with an optional :label. Use verify_registration/2 and optionally key_attrs/2 to obtain these attributes.

Returns {:ok, key} or {:error, :already_enrolled} when the credential ID already exists, including on another account. Other invalid changesets raise Ecto.InvalidChangesetError, including a foreign-key failure if the account was deleted before the insert.

Labels are truncated to Ithibati.UserKey.label_max/0 graphemes and trimmed. A missing or blank label is stored as "Passkey".

For a new account, compose Ithibati.Identity.Grant.with_key_and_codes/3 into its creation transaction instead.

authentication_challenge(rp_id, origin, opts \\ [])

Returns {:ok, challenge} for authentication, or {:error, :no_credentials} if no passkey exists.

The existence check covers the entire configured credential table. Credentials are not partitioned by relying-party ID or tenant in that table. This check is independent of Ithibati.Identity.Instance.needs_setup?/0, which reports the bootstrap claim state.

rp_id, origin and the options have the same meanings as in registration_challenge/3. Ithibati explicitly disables silent authentication, so a wax_ application setting cannot allow assertions without user presence.

Retain the challenge for verify_authentication/2 and enforce single use on every attempt.

authentication_options(challenge)

Returns PublicKeyCredentialRequestOptions with binary fields encoded as unpadded base64url.

allowCredentials is empty so the browser can offer discoverable passkeys for the relying party. User verification and timeout come from the challenge; timeout is converted to milliseconds for the browser.

delete_key(account, id, opts \\ [])

Deletes a passkey belonging to the account and returns {:ok, key}.

Returns {:error, :not_found} for a missing key, a key owned by another account or an invalid UUID. By default, deleting the account's last passkey returns {:error, :last_key}. This protection also applies to concurrent deletions through this function.

The :last option accepts :refuse (default) or :allow. Other values raise ArgumentError. With last: :allow, an account can be left without passkeys and must use recovery codes to sign in. If no credentials remain anywhere in the deployment, authentication_challenge/3 returns {:error, :no_credentials}.

key_attrs(map, label)

Adds label to verified credential attributes.

Accepts the map returned by verify_registration/2 and returns a map containing :key_id, :public_key and :label. Label trimming, truncation and the "Passkey" fallback are applied when the credential is stored, not by this function.

list_keys(account)

Returns the account's passkeys as a list of Ithibati.UserKey structs.

Rows are ordered by inserted_at, then id, both ascending. The ID provides a stable order when timestamps are equal. Each row includes its label and last_used_at value.

registration_challenge(rp_id, origin, opts \\ [])

Returns a Wax.Challenge for passkey registration.

rp_id is the relying-party ID. origin is an expected origin or a non-empty list of accepted origins. Supply both explicitly from trusted application settings; do not reflect client input.

Options

  • :user_verification"required", "preferred" (default) or "discouraged". These values are strings, not atoms.
  • :seconds — challenge lifetime as a positive integer; defaults to 60.

Invalid option values raise ArgumentError. Registration does not require an existing account or credential, so this function returns the challenge directly rather than an :ok tuple.

Ithibati requests no attestation and does not verify authenticator trust roots. It supplies the WebAuthn options it depends on explicitly so wax_ application defaults do not alter them.

Retain the challenge for verify_registration/2. The caller must enforce single use, including when verification fails.

registration_options(challenge, account_or_identifier, opts)

Returns PublicKeyCredentialCreationOptions with binary fields encoded as unpadded base64url.

Pass the configured account struct when adding a passkey, or an approved identifier when creating an account. An account populates excludeCredentials from its stored passkeys; an identifier produces an empty exclusion list.

The required :rp_name option supplies the display name of the relying party. User verification, attestation and timeout are taken from the challenge. The browser timeout is in milliseconds.

Ithibati requires discoverable credentials because sign-in does not ask for an identifier. It sets both residentKey and requireResidentKey and requests the credProps extension. The offered algorithms are ES256 and RS256.

rename_key(account, id, name)

Renames a passkey belonging to the account and returns {:ok, key}.

The label is truncated to Ithibati.UserKey.label_max/0 graphemes, then trimmed. Blank and non-string values become "Passkey". The update also changes updated_at.

Returns {:error, :not_found} for a missing key, a key owned by another account or an invalid UUID. The update is scoped to the supplied account and cannot transfer ownership.

verify_authentication(credential, challenge)

Verifies an assertion and returns {:ok, account} or {:error, reason}.

credential is the decoded JSON object produced by the browser's credential.toJSON(). Pass the retained authentication challenge as the second argument. The returned account is an instance of the configured account schema; no session or token is issued.

On success, the credential's last_used_at is updated without changing updated_at. A credential removed before that update is reported as :unknown_credential.

Library validation errors use atom reasons. wax_ errors may be exception structs; see verify_registration/2 for error handling. Ithibati does not store or compare authenticator signature counters.

Consume the challenge once

The caller owns challenge storage and must prevent reuse, including concurrent attempts. A failed verification must also consume the challenge. Leaving it available permits another attempt with the same assertion while it remains valid.

verify_registration(credential, challenge)

Verifies a registration credential and returns attributes ready to store.

credential is the decoded JSON object produced by the browser's credential.toJSON(); its keys are strings and its binary fields are base64url-encoded. Pass the retained registration challenge as the second argument.

Returns {:ok, %{key_id: binary, public_key: binary}} or {:error, reason}. Pass successful attributes to add_key/2 or Ithibati.Identity.Grant.with_key_and_codes/3; use key_attrs/2 to add a label first. Verification does not write a credential.

Library validation failures use atom reasons, including :malformed_credential, :not_discoverable, :no_attested_credential and :credential_id_too_long. Errors from wax_ can be exception structs. Handle known atoms and provide a fallback for other reasons.

An explicit false or "false" in clientExtensionResults.credProps.rk is rejected. An absent extension result is accepted for clients that do not report discoverability.

The caller must consume the stored challenge on every verification attempt, successful or not.