Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-identity/src/flows.rs

//! **The flows**: sign-up, invite, redeem, login, session, step-up, logout,
//! deactivate. Every rule is here once; storage, roles, audit and mail are the
//! host's.

use nornir_auth_webauthn::ceremony::{self, CeremonyStore, Challenges, ClientKey, Full};
use nornir_identity_session::invites::{self, Ask, Invite, MAX_INVITES};
use nornir_identity_session::step_up::{self, Presented, Stored};

use crate::{
    Code, Config, Credential, Event, IdentityHost, Mail, MailKind, Refused, Session, Site, User,
    UserState,
};

/// The prefix of a session token (a version, so a later format is told apart).
pub const TOKEN_PREFIX: &str = "nis1.";

/// The prefix of an invite's `created_by` that a sign-up mints for itself: the
/// first-login link of the pending user named after it.
pub const SELF_INVITE: &str = "self:";

/// The prefix of an invite's `created_by` that adds a passkey to an EXISTING
/// person: `for:<person>:<who minted it>` (another device, a lost key, the
/// host's own link).
pub const PERSON_INVITE: &str = "for:";

/// The prefix of an invite id the host was fed by the SHA-256 of its secret.
pub const SHA256_INVITE: &str = "sha256:";

/// The most open first-login links a sign-up keeps (RA5-8): past it a sign-up
/// is refused ([`Code::Busy`]); an admin's invites and the host's links have
/// their own bound ([`MAX_INVITES`]) and are never evicted by a sign-up.
pub const MAX_OPEN_SIGN_UPS: usize = MAX_INVITES;

/// The longest display name kept.
pub const MAX_DISPLAY: usize = 64;

/// A self-service sign-up.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignUp {
    pub email: String,
    /// Empty: the address's local part.
    pub display: String,
}

/// What an admin's invite is for.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InviteAsk {
    /// The pinned name, or empty: the invitee chooses one.
    pub display: String,
    /// Where the link goes; `None`: the admin hands it over.
    pub email: Option<String>,
    pub groups: Vec<String>,
    /// `None`: the configured default.
    pub ttl_secs: Option<i64>,
    /// **An existing person** (their id): the link adds a passkey to THEM (another
    /// device, a lost key). No groups, no name: both are theirs already. An admin
    /// mints it for anybody, a person for themself, and whoever the host's
    /// [`IdentityHost::may_enrol`] names.
    pub person: Option<String>,
}

/// An open invite, as a list shows it: never its secret.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InviteView {
    pub id: String,
    /// The pinned name, or empty.
    pub display: String,
    pub groups: Vec<String>,
    /// The existing person it adds a passkey to; `None`: it makes a new person.
    pub person: Option<String>,
    /// Who minted it.
    pub created_by: String,
    /// Unix seconds.
    pub created: i64,
    /// Unix seconds, exclusive.
    pub expires: i64,
}

/// A minted invite. Its `Debug` redacts the secret.
#[derive(Clone, PartialEq, Eq)]
pub struct Invited {
    /// Shown once.
    pub secret: String,
    pub invite_id: String,
    pub expires_at: i64,
    pub mailed: bool,
    pub mail_refusal: Option<String>,
}

impl std::fmt::Debug for Invited {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Invited")
            .field("secret", &"<redacted>")
            .field("invite_id", &self.invite_id)
            .field("expires_at", &self.expires_at)
            .field("mailed", &self.mailed)
            .field("mail_refusal", &self.mail_refusal)
            .finish()
    }
}

/// What the relay (a console, a CLI) says about the browser it relays.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Relay {
    pub rp_id: String,
    pub origin: String,
    /// The browser's address, for the per-client ceremony bound.
    pub client: Option<ClientKey>,
}

/// An open ceremony, as the browser needs it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ceremony {
    pub id: String,
    /// base64url.
    pub challenge: String,
    pub rp_id: String,
    /// Creation only: the user handle and names.
    pub user_id: String,
    pub user_name: String,
    pub display: String,
    pub user_verification_required: bool,
    pub expires_at_ms: i64,
    /// Creation for an existing person: the passkeys they hold, which the
    /// authenticator must not replace (`excludeCredentials`).
    pub exclude: Vec<Vec<u8>>,
}

/// A creation ceremony's answer, as the relay passes it on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Registration {
    pub ceremony_id: String,
    pub credential_id: Vec<u8>,
    /// P-256, SEC1 uncompressed.
    pub public_key: Vec<u8>,
    pub client_data_json: Vec<u8>,
    pub attestation_object: Vec<u8>,
    pub rp_id: String,
    pub origin: String,
}

/// An assertion (`navigator.credentials.get`), as the relay passes it on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Assertion {
    /// The login ceremony's id (empty for a step-up: its challenge is the key).
    pub ceremony_id: String,
    pub credential_id: Vec<u8>,
    pub client_data_json: Vec<u8>,
    pub authenticator_data: Vec<u8>,
    pub signature: Vec<u8>,
    pub rp_id: String,
    pub origin: String,
}

/// Who a session is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionView {
    pub user_id: String,
    pub display: String,
    pub roles: Vec<String>,
    pub admin: bool,
    /// Unix seconds.
    pub expires_at: i64,
    pub stepped_up: bool,
}

/// A minted session. Its `Debug` redacts the token.
#[derive(Clone, PartialEq, Eq)]
pub struct LoggedIn {
    /// The opaque token the relay carries; the store keeps only its hash.
    pub token: String,
    pub session: SessionView,
}

impl std::fmt::Debug for LoggedIn {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LoggedIn")
            .field("token", &"<redacted>")
            .field("session", &self.session)
            .finish()
    }
}

/// A step-up challenge.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Challenge {
    /// base64url.
    pub challenge: String,
    pub expires_at_ms: i64,
}

