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-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));
    }
}