arcly-http-identity 0.7.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! Home-realm discovery + per-tenant IdP registry (enterprise B2B SSO).
//!
//! In B2B, "which login do I show this user?" is answered by their **email
//! domain**: `alice@acme.com` → Acme's Azure AD, `bob@globex.io` → Globex's
//! Okta. Each org (tenant) federates its own IdP. This registry maps
//! `domain → IdP config` and `tenant → IdP config`, so the app can:
//!
//! 1. do home-realm discovery at the email step (return the right IdP to redirect to), and
//! 2. bind a federated login to the correct tenant (not the global consumer pool).
//!
//! It sits behind an `ArcSwap` so an admin can onboard/rotate a tenant's IdP
//! with no restart — the same lock-free hot-reload pattern as `TenantRegistry`
//! and `PolicyEngine`.

use std::collections::HashMap;
use std::sync::Arc;

use arc_swap::ArcSwap;

/// The federation protocol a tenant's IdP speaks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdpKind {
    Oidc,
    Saml,
}

/// One tenant's identity-provider configuration.
#[derive(Debug, Clone)]
pub struct IdpConfig {
    /// Stable registry id (e.g. `"acme-azuread"`).
    pub id: String,
    /// The tenant this IdP authenticates users into.
    pub tenant: String,
    pub kind: IdpKind,
    /// OIDC issuer / SAML entity id.
    pub issuer: String,
    /// OIDC client id / SAML SP audience (the token/assertion `aud`).
    pub audience: String,
    /// Email domains routed to this IdP (lowercased, no `@`).
    pub domains: Vec<String>,
    /// Redirect/ACS URL to send the browser to.
    pub sso_url: String,
}

/// Immutable snapshot of all registered IdPs, indexed for O(1) lookup.
struct IdpTable {
    by_id: HashMap<String, Arc<IdpConfig>>,
    by_domain: HashMap<String, Arc<IdpConfig>>,
}

impl IdpTable {
    fn build(configs: Vec<IdpConfig>) -> Self {
        let mut by_id = HashMap::new();
        let mut by_domain = HashMap::new();
        for cfg in configs {
            let arc = Arc::new(cfg);
            for d in &arc.domains {
                by_domain.insert(d.to_ascii_lowercase(), arc.clone());
            }
            by_id.insert(arc.id.clone(), arc.clone());
        }
        Self { by_id, by_domain }
    }
}

/// Hot-swappable registry of per-tenant IdPs. Provide via DI.
pub struct IdpRegistry {
    table: ArcSwap<IdpTable>,
}

impl IdpRegistry {
    pub fn new(configs: Vec<IdpConfig>) -> Self {
        Self {
            table: ArcSwap::from_pointee(IdpTable::build(configs)),
        }
    }

    /// Home-realm discovery: resolve the IdP for an email address by its domain.
    /// Returns `None` for consumer addresses with no federated org (fall back to
    /// password / social login).
    pub fn resolve_by_email(&self, email: &str) -> Option<Arc<IdpConfig>> {
        let domain = email.rsplit('@').next()?.to_ascii_lowercase();
        self.table.load().by_domain.get(&domain).cloned()
    }

    /// Look up an IdP by its registry id (used on the callback to know which
    /// tenant + verifier applies).
    pub fn get(&self, id: &str) -> Option<Arc<IdpConfig>> {
        self.table.load().by_id.get(id).cloned()
    }

    /// Hot-reload the whole set (admin onboarding / rotation) with no restart.
    pub fn reload(&self, configs: Vec<IdpConfig>) {
        self.table.store(Arc::new(IdpTable::build(configs)));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn reg() -> IdpRegistry {
        IdpRegistry::new(vec![IdpConfig {
            id: "acme-azuread".into(),
            tenant: "acme".into(),
            kind: IdpKind::Oidc,
            issuer: "https://login.microsoftonline.com/acme/v2.0".into(),
            audience: "client-acme".into(),
            domains: vec!["acme.com".into(), "acme.co.uk".into()],
            sso_url: "https://login.microsoftonline.com/acme/authorize".into(),
        }])
    }

    #[test]
    fn resolves_home_realm_by_domain() {
        let r = reg();
        assert_eq!(r.resolve_by_email("alice@ACME.com").unwrap().tenant, "acme");
        assert_eq!(
            r.resolve_by_email("bob@acme.co.uk").unwrap().id,
            "acme-azuread"
        );
        assert!(r.resolve_by_email("carol@gmail.com").is_none());
    }
}