#[derive(Clone)]
enum Purpose {
    Login,
    Redeem {
        secret: String,
        user_id: String,
        display: String,
    },
}

/// **The identity core of one product**: every flow, over the product's host.
pub struct Identity<H: IdentityHost> {
    host: H,
    config: Config,
    ceremonies: CeremonyStore<Purpose>,
    step_ups: Challenges,
}

impl<H: IdentityHost> std::fmt::Debug for Identity<H> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Identity")
            .field("site", &self.host.site())
            .field("ceremonies", &self.ceremonies)
            .field("step_ups", &self.step_ups)
            .finish()
    }
}

fn refuse<T>(code: Code, detail: impl Into<String>) -> Result<T, Refused> {
    Err(Refused::new(code, detail))
}

fn busy(full: Full) -> Refused {
    Refused::new(Code::Busy, full.to_string())
}

/// Lowercased and checked, or why not.
pub fn normalize_email(email: &str) -> Result<String, Refused> {
    let email = email.trim().to_lowercase();
    let ok = email.len() <= 254
        && !email.chars().any(|c| c.is_whitespace() || c.is_control())
        && matches!(email.split_once('@'), Some((local, domain))
            if !local.is_empty() && !domain.is_empty() && !domain.contains('@') && domain.contains('.')
               && !domain.starts_with('.') && !domain.ends_with('.'));
    if ok {
        Ok(email)
    } else {
        refuse(Code::Malformed, "that is not an e-mail address")
    }
}

fn display_name(text: &str) -> Result<String, Refused> {
    let name = text.trim();
    if name.is_empty() {
        return refuse(Code::Malformed, "a name is required");
    }
    if name.chars().count() > MAX_DISPLAY || name.chars().any(char::is_control) {
        return refuse(
            Code::Malformed,
            format!("a name is at most {MAX_DISPLAY} printable characters"),
        );
    }
    Ok(name.to_owned())
}

fn new_user_id() -> String {
    format!("u-{}", ceremony::hex(&ceremony::random_bytes(8)))
}

impl<H: IdentityHost> Identity<H> {
    /// The core over `host`, with `config`.
    pub fn new(host: H, config: Config) -> Self {
        let ceremonies = CeremonyStore::new(config.limits);
        let step_ups = Challenges::new(config.limits);
        Identity {
            host,
            config,
            ceremonies,
            step_ups,
        }
    }

    /// The host.
    pub fn host(&self) -> &H {
        &self.host
    }

    /// The settings.
    pub fn config(&self) -> &Config {
        &self.config
    }

    fn now_ms(&self) -> i64 {
        self.host.now_ms()
    }

    fn now(&self) -> i64 {
        self.now_ms().div_euclid(1000)
    }

    fn admin_groups(&self, groups: &[String]) -> bool {
        let probe = User {
            id: String::new(),
            display: String::new(),
            email: None,
            groups: groups.to_vec(),
            state: UserState::Active,
            created: 0,
        };
        self.host.roles(&probe).admin
    }

    fn view(&self, user: &User, session: &Session, now: i64) -> SessionView {
        let roles = self.host.roles(user);
        SessionView {
            user_id: user.id.clone(),
            display: user.display.clone(),
            roles: roles.names,
            admin: roles.admin,
            expires_at: session.expires,
            stepped_up: session
                .stepped_up_at
                .is_some_and(|at| step_up::stepped_up(true, at, now)),
        }
    }

    fn mint_session(&self, user: &User, user_verified: bool) -> Result<LoggedIn, Refused> {
        let now = self.now();
        // A floor voids every session minted AT its second (`Revocations::admits`), so one
        // minted in the second of this person's logout everywhere (or a log-everyone-out)
        // would be refused from its first use: refused here by name instead, and a login
        // a second later works (GHUNT-12).
        if !self.host.store().revocations()?.admits(&user.id, now) {
            return refuse(
                Code::Busy,
                "signed out everywhere this very second: sign in again in a moment",
            );
        }
        let token = format!(
            "{TOKEN_PREFIX}{}",
            ceremony::b64url(&ceremony::random_bytes(32))
        );
        let session = Session {
            id: invites::hash_secret(&token),
            user: user.id.clone(),
            auth_time: now,
            expires: now.saturating_add(self.config.session_ttl_secs),
            stepped_up_at: user_verified.then_some(now),
        };
        self.host.store().put_session(&session)?;
        self.host.audit(Event::LoggedIn {
            user: user.id.clone(),
            session: session.id.clone(),
            user_verified,
        });
        Ok(LoggedIn {
            session: self.view(user, &session, now),
            token,
        })
    }

    fn check_relay(&self, rp_id: &str, origin: &str) -> Result<(), Refused> {
        if rp_id.trim().is_empty() || origin.trim().is_empty() {
            return refuse(
                Code::Malformed,
                "the relying party and the origin are both required",
            );
        }
        ceremony::check_relying_party(&self.config.parties, rp_id, origin)
            .map_err(|why| Refused::new(Code::Ceremony, why))
    }

    fn open(
        &self,
        purpose: Purpose,
        relay: &Relay,
        user: (&str, &str, &str),
    ) -> Result<Ceremony, Refused> {
        self.check_relay(&relay.rp_id, &relay.origin)?;
        let started = self
            .ceremonies
            .start(purpose, relay.client.as_ref(), self.now_ms())
            .map_err(busy)?;
        Ok(Ceremony {
            challenge: started.challenge_b64(),
            id: started.id,
            rp_id: relay.rp_id.trim().to_owned(),
            user_id: user.0.to_owned(),
            user_name: user.1.to_owned(),
            display: user.2.to_owned(),
            user_verification_required: step_up::user_verification(
                self.config.user_verification,
                false,
            ),
            expires_at_ms: started.expires_at_ms,
            exclude: Vec::new(),
        })
    }

