crates/nornir-identity/src/lib.rs
//! **The identity core: one copy of sign-up, login, invites, sessions and
//! step-up for every nordisk product** (IDENTITY-HOST, owner GO 2026-10-27).
//!
//! [`Identity<H>`] owns the whole flow. A product implements ONE small trait,
//! [`IdentityHost`]: what kind of site it is ([`Site`], data, never a code path),
//! where users live ([`UserStore`]), what its roles are, its audit chain, and how
//! a link reaches a person ([`Mailer`]). Nothing else is the product's: the
//! passkey ceremony is nornir-auth-webauthn's `ceremony`, the invites, floors and
//! step-up rules are nornir-identity-session's, and the rules joining them are
//! here, once.
//!
//! * `grpc`: the `IdentityService` proto and [`grpc::IdentityGrpc`], the one
//! adapter gunnar-server and holger-server both serve.
//! * `test-support`: [`test_host::TestHost`], a soft passkey, and the
//! conformance suite ([`conformance`], [`conformance_suite!`]) a product runs
//! against its own host.
//!
//! A session is an opaque token whose blake3 hash is all the store keeps; it
//! ends on logout, on deactivation (every path, the CSV import's included: they
//! all call [`Identity::deactivate`]), on its expiry and under a revocation floor.
mod flows;
pub use flows::*;
#[cfg(feature = "grpc")]
pub mod grpc;
/// **Company single sign-on** (feature `sso`, A34 M-4): edda nornir-identity-sso,
/// one copy for every product behind this core.
#[cfg(feature = "sso")]
pub use nornir_identity_sso as sso;
#[cfg(feature = "test-support")]
pub mod test_host;
#[cfg(feature = "test-support")]
pub mod conformance;
pub use nornir_auth_webauthn::ceremony::{ClientKey, Limits, RelyingParty};
pub use nornir_identity_session::invites::Invite;
pub use nornir_identity_session::session::Revocations;
pub use nornir_identity_session::step_up::UserVerification;
// ─── The product's data ─────────────────────────────────────────────────────
/// **What kind of site this is**: data the host returns, never a code path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Site {
/// A site that sells: anybody may sign up.
Root,
/// A private install: invite only.
Private,
}
/// Where a person's account stands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UserState {
/// Signed up, no passkey yet: cannot log in.
Pending,
/// May log in.
Active,
/// Ended by an admin or the directory: cannot log in, has no sessions.
Deactivated,
}
/// A person, as the core reads and writes them through [`UserStore`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
/// The stable id (also the WebAuthn user handle).
pub id: String,
/// The name shown.
pub display: String,
/// The address a sign-up named, lowercased.
pub email: Option<String>,
/// The product's groups; [`IdentityHost::roles`] reads them.
pub groups: Vec<String>,
pub state: UserState,
/// Unix seconds.
pub created: i64,
}
/// A stored passkey.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credential {
/// The credential id (rawId).
pub id: Vec<u8>,
/// Whose it is.
pub user: String,
/// P-256, SEC1 uncompressed.
pub public_key: Vec<u8>,
/// The last counter seen.
pub sign_count: u32,
/// Unix seconds.
pub created: i64,
}
/// A session, as the store keeps it: never the token, only its hash.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
/// blake3(token), base64url.
pub id: String,
pub user: String,
/// When the login happened, Unix seconds (the floors compare against it).
pub auth_time: i64,
/// Unix seconds, exclusive.
pub expires: i64,
/// When the person last verified themselves on their own passkey (a
/// user-verified login counts), Unix seconds.
pub stepped_up_at: Option<i64>,
}
/// **The product's own roles** for a person.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Roles {
/// May invite and deactivate.
pub admin: bool,
/// The product's role names, shown as they are.
pub names: Vec<String>,
}
/// **What the core tells the product's audit chain.** No secret is in it: an
/// invite is named by its hash, a session by its hash.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
SignedUp { user: String },
SignUpRefused { code: Code },
InviteMinted { invite: String, by: String, mailed: bool },
InviteRedeemed { invite: String, user: String },
PasskeyRegistered { user: String, credential: String },
LoggedIn { user: String, session: String, user_verified: bool },
/// A passkey login was refused. `user` is the person the presented passkey is
/// registered to, when it named one (a bad signature, a deactivated person), so the
/// product can write the refusal to that person's account audit; `None` for a passkey
/// this site does not know or a ceremony refused before the passkey was read (0.2.1).
LoginRefused { code: Code, user: Option<String> },
SteppedUp { user: String },
LoggedOut { user: String, everywhere: bool },
Deactivated { user: String, by: String },
/// An open invite was withdrawn (by its id, the secret's hash).
InviteRevoked { invite: String, by: String },
/// The host ended every session of one person (a revoked passkey).
SessionsEnded { user: String, by: String },
/// A stepped-up admin ended every session on the site.
EveryoneLoggedOut { by: String },
}
/// The store failed: the product's words.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreError(pub String);
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for StoreError {}
/// **Where a product keeps its people** (Gunnar: its auth tables; Holger: znippy).
/// Every write is the whole record. `update_invites` and `update_revocations`
/// hold the store's lock across the closure, so two racing redemptions cannot
/// both spend one invite.
pub trait UserStore: Send + Sync {
fn user(&self, id: &str) -> Result<Option<User>, StoreError>;
fn user_by_email(&self, email: &str) -> Result<Option<User>, StoreError>;
fn users(&self) -> Result<Vec<User>, StoreError>;
fn put_user(&self, user: &User) -> Result<(), StoreError>;
fn credential(&self, id: &[u8]) -> Result<Option<Credential>, StoreError>;
fn credentials_of(&self, user: &str) -> Result<Vec<Credential>, StoreError>;
fn put_credential(&self, credential: &Credential) -> Result<(), StoreError>;
fn session(&self, id: &str) -> Result<Option<Session>, StoreError>;
fn put_session(&self, session: &Session) -> Result<(), StoreError>;
fn delete_session(&self, id: &str) -> Result<(), StoreError>;
fn invites(&self) -> Result<Vec<Invite>, StoreError>;
fn update_invites(&self, change: &mut dyn FnMut(&mut Vec<Invite>)) -> Result<(), StoreError>;
fn revocations(&self) -> Result<Revocations, StoreError>;
fn update_revocations(&self, change: &mut dyn FnMut(&mut Revocations)) -> Result<(), StoreError>;
}
/// What a link is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailKind {
/// A sign-up's first login: the link registers the first passkey.
FirstLogin,
/// An admin's invite.
Invite,
}
/// One link to one person. The mailer builds the URL from its own base.
#[derive(Clone, PartialEq, Eq)]
pub struct Mail {
pub to: String,
pub kind: MailKind,
/// The single-use secret the link carries.
pub secret: String,
}
impl std::fmt::Debug for Mail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Mail").field("to", &self.to).field("kind", &self.kind).field("secret", &"<redacted>").finish()
}
}
/// **How a link reaches a person.**
pub trait Mailer: Send + Sync {
/// Send it, or say why not (the product's words).
fn send(&self, mail: &Mail) -> Result<(), String>;
}
/// **The airgap mailer: it refuses, by name.** A site with no outbound mail
/// hands its invite links over by hand; a sign-up there is refused.
#[derive(Debug, Clone, Copy, Default)]
pub struct NoMail;
/// What [`NoMail`] answers.
pub const NO_MAIL: &str = "this site sends no e-mail (airgap): hand the link over yourself";
impl Mailer for NoMail {
fn send(&self, _: &Mail) -> Result<(), String> {
Err(NO_MAIL.to_owned())
}
}
/// **The ONLY product-specific part of identity.**
pub trait IdentityHost: Send + Sync {
/// Root (sells sign-up) or Private (invite only).
fn site(&self) -> Site;
/// Users, passkeys, sessions, invites, floors.
fn store(&self) -> &dyn UserStore;
/// The product's own roles for a person.
fn roles(&self, user: &User) -> Roles;
/// The product's audit chain.
fn audit(&self, event: Event);
/// First-login and invite links.
fn mailer(&self) -> &dyn Mailer;
/// **Whether `by` may mint a passkey link for the existing `person`**,
/// beside an admin (anybody) and the person themself, who always may: the
/// product's own rule (Gunnar: an account admin for the account's people).
/// Default: nobody else.
fn may_enrol(&self, by: &User, person: &User) -> bool {
let _ = (by, person);
false
}
/// Unix milliseconds. A test host's clock is its own.
fn now_ms(&self) -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
.unwrap_or(0)
}
}
impl<T: IdentityHost + ?Sized> IdentityHost for std::sync::Arc<T> {
fn site(&self) -> Site {
(**self).site()
}
fn store(&self) -> &dyn UserStore {
(**self).store()
}
fn roles(&self, user: &User) -> Roles {
(**self).roles(user)
}
fn audit(&self, event: Event) {
(**self).audit(event)
}
fn mailer(&self) -> &dyn Mailer {
(**self).mailer()
}
fn may_enrol(&self, by: &User, person: &User) -> bool {
(**self).may_enrol(by, person)
}
fn now_ms(&self) -> i64 {
(**self).now_ms()
}
}
// ─── Refusals ───────────────────────────────────────────────────────────────
/// **A refusal's stable code.** The words are the console's; the code is what
/// two products must agree on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Code {
/// Sign-up on a Private site.
SignUpClosed,
/// The invite or link is unknown, expired, used or revoked (one answer).
InviteNotOpen,
/// An invite would make an admin.
InviteWouldBeAdmin,
/// The caller is not an admin.
NotAdmin,
/// The site already has its first admin.
Bootstrapped,
/// A passkey ceremony failed its checks.
Ceremony,
/// Too many ceremonies are open (server-wide or for this client).
Busy,
/// No such session (unknown, expired, ended or under a floor).
NoSession,
/// The person may not log in (pending or deactivated).
NotActive,
/// The verb needs a fresh step-up.
NeedsStepUp,
/// The mailer refused.
MailRefused,
/// The request is malformed.
Malformed,
/// The product's store failed.
Store,
}
impl Code {
/// The stable name (the gRPC refusal metadata).
pub fn as_str(self) -> &'static str {
match self {
Code::SignUpClosed => "signup_closed",
Code::InviteNotOpen => "invite_not_open",
Code::InviteWouldBeAdmin => "invite_would_be_admin",
Code::NotAdmin => "not_admin",
Code::Bootstrapped => "bootstrapped",
Code::Ceremony => "ceremony",
Code::Busy => "busy",
Code::NoSession => "no_session",
Code::NotActive => "not_active",
Code::NeedsStepUp => "needs_step_up",
Code::MailRefused => "mail_refused",
Code::Malformed => "malformed",
Code::Store => "store",
}
}
/// Every code, for a product mapping its words.
pub const ALL: [Code; 13] = [
Code::SignUpClosed,
Code::InviteNotOpen,
Code::InviteWouldBeAdmin,
Code::NotAdmin,
Code::Bootstrapped,
Code::Ceremony,
Code::Busy,
Code::NoSession,
Code::NotActive,
Code::NeedsStepUp,
Code::MailRefused,
Code::Malformed,
Code::Store,
];
}
impl std::fmt::Display for Code {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// A refusal: its code and the core's detail.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused {
pub code: Code,
pub detail: String,
}
impl Refused {
pub fn new(code: Code, detail: impl Into<String>) -> Self {
Refused { code, detail: detail.into() }
}
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.detail)
}
}
impl std::error::Error for Refused {}
impl From<StoreError> for Refused {
fn from(e: StoreError) -> Self {
Refused::new(Code::Store, e.0)
}
}
// ─── The core's settings ────────────────────────────────────────────────────
/// How long a session lives when the product says nothing: twelve hours.
pub const SESSION_TTL_SECS: i64 = 12 * 3600;
/// The core's settings, the product's to choose.
#[derive(Debug, Clone)]
pub struct Config {
/// The relying parties this server answers for; empty: the relay's word.
pub parties: Vec<RelyingParty>,
/// How long a session lives, seconds.
pub session_ttl_secs: i64,
/// Whether a login must be user-verified.
pub user_verification: UserVerification,
/// How long an invite or first-login link lives, seconds.
pub invite_ttl_secs: i64,
/// The ceremony store's bounds.
pub limits: Limits,
}
impl Default for Config {
fn default() -> Self {
Config {
parties: Vec::new(),
session_ttl_secs: SESSION_TTL_SECS,
user_verification: UserVerification::Preferred,
invite_ttl_secs: nornir_identity_session::invites::INVITE_TTL_SECS,
limits: Limits::default(),
}
}
}