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
//! SAML 2.0 inbound (SP side) — a **trait seam**, not a bundled implementation.
//!
//! SAML requires XML canonicalisation + XML-DSig signature verification, a large
//! and security-sensitive surface. Per this crate's contract ("all I/O and heavy
//! crypto behind a trait, link no external SDK"), the app supplies a
//! [`SamlProvider`] built on a vetted SAML library; the crate only defines the
//! seam and the mapping into the shared federated-login path.
//!
//! The seam yields a [`FederatedProfile`], so a SAML assertion flows through the
//! exact same [`IdentityService::login_federated`](crate::identity::IdentityService::login_federated)
//! account-linking + provisioning logic as OIDC and social login.

use async_trait::async_trait;

use crate::error::Result;
use crate::federation::FederatedProfile;

/// The verified result of a SAML assertion, before mapping to a profile.
#[derive(Debug, Clone)]
pub struct SamlAssertion {
    /// The IdP entity id (`Issuer`).
    pub issuer: String,
    /// The `NameID` — the subject identifier from the IdP.
    pub name_id: String,
    /// Attribute statements (e.g. `email`, `groups`), flattened.
    pub attributes: std::collections::HashMap<String, Vec<String>>,
    /// Whether the IdP asserts the email is verified (org IdPs generally do).
    pub email_verified: bool,
}

impl SamlAssertion {
    fn attr(&self, key: &str) -> Option<&str> {
        self.attributes
            .get(key)
            .and_then(|v| v.first())
            .map(String::as_str)
    }

    /// Map the assertion to a [`FederatedProfile`]. `provider` is the registry id
    /// of the SAML IdP; `NameID` becomes the stable `provider_id`.
    pub fn to_profile(&self, provider: impl Into<String>) -> FederatedProfile {
        let mut attributes = serde_json::Map::new();
        for (k, v) in &self.attributes {
            attributes.insert(k.clone(), serde_json::json!(v));
        }
        FederatedProfile {
            provider: provider.into(),
            provider_id: self.name_id.clone(),
            email: self
                .attr("email")
                .or_else(|| self.attr("urn:oid:0.9.2342.19200300.100.1.3"))
                .map(str::to_owned),
            email_verified: self.email_verified,
            name: self.attr("displayName").map(str::to_owned),
            attributes,
        }
    }
}

/// SP-side SAML verification. Implement over a SAML library; return the verified
/// assertion (signature, conditions, audience, and `InResponseTo`/replay must be
/// checked by the implementation).
#[async_trait]
pub trait SamlProvider: Send + Sync + 'static {
    /// The IdP registry id this provider serves.
    fn id(&self) -> &str;
    /// Build the AuthnRequest redirect URL (with `RelayState`).
    fn authn_request_url(&self, relay_state: &str) -> Result<String>;
    /// Verify a base64 `SAMLResponse` from the ACS callback and extract the
    /// assertion. Implementations MUST verify the XML signature and conditions.
    async fn verify_response(&self, saml_response_b64: &str) -> Result<SamlAssertion>;
}