    /// Take a ceremony and check the client data the browser signed against it.
    fn take(
        &self,
        id: &str,
        kind: &str,
        cdj: &[u8],
        rp_id: &str,
        origin: &str,
    ) -> Result<Purpose, Refused> {
        let taken = self.ceremonies.take(id, self.now_ms());
        let data = ceremony::client_data(cdj).map_err(|why| Refused::new(Code::Ceremony, why))?;
        let Some(taken) = taken else {
            return refuse(
                Code::Ceremony,
                "this ceremony was not opened here, was already answered, or expired",
            );
        };
        self.check_relay(rp_id, origin)?;
        ceremony::check_ceremony(&data, kind, origin, rp_id)
            .map_err(|why| Refused::new(Code::Ceremony, why))?;
        if data.challenge != taken.challenge {
            return refuse(
                Code::Ceremony,
                "the browser answered another challenge than this ceremony's",
            );
        }
        Ok(taken.purpose)
    }

    // ─── Sign-up ────────────────────────────────────────────────────────────

    /// **Self-service sign-up**: a Root site mails a first-login link; a Private
    /// site refuses ([`Code::SignUpClosed`]). The answer is the same whether or
    /// not the address had an account (an active one gets no mail).
    pub fn sign_up(&self, ask: SignUp) -> Result<(), Refused> {
        if self.host.site() != Site::Root {
            self.host.audit(Event::SignUpRefused {
                code: Code::SignUpClosed,
            });
            return refuse(Code::SignUpClosed, "this site is invite only");
        }
        let email = normalize_email(&ask.email)?;
        let store = self.host.store();
        let existing = store.user_by_email(&email)?;
        let now = self.now();
        let user = match existing {
            Some(user) if user.state != UserState::Pending => return Ok(()),
            Some(user) => user,
            None => {
                let display = if ask.display.trim().is_empty() {
                    email.split('@').next().unwrap_or_default().to_owned()
                } else {
                    ask.display
                };
                User {
                    id: new_user_id(),
                    display: display_name(&display)?,
                    email: Some(email.clone()),
                    groups: Vec::new(),
                    state: UserState::Pending,
                    created: self.now(),
                }
            }
        };
        // RA5-8: sign-ups have their own bound and are refused past it; they never evict an
        // admin's invite or the host's link. One address holds one open link: its own older
        // one does not count, and is replaced below.
        let own = format!("{SELF_INVITE}{}", user.id);
        let waiting = store
            .invites()?
            .iter()
            .filter(|i| i.open(now) && is_sign_up(i) && i.created_by != own)
            .count();
        if waiting >= MAX_OPEN_SIGN_UPS {
            self.host.audit(Event::SignUpRefused { code: Code::Busy });
            return refuse(
                Code::Busy,
                "too many sign-ups are waiting for their first login: try again later",
            );
        }
        let mint = Ask {
            display: user.display.clone(),
            groups: Vec::new(),
            by: own.clone(),
            ttl_secs: self.config.invite_ttl_secs,
        };
        let (secret, invite) = invites::mint(mint, now, |_| false)
            .map_err(|e| Refused::new(Code::Malformed, e.to_string()))?;
        // RA5-30: stored first and mailed last, so a failed store never mails a link that
        // does not work. A refused mail withdraws the link again; a new person stays
        // pending with no link (nothing to log in with), and the address's next sign-up
        // takes them up.
        store.put_user(&user)?;
        store.update_invites(&mut |all| {
            all.retain(|i| !(i.created_by == own && i.open(now)));
            all.push(invite.clone());
            keep_bounded(all, now);
        })?;
        if let Err(why) = self.host.mailer().send(&Mail {
            to: email,
            kind: MailKind::FirstLogin,
            secret,
        }) {
            let _ = store.update_invites(&mut |all| all.retain(|i| i.id != invite.id));
            self.host.audit(Event::SignUpRefused {
                code: Code::MailRefused,
            });
            return refuse(Code::MailRefused, why);
        }
        self.host.audit(Event::SignedUp { user: user.id });
        Ok(())
    }

    // ─── Invites ────────────────────────────────────────────────────────────

    /// **An admin's single-use invite.** Never grants an admin. With an address
    /// the link is mailed; a refusing mailer (airgap) leaves the admin the secret
    /// to hand over, and says so.
    ///
    /// Every invite, a passkey link for an existing person included, needs a
    /// fresh step-up ([`Code::NeedsStepUp`], RA5-2), as deactivating and ending
    /// everyone's sessions do: a passkey added to an account outlives the
    /// session that minted it, so a stolen session must not mint one.
    pub fn invite(&self, token: &str, ask: InviteAsk) -> Result<Invited, Refused> {
        let caller = self.require_step_up(token)?;
        if let Some(person) = ask.person.clone() {
            return self.invite_person(caller, &person, ask);
        }
        if !caller.admin {
            return refuse(Code::NotAdmin, "only an admin invites");
        }
        let to = ask
            .email
            .as_deref()
            .filter(|e| !e.trim().is_empty())
            .map(normalize_email)
            .transpose()?;
        let display = if ask.display.trim().is_empty() {
            String::new()
        } else {
            display_name(&ask.display)?
        };
        let mint = Ask {
            display,
            groups: ask.groups,
            by: caller.user_id.clone(),
            ttl_secs: ask
                .ttl_secs
                .filter(|t| *t > 0)
                .unwrap_or(self.config.invite_ttl_secs),
        };
        let now = self.now();
        let (secret, invite) = invites::mint(mint, now, |groups| self.admin_groups(groups))
            .map_err(|e| Refused::new(Code::InviteWouldBeAdmin, e.to_string()))?;
        self.store_invite(&invite, now)?;
        let mail_refusal = match to {
            Some(to) => self
                .host
                .mailer()
                .send(&Mail {
                    to,
                    kind: MailKind::Invite,
                    secret: secret.clone(),
                })
                .err(),
            None => Some("no address was given: hand the link over yourself".to_owned()),
        };
        let mailed = mail_refusal.is_none();
        self.host.audit(Event::InviteMinted {
            invite: invite.id.clone(),
            by: caller.user_id,
            mailed,
        });
        Ok(Invited {
            secret,
            invite_id: invite.id,
            expires_at: invite.expires,
            mailed,
            mail_refusal,
        })
    }

