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(
®.ceremony_id,
"webauthn.create",
®.client_data_json,
®.rp_id,
®.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(®.credential_id).map_err(bad)?;
ceremony::check_public_key(®.public_key).map_err(bad)?;
let attested = ceremony::verify_attestation(
®.attestation_object,
®.client_data_json,
reg.rp_id.trim(),
®.credential_id,
®.public_key,
)
.map_err(bad)?;
let store = self.host.store();
if store.credential(®.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(®.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,
}
}