Features / supported
Passkey authentication
Purpose
Passwordless sign-in with WebAuthn/FIDO2 passkeys as the primary factor, TOTP as an optional step-up factor, and single-use recovery codes as the recovery path. Sessions are revocable server-side records carrying an authentication strength.
User outcomes
- A user registers with email + passkey and signs in email-first: the server issues a challenge listing exactly that user’s credentials.
- Users with TOTP enabled must enter a valid code after the passkey; the
session is upgraded to
passkey_plus_totpstrength. - A user who lost their authenticator signs in with a recovery code
(consumed exactly once,
recovery-strength session). - The security page manages passkeys, TOTP, recovery codes, and sessions (“sign out other devices”).
Actors and permissions
Global users only — identity is organization/team-agnostic (SPEC §13.4).
Authorization is separate and membership-based (BillingCore.Orgs).
Domain terminology
- Ceremony — a WebAuthn registration (attestation) or authentication (assertion) exchange against a short-lived challenge.
- Session strength —
passkey,passkey_plus_totp, orrecovery. - Recovery code batch — 10 codes generated together; a new batch invalidates all unconsumed codes of previous batches.
Workflows
- Passkey registration (BC-US-145) — email →
registration_challenge(origin/RP ID fromconfig :billing_core, :webauthn, 120 s validity, attestationnone, user verificationpreferred) → browser attestation →verify_registrationpersists the credential (COSE key, sign count, backup flags, transports, name). Credential IDs are globally unique. - Sign-in (email-first) —
authentication_challengeallows exactly the user’s non-revoked credentials →verify_authenticationchecks the assertion via Wax and the signature counter, then updatessign_countandlast_used_at.POST /sessionhands the LiveView-issued token to the HTTP session and attaches IP/user-agent metadata. - TOTP enrollment (BC-US-146) —
enroll_totpstores the secret envelope-encrypted (AES-256-GCM undercredential_cipher_key; the plaintext exists only in the enrollment response for QR/manual entry) →confirm_totpwith a fresh code activates the factor and generates the mandatory recovery-code batch, shown exactly once. - TOTP step-up —
verify_totpaccepts ±1 period (30 s) of drift and atomically advanceslast_timestep, so a code is never accepted twice ({:error, :code_already_used}). - TOTP removal — requires a valid current code; in one transaction the
factor is revoked, unconsumed recovery codes are invalidated, and all
sessions of strength
passkey_plus_totp/recoveryare revoked. - Recovery —
consume_recovery_codesetsconsumed_atatomically (single use);generate_recovery_codesrequires active TOTP and invalidates the previous batch.
State transitions
No formal machine. Credentials/factors: active → revoked (revoked_at);
sessions: active → revoked or expired; recovery codes: unconsumed →
consumed; TOTP factors: pending (created) → activated (confirmed).
Business rules / invariants
- Challenges are short-lived (120 s, re-checked server-side even with a stubbed verifier) and single-use — the LiveView takes the challenge assign on the first verification attempt.
- A non-increasing WebAuthn signature counter is rejected as a possible
authenticator clone (
{:error, :sign_count_regression}); both counters at zero means the authenticator implements no counter and is accepted. - The final active passkey cannot be revoked while no active TOTP factor
exists (
{:error, :last_credential}). - Recovery codes exist only alongside TOTP; only SHA-256 hashes are stored.
- Session tokens are 32 random bytes; only the SHA-256 hash is persisted. Default validity is 14 days.
- Federated OIDC identities can be linked (
issuer/subjectunique).
GraphQL contract
None. Authentication is LiveView + POST/DELETE /session; GraphQL
authenticates via Authorization: Bearer <session token> (SPEC §14.2).
CLI surface
Not yet implemented (BC-US-157 planned).
MCP surface
Not yet implemented (BC-US-158 planned).
UI behavior
Implemented LiveViews: /register and /sign-in (email-first passkey
ceremonies with TOTP step-up and recovery-code fallback), /security
(passkeys, TOTP enrollment/removal, recovery-code regeneration, session
list with single/other revocation; the current session is not revocable
from the list). BillingCoreWeb.UserAuth.recently_authenticated?/2 gates
step-up-sensitive routes.
Accounting / ERP effects
None.
Asynchronous / failure / recovery behavior
All operations are synchronous transactions. Credential and session revocations are audited inside the same transaction. A registration that never finished passkey setup can be resumed from the register screen.
Observability
Audit events: identity.session.revoked,
identity.webauthn_credential.revoked, identity.totp_factor.revoked,
identity.recovery_code.consumed.
Tests
test/workflows/authentication_test.exs — full ceremony coverage:
registration, single-use challenges, sign-in, clone detection, TOTP
step-up, recovery codes, security page, session teardown, step-up helpers.
Security / privacy
Secrets at rest: TOTP seeds AES-256-GCM envelope-encrypted (production key
via CREDENTIAL_CIPHER_KEY); recovery codes and session tokens hashed.
Cryptographic WebAuthn verification is isolated behind a Verifier
behaviour (default WaxVerifier: full origin, RP ID, challenge, signature,
user-presence validation). Errors on sign-in are generic — unknown email
and wrong code fail identically.
Limitations
- OIDC federated sign-in has persistence (
link_federated_identity) but no login flow/UI yet. - No WebAuthn attestation policy beyond
none; no passkey usernameless (discoverable-credential-first) flow — sign-in is email-first.