    fn store_invite(&self, invite: &Invite, now: i64) -> Result<(), Refused> {
        self.host.store().update_invites(&mut |all| {
            all.push(invite.clone());
            keep_bounded(all, now);
        })?;
        Ok(())
    }

    /// **The site's first admin invite**, the host's own (its installer): it
    /// carries `admin_groups`, and is refused once an active admin exists.
    pub fn first_admin_invite(
        &self,
        admin_groups: Vec<String>,
        by: &str,
    ) -> Result<Invited, Refused> {
        let store = self.host.store();
        if store
            .users()?
            .iter()
            .any(|u| u.state == UserState::Active && self.host.roles(u).admin)
        {
            return refuse(
                Code::Bootstrapped,
                "this site has its admin: an admin invites the next one",
            );
        }
        let now = self.now();
        let ask = Ask {
            display: String::new(),
            groups: admin_groups,
            by: by.to_owned(),
            ttl_secs: self.config.invite_ttl_secs,
        };
        let (secret, invite) = invites::mint(ask, now, |_| false)
            .map_err(|e| Refused::new(Code::Malformed, e.to_string()))?;
        self.store_invite(&invite, now)?;
        self.host.audit(Event::InviteMinted {
            invite: invite.id.clone(),
            by: by.to_owned(),
            mailed: false,
        });
        Ok(Invited {
            secret,
            invite_id: invite.id,
            expires_at: invite.expires,
            mailed: false,
            mail_refusal: None,
        })
    }

    /// **Open the creation ceremony that redeems an invite or a first-login
    /// link.** The invite is only read here; it is spent at the finish.
    pub fn redeem_start(
        &self,
        secret: &str,
        display: &str,
        relay: &Relay,
    ) -> Result<Ceremony, Refused> {
        let now = self.now();
        let all = self.host.store().invites()?;
        let invite = find_open(&all, secret, now)
            .map_err(|e| Refused::new(Code::InviteNotOpen, e.to_string()))?;
        let sign_up = invite.created_by.starts_with(SELF_INVITE);
        let (user_id, name, display, exclude) = match invite_parties(&invite.created_by).0 {
            Some(uid) => {
                // A sign-up's link opens only for its pending person; a passkey
                // link for an existing person, for anybody not deactivated.
                let user = self.host.store().user(uid)?.filter(|u| match u.state {
                    UserState::Pending => true,
                    UserState::Active => !sign_up,
                    UserState::Deactivated => false,
                });
                let Some(user) = user else {
                    return refuse(
                        Code::InviteNotOpen,
                        "this link is not valid (it may have expired or been used)",
                    );
                };
                let held = self
                    .host
                    .store()
                    .credentials_of(&user.id)?
                    .into_iter()
                    .map(|c| c.id)
                    .collect();
                (
                    user.id,
                    user.email.unwrap_or_else(|| user.display.clone()),
                    user.display,
                    held,
                )
            }
            None => {
                let display = if invite.display.is_empty() {
                    display_name(display)?
                } else {
                    invite.display.clone()
                };
                (new_user_id(), display.clone(), display, Vec::new())
            }
        };
        let purpose = Purpose::Redeem {
            secret: secret.trim().to_owned(),
            user_id: user_id.clone(),
            display: display.clone(),
        };
        let mut opened = self.open(purpose, relay, (&user_id, &name, &display))?;
        opened.exclude = exclude;
        Ok(opened)
    }

    /// **Finish a redemption**: the attestation is bound to the request, the
    /// invite spent (once), the person made or activated, the passkey stored and
    /// a session minted.
    pub fn redeem_finish(&self, reg: Registration) -> Result<LoggedIn, Refused> {
        let purpose = self.take(
            &reg.ceremony_id,
            "webauthn.create",
            &reg.client_data_json,
            &reg.rp_id,
            &reg.origin,
        )?;
        let Purpose::Redeem {
            secret,
            user_id,
            display,
        } = purpose
        else {
            return refuse(
                Code::Ceremony,
                "this ceremony was opened for a login, not a registration",
            );
        };
        let bad = |why: String| Refused::new(Code::Ceremony, why);
        ceremony::check_credential_id(&reg.credential_id).map_err(bad)?;
        ceremony::check_public_key(&reg.public_key).map_err(bad)?;
        let attested = ceremony::verify_attestation(
            &reg.attestation_object,
            &reg.client_data_json,
            reg.rp_id.trim(),
            &reg.credential_id,
            &reg.public_key,
        )
        .map_err(bad)?;
        let store = self.host.store();
        if store.credential(&reg.credential_id)?.is_some() {
            return refuse(Code::Ceremony, "this passkey is already registered here");
        }
        let now = self.now();
        let mut spent = None;
        store.update_invites(&mut |all| spent = Some(redeem(all, &secret, &user_id, now)))?;
        let invite = match spent {
            Some(Ok(invite)) => invite,
            _ => return refuse(Code::InviteNotOpen, invites::Refused::NotOpen.to_string()),
        };
        let user = if invite_parties(&invite.created_by).0.is_some() {
            let Some(mut user) = store
                .user(&user_id)?
                .filter(|u| u.state != UserState::Deactivated)
            else {
                return refuse(Code::InviteNotOpen, invites::Refused::NotOpen.to_string());
            };
            user.state = UserState::Active;
            user
        } else {
            User {
                id: user_id.clone(),
                display,
                email: None,
                groups: invite.groups.clone(),
                state: UserState::Active,
                created: now,
            }
        };
        // RA5-30: the invite was spent above (once, under the store's lock); a write that
        // fails after it opens the invite again, so the link is never left spent with
        // nothing to log in with.
        let written = store.put_user(&user).and_then(|()| {
            store.put_credential(&Credential {
                id: reg.credential_id.clone(),
                user: user.id.clone(),
                public_key: reg.public_key,
                sign_count: attested.sign_count,
                created: now,
            })
        });
        if let Err(failed) = written {
            let _ = store.update_invites(&mut |all| {
                if let Some(i) = all
                    .iter_mut()
                    .find(|i| i.id == invite.id && i.used_by.as_deref() == Some(user_id.as_str()))
                {
                    i.used_by = None;
                    i.used_at = None;
                }
            });
            return Err(failed.into());
        }
        self.host.audit(Event::InviteRedeemed {
            invite: invite.id,
            user: user.id.clone(),
        });
        self.host.audit(Event::PasskeyRegistered {
            user: user.id.clone(),
            credential: ceremony::sha256_hex(&reg.credential_id),
        });
        self.mint_session(&user, attested.user_verified)
    }

