crates/nornir-identity-sso/src/lib.rs
//! **Company single sign-on, one copy for every product** (A34 M-4).
//!
//! An account's own identity provider (OIDC or SAML 2.0) signs its people in.
//! Gunnar kept all of this in `gunnar-grpc` (`sso/mod.rs`, `oidc.rs`, `saml.rs`)
//! and the rules in `gunnar-core` (`sso.rs`, `sso_table.rs`); they live here now,
//! and a product keeps only its storage, its transport and its words. Every
//! feature is off by default, so a build that names none links nothing.
//!
//! * `sso` — the protocol-free half:
//! * [`guard`]: which URL and which address an IdP may have (https, public,
//! or inside the operator's listed private networks — never the box's own);
//! * [`egress`]: the one way a product reaches an IdP ([`egress::Egress`]);
//! * [`state`]: one listener's memory — the flows in flight, the second-factor
//! tickets, the SAML assertions consumed, the rate windows;
//! * [`paths`]: the console paths an IdP is told (redirect URI, ACS, SP entity id);
//! * [`rules`]: who a verified sign-in is — the claim (A34-2), the rebind after
//! an IdP migration (A34-11) and the JIT policy (A34-25);
//! * [`domains`]: an e-mail domain leads to an account only after a DNS proof
//! (A34-5);
//! * [`refusal`]: every refusal has a code a console maps to its own words
//! (A34-46).
//! * `oidc` — the relying party ([`oidc`]) over `nornir_auth_oidc::rp`, with
//! the IdP's discovery document and keys cached per issuer and CA.
//! * `saml` — the service provider ([`saml`]) over `nornir_auth_saml::sp`.
//!
//! Every call that reaches the network is blocking and goes through the
//! product's [`egress::Egress`]; an async product runs it on its blocking pool.
#[cfg(feature = "sso")]
pub mod domains;
#[cfg(feature = "sso")]
pub mod egress;
#[cfg(feature = "sso")]
pub mod guard;
#[cfg(feature = "sso")]
pub mod paths;
#[cfg(feature = "rules")]
pub mod refusal;
#[cfg(feature = "rules")]
pub mod rules;
#[cfg(feature = "sso")]
pub mod state;
#[cfg(feature = "oidc")]
pub mod oidc;
#[cfg(feature = "saml")]
pub mod saml;
#[cfg(feature = "sso")]
pub use egress::Egress;
#[cfg(feature = "sso")]
pub use guard::{
address_forbidden, address_forbidden_in, addresses_allowed, addresses_allowed_in, url_allowed,
url_allowed_in, PrivateNets,
};
#[cfg(feature = "rules")]
pub use refusal::{code_of, Refusal};
#[cfg(feature = "sso")]
pub use state::{
client_key, Flow, Second, SsoState, BEGINS_PER_CLIENT_PER_MINUTE, EGRESS_OFF,
FAILURES_PER_CLIENT_PER_MINUTE, FLOW_TTL_MS, IDP_CACHE_MS, MAX_FLOWS, MAX_FLOWS_PER_CLIENT,
MAX_IDP_BODY, NO_EGRESS, SCIM_PER_MINUTE, SECOND_TTL_MS, SKEW_SECS,
};
/// `n` bytes from the operating system's random source. A source that cannot
/// answer is a panic, never a predictable value.
#[cfg(feature = "sso")]
pub fn random_bytes(n: usize) -> Vec<u8> {
let mut out = vec![0u8; n];
getrandom::fill(&mut out).expect("the operating system's random source answered");
out
}
/// Lowercase hex.
#[cfg(feature = "rules")]
pub fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
/// Lowercase hex SHA-256: how a state, a ticket or a bearer is kept (never the
/// secret itself).
#[cfg(feature = "rules")]
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::Digest as _;
hex(&sha2::Sha256::digest(bytes))
}
/// `SHA256:<base64>` of a certificate's DER, as a listing shows it.
#[cfg(feature = "sso")]
pub fn fingerprint(der: &[u8]) -> String {
use base64::Engine as _;
use sha2::Digest as _;
format!(
"SHA256:{}",
base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(der))
)
}
/// `2026-10-05T12:00:00Z` for a unix second (UTC, proleptic Gregorian).
#[cfg(feature = "sso")]
pub fn rfc3339(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let secs = unix.rem_euclid(86_400);
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
secs / 3600,
secs % 3600 / 60,
secs % 60
)
}
#[cfg(all(test, feature = "sso"))]
mod tests {
use super::*;
#[test]
fn rfc3339_is_utc() {
assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339(1_800_000_000), "2027-01-15T08:00:00Z");
assert_eq!(rfc3339(951_782_400), "2000-02-29T00:00:00Z");
}
#[test]
fn hashes_and_fingerprints_are_stable() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
fingerprint(b"abc"),
"SHA256:ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0"
);
assert_eq!(random_bytes(32).len(), 32);
assert_ne!(random_bytes(16), random_bytes(16));
}
}