    // ─── Login ──────────────────────────────────────────────────────────────

    /// **Open a passkey login** (discoverable credentials: nobody is named).
    pub fn login_start(&self, relay: &Relay) -> Result<Ceremony, Refused> {
        self.open(Purpose::Login, relay, ("", "", ""))
    }

    /// **Verify a login assertion and mint a session.**
    pub fn login_finish(&self, a: Assertion) -> Result<LoggedIn, Refused> {
        let mut named = None;
        let r = self.login_finish_inner(a, &mut named);
        if let Err(refused) = &r {
            self.host.audit(Event::LoginRefused {
                code: refused.code,
                user: named,
            });
        }
        r
    }

    /// `named` is set to the person the passkey is registered to once its signature verified.
    fn login_finish_inner(
        &self,
        a: Assertion,
        named: &mut Option<String>,
    ) -> Result<LoggedIn, Refused> {
        let purpose = self.take(
            &a.ceremony_id,
            "webauthn.get",
            &a.client_data_json,
            &a.rp_id,
            &a.origin,
        )?;
        if !matches!(purpose, Purpose::Login) {
            return refuse(
                Code::Ceremony,
                "this ceremony was opened for a registration, not a login",
            );
        }
        ceremony::check_credential_id(&a.credential_id)
            .map_err(|why| Refused::new(Code::Ceremony, why))?;
        let store = self.host.store();
        let Some(mut credential) = store.credential(&a.credential_id)? else {
            return refuse(Code::Ceremony, "this passkey is not registered here");
        };
        let Some(user) = store.user(&credential.user)? else {
            return refuse(Code::Ceremony, "this passkey is not registered here");
        };
        // The signature first, with the counter left out (a stored 0 takes any count), so a
        // refusal can tell "this key did not sign" from "this key signed a count that did not
        // grow" (GUN-FIELD-TEST): the second is a cloned authenticator or a replay, signed by
        // the person's own key, and it names them.
        let verified = ceremony::verify_assertion(
            &credential.public_key,
            0,
            &a.client_data_json,
            &a.authenticator_data,
            &a.signature,
            a.rp_id.trim(),
            step_up::user_verification(self.config.user_verification, false),
        )
        .map_err(|why| Refused::new(Code::Ceremony, why))?;
        // Named only once the passkey signed (RA5-29): a credential id is no secret, so a
        // refusal before the signature would let anyone write rows into anyone's audit.
        *named = Some(user.id.clone());
        // The counter rule, nornir-auth-webauthn's `counter_fits` (A34-31): a credential stored
        // at 0 never counted and takes any count; one that counted must grow.
        if credential.sign_count != 0 && verified.sign_count <= credential.sign_count {
            return refuse(
                Code::Ceremony,
                format!(
                    "the signature counter went from {} to {}: a cloned authenticator, or a replay",
                    credential.sign_count, verified.sign_count
                ),
            );
        }
        // After the signature, so the state is no oracle for which passkeys exist.
        if user.state != UserState::Active {
            return refuse(Code::NotActive, "this account may not log in");
        }
        credential.sign_count = verified.sign_count;
        store.put_credential(&credential)?;
        self.mint_session(&user, verified.user_verified)
    }

    // ─── Sessions ───────────────────────────────────────────────────────────

    fn live(&self, token: &str) -> Result<(Session, User, i64), Refused> {
        let gone = || Refused::new(Code::NoSession, "no session: log in");
        if !token.starts_with(TOKEN_PREFIX) {
            return Err(gone());
        }
        let store = self.host.store();
        let now = self.now();
        let session = store
            .session(&invites::hash_secret(token))?
            .ok_or_else(gone)?;
        if now >= session.expires {
            store.delete_session(&session.id)?;
            return Err(gone());
        }
        if !store
            .revocations()?
            .admits(&session.user, session.auth_time)
        {
            return Err(gone());
        }
        let user = store
            .user(&session.user)?
            .filter(|u| u.state == UserState::Active)
            .ok_or_else(gone)?;
        Ok((session, user, now))
    }

    /// **Who a session is**, or [`Code::NoSession`] (unknown, expired, ended,
    /// under a floor, or its person no longer active).
    pub fn session(&self, token: &str) -> Result<SessionView, Refused> {
        let (session, user, now) = self.live(token)?;
        Ok(self.view(&user, &session, now))
    }

    /// The session, when it stepped up within the window; else
    /// [`Code::NeedsStepUp`].
    pub fn require_step_up(&self, token: &str) -> Result<SessionView, Refused> {
        let view = self.session(token)?;
        if !view.stepped_up {
            return refuse(
                Code::NeedsStepUp,
                "this needs a fresh touch of your own passkey",
            );
        }
        Ok(view)
    }

    /// **A fresh step-up challenge** for this session's person.
    pub fn step_up_start(
        &self,
        token: &str,
        client: Option<&ClientKey>,
    ) -> Result<Challenge, Refused> {
        self.live(token)?;
        let (challenge, expires_at_ms) =
            self.step_ups.issue(client, self.now_ms()).map_err(busy)?;
        Ok(Challenge {
            challenge: ceremony::b64url(&challenge),
            expires_at_ms,
        })
    }

    /// **Step up**: a fresh, user-verified touch of the session's OWN passkey.
    pub fn step_up_finish(&self, token: &str, a: Assertion) -> Result<SessionView, Refused> {
        let (mut session, user, _) = self.live(token)?;
        let up = Presented {
            credential_id: &a.credential_id,
            client_data_json: &a.client_data_json,
            authenticator_data: &a.authenticator_data,
            signature: &a.signature,
            rp_id: &a.rp_id,
            origin: &a.origin,
        };
        let store = self.host.store();
        let done = step_up::verify_own_passkey(
            &up,
            &user.id,
            &self.step_ups,
            &self.config.parties,
            self.now_ms(),
            |id| {
                store.credential(id).map(|c| {
                    c.map(|c| Stored {
                        principal_id: c.user,
                        public_key: c.public_key,
                        sign_count: c.sign_count,
                    })
                })
            },
        )?;
        let done = done.map_err(|why| match why {
            step_up::Refused::Malformed(w) => Refused::new(Code::Malformed, w),
            step_up::Refused::Ceremony(w) => Refused::new(Code::Ceremony, w),
        })?;
        if let Some(mut credential) = store.credential(&done.credential_id)? {
            credential.sign_count = done.sign_count;
            store.put_credential(&credential)?;
        }
        let now = self.now();
        session.stepped_up_at = Some(now);
        store.put_session(&session)?;
        self.host.audit(Event::SteppedUp {
            user: user.id.clone(),
        });
        Ok(self.view(&user, &session, now))
    }

    /// **Log out**: this session, or (`everywhere`) every session of its person.
    pub fn logout(&self, token: &str, everywhere: bool) -> Result<(), Refused> {
        let (session, user, now) = self.live(token)?;
        let store = self.host.store();
        store.delete_session(&session.id)?;
        if everywhere {
            store.update_revocations(&mut |r| r.revoke_subject(&user.id, now))?;
        }
        self.host.audit(Event::LoggedOut {
            user: user.id,
            everywhere,
        });
        Ok(())
    }

    // ─── Deactivation ───────────────────────────────────────────────────────

    /// **Deactivate a person** (the host's own path: the directory, the CSV
    /// import, SCIM): they cannot log in, and every session of theirs ends.
    pub fn deactivate(&self, user_id: &str, by: &str) -> Result<(), Refused> {
        let store = self.host.store();
        let Some(mut user) = store.user(user_id)? else {
            return refuse(
                Code::Malformed,
                format!("there is no person {user_id:?} here"),
            );
        };
        user.state = UserState::Deactivated;
        store.put_user(&user)?;
        let now = self.now();
        store.update_revocations(&mut |r| r.revoke_subject(&user.id, now))?;
        self.host.audit(Event::Deactivated {
            user: user.id,
            by: by.to_owned(),
        });
        Ok(())
    }

    /// **An admin deactivates a person**: the admin must have stepped up.
    pub fn deactivate_as(&self, token: &str, user_id: &str) -> Result<(), Refused> {
        let caller = self.require_step_up(token)?;
        if !caller.admin {
            return refuse(Code::NotAdmin, "only an admin deactivates");
        }
        self.deactivate(user_id, &caller.user_id)
    }

    // ─── Passkeys of an existing person, lists, ends, vouching ───────────────

    /// **The host's own passkey link for an existing person** (an installer's
    /// first admin, a shop's paid account): no session, the host's authority.
    /// Never mailed: the host hands it over. Lives `ttl_secs` (`None`: the
    /// configured default). Refused for nobody of that id and for a
    /// deactivated person.
    pub fn enrolment(
        &self,
        person: &str,
        by: &str,
        ttl_secs: Option<i64>,
    ) -> Result<Invited, Refused> {
        let user = self.enrollable(person)?;
        self.mint_for_person(&user, by, None, ttl_secs)
    }

    /// **A passkey link the host was FED by its secret's SHA-256** (an
    /// installer's break-glass token, a sold estate's first-login link: the
    /// host holds only the hash). One per hash: recording it again renews an
    /// unused one. The link then redeems like any other, once, and a used hash is
    /// never re-opened (RA5-33): fed again, it is refused ([`Code::InviteNotOpen`]).
    pub fn fed_enrolment(
        &self,
        person: &str,
        secret_sha256_hex: &str,
        ttl_secs: i64,
        by: &str,
    ) -> Result<String, Refused> {
        let hex = secret_sha256_hex.trim();
        if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
            return refuse(
                Code::Malformed,
                "a fed link is the hex SHA-256 of its secret (64 characters), never the secret",
            );
        }
        let user = self.enrollable(person)?;
        if user.id.contains(':') {
            return refuse(Code::Malformed, "a person's id holds no ':'");
        }
        let now = self.now();
        let invite = Invite {
            id: fed_invite_id(hex),
            display: String::new(),
            groups: Vec::new(),
            created: now,
            expires: now.saturating_add(ttl_secs.max(60)),
            created_by: format!("{PERSON_INVITE}{}:{by}", user.id),
            used_by: None,
            used_at: None,
            revoked: false,
        };
        let id = invite.id.clone();
        let mut used = false;
        self.host.store().update_invites(&mut |all| {
            used = all.iter().any(|i| i.id == id && i.used_by.is_some());
            if used {
                return;
            }
            all.retain(|i| i.id != id);
            all.push(invite.clone());
            keep_bounded(all, now);
        })?;
        if used {
            return refuse(
                Code::InviteNotOpen,
                "this link was used: a used link is never opened again",
            );
        }
        self.host.audit(Event::InviteMinted {
            invite: id.clone(),
            by: by.to_owned(),
            mailed: false,
        });
        Ok(id)
    }

    /// **Withdraw an open invite on the host's own authority** (an
    /// installer's first link once its person holds a passkey). `false`: no
    /// open invite of that id.
    pub fn withdraw_invite(&self, invite_id: &str, by: &str) -> Result<bool, Refused> {
        let now = self.now();
        let mut found = false;
        self.host.store().update_invites(&mut |all| {
            if let Some(i) = all.iter_mut().find(|i| i.id == invite_id && i.open(now)) {
                i.revoked = true;
                found = true;
            }
        })?;
        if found {
            self.host.audit(Event::InviteRevoked {
                invite: invite_id.to_owned(),
                by: by.to_owned(),
            });
        }
        Ok(found)
    }

    fn enrollable(&self, person: &str) -> Result<User, Refused> {
        match self.host.store().user(person)? {
            Some(u) if u.state != UserState::Deactivated => Ok(u),
            Some(_) => refuse(
                Code::NotActive,
                "this person is deactivated: they get no passkey",
            ),
            None => refuse(
                Code::Malformed,
                format!("there is no person {person:?} here"),
            ),
        }
    }

    fn mint_for_person(
        &self,
        user: &User,
        by: &str,
        to: Option<String>,
        ttl: Option<i64>,
    ) -> Result<Invited, Refused> {
        if user.id.contains(':') {
            return refuse(Code::Malformed, "a person's id holds no ':'");
        }
        let ask = Ask {
            display: String::new(),
            groups: Vec::new(),
            by: format!("{PERSON_INVITE}{}:{by}", user.id),
            ttl_secs: ttl
                .filter(|t| *t > 0)
                .unwrap_or(self.config.invite_ttl_secs),
        };
        let now = self.now();
        let (secret, invite) = invites::mint(ask, now, |_| false)
            .map_err(|e| Refused::new(Code::Malformed, e.to_string()))?;
        self.store_invite(&invite, now)?;
        let mail_refusal = match to {
            Some(to) => self
                .host
                .mailer()
                .send(&Mail {
                    to,
                    kind: MailKind::Invite,
                    secret: secret.clone(),
                })
                .err(),
            None => Some("no address was given: hand the link over yourself".to_owned()),
        };
        let mailed = mail_refusal.is_none();
        self.host.audit(Event::InviteMinted {
            invite: invite.id.clone(),
            by: by.to_owned(),
            mailed,
        });
        Ok(Invited {
            secret,
            invite_id: invite.id,
            expires_at: invite.expires,
            mailed,
            mail_refusal,
        })
    }

    /// A stepped-up session's passkey link for an existing person: an admin for
    /// anybody, a person for themself, or whoever the host's `may_enrol` names.
    /// `caller` comes from [`Self::require_step_up`] ([`Self::invite`]).
    fn invite_person(
        &self,
        caller: SessionView,
        person: &str,
        ask: InviteAsk,
    ) -> Result<Invited, Refused> {
        if !ask.groups.is_empty() || !ask.display.trim().is_empty() {
            return refuse(
                Code::Malformed,
                "a passkey for an existing person carries no groups and no name: both are theirs",
            );
        }
        let user = self.enrollable(person)?;
        let allowed = caller.admin || caller.user_id == user.id || {
            let by = self.host.store().user(&caller.user_id)?;
            by.is_some_and(|by| self.host.may_enrol(&by, &user))
        };
        if !allowed {
            return refuse(
                Code::NotAdmin,
                "only an admin mints a passkey link for somebody else",
            );
        }
        let to = ask
            .email
            .as_deref()
            .filter(|e| !e.trim().is_empty())
            .map(normalize_email)
            .transpose()?;
        self.mint_for_person(&user, &caller.user_id, to, ask.ttl_secs)
    }

    /// **The open invites this session may see**: an admin every one, anybody
    /// else the ones they minted and the ones for them. Never a secret.
    pub fn list_invites(&self, token: &str) -> Result<Vec<InviteView>, Refused> {
        let caller = self.session(token)?;
        let now = self.now();
        Ok(self
            .host
            .store()
            .invites()?
            .iter()
            .filter(|i| i.open(now))
            .map(invite_view)
            .filter(|v| {
                caller.admin
                    || v.created_by == caller.user_id
                    || v.person.as_deref() == Some(caller.user_id.as_str())
            })
            .collect())
    }

    /// **Withdraw an open invite**: an admin any, a person one they minted or
    /// one for them. Unknown, used, expired or withdrawn: [`Code::InviteNotOpen`].
    pub fn revoke_invite(&self, token: &str, invite_id: &str) -> Result<(), Refused> {
        let caller = self.session(token)?;
        let now = self.now();
        let mut outcome = refuse(Code::InviteNotOpen, invites::Refused::NotOpen.to_string());
        self.host.store().update_invites(&mut |all| {
            outcome = match all.iter_mut().find(|i| i.id == invite_id && i.open(now)) {
                None => refuse(Code::InviteNotOpen, invites::Refused::NotOpen.to_string()),
                Some(i) => {
                    let v = invite_view(i);
                    if caller.admin
                        || v.created_by == caller.user_id
                        || v.person.as_deref() == Some(caller.user_id.as_str())
                    {
                        i.revoked = true;
                        Ok(())
                    } else {
                        refuse(
                            Code::NotAdmin,
                            "only an admin withdraws somebody else's invite",
                        )
                    }
                }
            };
        })?;
        if outcome.is_ok() {
            self.host.audit(Event::InviteRevoked {
                invite: invite_id.to_owned(),
                by: caller.user_id,
            });
        }
        outcome
    }

    /// **End every session of one person** (the host's own path: a passkey
    /// revoked, a key lost). They may log in again.
    pub fn end_sessions(&self, user_id: &str, by: &str) -> Result<(), Refused> {
        let now = self.now();
        self.host
            .store()
            .update_revocations(&mut |r| r.revoke_subject(user_id, now))?;
        self.host.audit(Event::SessionsEnded {
            user: user_id.to_owned(),
            by: by.to_owned(),
        });
        Ok(())
    }

    /// **End every session on the site**: a stepped-up admin, their own included.
    pub fn end_all(&self, token: &str) -> Result<(), Refused> {
        let caller = self.require_step_up(token)?;
        if !caller.admin {
            return refuse(Code::NotAdmin, "only an admin ends everyone's sessions");
        }
        let now = self.now();
        self.host
            .store()
            .update_revocations(&mut |r| r.revoke_all(now))?;
        self.host
            .audit(Event::EveryoneLoggedOut { by: caller.user_id });
        Ok(())
    }

    /// **A session for a person the host vouched for** (company sign-on: the
    /// identity provider verified them, the host checked it). Only an active
    /// person; `user_verified` is the host's word.
    pub fn vouched_session(&self, user_id: &str, user_verified: bool) -> Result<LoggedIn, Refused> {
        match self.host.store().user(user_id)? {
            Some(user) if user.state == UserState::Active => {
                self.mint_session(&user, user_verified)
            }
            _ => refuse(Code::NotActive, "this account may not log in"),
        }
    }
}

/// The id of an invite the host was FED by the hash of its secret (an
/// installer's break-glass token: the host never sees the token):
/// `sha256:<lowercase hex>`.
pub fn fed_invite_id(secret_sha256_hex: &str) -> String {
    format!(
        "{SHA256_INVITE}{}",
        secret_sha256_hex.trim().to_ascii_lowercase()
    )
}

fn fed_id_of(secret: &str) -> String {
    fed_invite_id(&ceremony::sha256_hex(secret.trim().as_bytes()))
}

/// The open invite `secret` names: by its blake3 id, or (a fed one) its SHA-256.
fn find_open<'a>(
    all: &'a [Invite],
    secret: &str,
    now: i64,
) -> Result<&'a Invite, invites::Refused> {
    invites::find_open(all, secret, now).or_else(|refused| {
        let id = fed_id_of(secret);
        all.iter()
            .find(|i| i.id == id && i.open(now))
            .ok_or(refused)
    })
}

/// Spend the invite `secret` names, a fed one included: single use.
fn redeem(
    all: &mut [Invite],
    secret: &str,
    by: &str,
    now: i64,
) -> Result<Invite, invites::Refused> {
    invites::redeem(all, secret, by, now).or_else(|refused| {
        let id = fed_id_of(secret);
        let invite = all
            .iter_mut()
            .find(|i| i.id == id && i.open(now))
            .ok_or(refused)?;
        invite.used_by = Some(by.to_owned());
        invite.used_at = Some(now);
        Ok(invite.clone())
    })
}

/// Is `i` a sign-up's own first-login link (no session minted it)?
fn is_sign_up(i: &Invite) -> bool {
    i.created_by.starts_with(SELF_INVITE)
}

/// **The store's bounds, per kind** (RA5-8). Closed invites go first once the
/// store holds more than both bounds; past [`MAX_INVITES`] open ones an admin's
/// or the host's oldest open link goes, NEVER a sign-up's. Sign-ups are bounded
/// before they are minted ([`MAX_OPEN_SIGN_UPS`], refused, never evicting).
fn keep_bounded(all: &mut Vec<Invite>, now: i64) {
    // A used fed link is kept: it is the record that its hash was spent (RA5-33).
    let spent_fed = |i: &Invite| i.id.starts_with(SHA256_INVITE) && i.used_by.is_some();
    while all.len() > MAX_INVITES + MAX_OPEN_SIGN_UPS {
        let Some(closed) = all.iter().position(|i| !i.open(now) && !spent_fed(i)) else {
            break;
        };
        all.remove(closed);
    }
    while all.iter().filter(|i| i.open(now) && !is_sign_up(i)).count() > MAX_INVITES {
        let Some(oldest) = all.iter().position(|i| i.open(now) && !is_sign_up(i)) else {
            break;
        };
        all.remove(oldest);
    }
}

/// Whose an invite is and who minted it, out of its `created_by`.
fn invite_parties(created_by: &str) -> (Option<&str>, &str) {
    if let Some(uid) = created_by.strip_prefix(SELF_INVITE) {
        return (Some(uid), uid);
    }
    if let Some((uid, by)) = created_by
        .strip_prefix(PERSON_INVITE)
        .and_then(|rest| rest.split_once(':'))
    {
        return (Some(uid), by);
    }
    (None, created_by)
}

fn invite_view(i: &Invite) -> InviteView {
    let (person, by) = invite_parties(&i.created_by);
    InviteView {
        id: i.id.clone(),
        display: i.display.clone(),
        groups: i.groups.clone(),
        person: person.map(str::to_owned),
        created_by: by.to_owned(),
        created: i.created,
        expires: i.expires,
    }
}