entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! SAML Service Provider (SP) and Identity Provider (`IdP`) configuration.

use core::fmt;

use crate::util::validation::is_https_url;

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// Error returned when SAML configuration validation fails.
#[doc(alias = "saml_config_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SamlConfigError {
    field: &'static str,
    reason: &'static str,
}

impl fmt::Display for SamlConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "saml config: {} {}", self.field, self.reason)
    }
}

impl std::error::Error for SamlConfigError {}

// ---------------------------------------------------------------------------
// SamlConfig
// ---------------------------------------------------------------------------

/// Configuration for a SAML 2.0 Service Provider and its trusted Identity
/// Provider.
///
/// All fields are validated at construction time:
/// - No field may be empty.
/// - URL fields (`acs_url`, `idp_sso_url`) must start with `https://`.
#[doc(alias = "saml_config")]
#[derive(Debug, Clone)]
pub struct SamlConfig {
    /// The SP entity ID (audience URI).
    entity_id: String,
    /// The Assertion Consumer Service URL where the `IdP` posts responses.
    acs_url: String,
    /// The `IdP` Single Sign-On URL for initiating authentication.
    idp_sso_url: String,
    /// The `IdP` entity ID used to validate assertion issuers.
    idp_entity_id: String,
    /// Maximum clock skew tolerance in seconds for temporal validation.
    /// Default: 60 seconds.
    clock_skew_secs: u64,
}

impl SamlConfig {
    /// Returns the SP entity ID.
    #[must_use]
    #[inline]
    pub fn entity_id(&self) -> &str {
        &self.entity_id
    }

    /// Returns the Assertion Consumer Service URL.
    #[must_use]
    #[inline]
    pub fn acs_url(&self) -> &str {
        &self.acs_url
    }

    /// Returns the `IdP` Single Sign-On URL.
    #[must_use]
    #[inline]
    pub fn idp_sso_url(&self) -> &str {
        &self.idp_sso_url
    }

    /// Returns the `IdP` entity ID.
    #[must_use]
    #[inline]
    pub fn idp_entity_id(&self) -> &str {
        &self.idp_entity_id
    }

    /// Returns the configured clock skew tolerance in seconds.
    #[must_use]
    #[inline]
    pub fn clock_skew_secs(&self) -> u64 {
        self.clock_skew_secs
    }

    /// Sets the clock skew tolerance in seconds.
    #[must_use]
    pub fn with_clock_skew_secs(mut self, secs: u64) -> Self {
        self.clock_skew_secs = secs;
        self
    }
}

/// Validates that a field value is not empty.
fn validate_not_empty(value: &str, field: &'static str) -> Result<(), SamlConfigError> {
    if value.is_empty() {
        return Err(SamlConfigError {
            field,
            reason: "must not be empty",
        });
    }
    Ok(())
}

/// Validates that a URL field is not empty and uses HTTPS.
fn validate_https_url(value: &str, field: &'static str) -> Result<(), SamlConfigError> {
    validate_not_empty(value, field)?;
    if !is_https_url(value) {
        return Err(SamlConfigError {
            field,
            reason: "must use HTTPS",
        });
    }
    Ok(())
}

impl SamlConfig {
    /// Create a new SAML configuration.
    ///
    /// # Errors
    ///
    /// Returns [`SamlConfigError`] if any field is empty or if URL fields
    /// do not use HTTPS.
    pub fn new(
        entity_id: &str,
        acs_url: &str,
        idp_sso_url: &str,
        idp_entity_id: &str,
    ) -> Result<Self, SamlConfigError> {
        validate_not_empty(entity_id, "entity_id")?;
        validate_https_url(acs_url, "acs_url")?;
        validate_https_url(idp_sso_url, "idp_sso_url")?;
        validate_not_empty(idp_entity_id, "idp_entity_id")?;

        Ok(Self {
            entity_id: entity_id.to_string(),
            acs_url: acs_url.to_string(),
            idp_sso_url: idp_sso_url.to_string(),
            idp_entity_id: idp_entity_id.to_string(),
            clock_skew_secs: 60,
        })
    }
}

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

    // --- Valid configuration ---

    #[test]
    fn config_round_trip() {
        let cfg = SamlConfig::new(
            "https://sp.example.com",
            "https://sp.example.com/acs",
            "https://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap();
        assert_eq!(cfg.entity_id(), "https://sp.example.com");
        assert_eq!(cfg.acs_url(), "https://sp.example.com/acs");
        assert_eq!(cfg.idp_sso_url(), "https://idp.example.com/sso");
        assert_eq!(cfg.idp_entity_id(), "https://idp.example.com");
    }

    // --- Empty field validation ---

    #[test]
    fn reject_empty_entity_id() {
        let err = SamlConfig::new(
            "",
            "https://sp.example.com/acs",
            "https://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap_err();
        assert!(err.to_string().contains("entity_id"), "got: {err}");
    }

    #[test]
    fn reject_empty_acs_url() {
        let err = SamlConfig::new(
            "https://sp.example.com",
            "",
            "https://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap_err();
        assert!(err.to_string().contains("acs_url"), "got: {err}");
    }

    #[test]
    fn reject_empty_idp_sso_url() {
        let err = SamlConfig::new(
            "https://sp.example.com",
            "https://sp.example.com/acs",
            "",
            "https://idp.example.com",
        )
        .unwrap_err();
        assert!(err.to_string().contains("idp_sso_url"), "got: {err}");
    }

    #[test]
    fn reject_empty_idp_entity_id() {
        let err = SamlConfig::new(
            "https://sp.example.com",
            "https://sp.example.com/acs",
            "https://idp.example.com/sso",
            "",
        )
        .unwrap_err();
        assert!(err.to_string().contains("idp_entity_id"), "got: {err}");
    }

    // --- HTTPS validation ---

    #[test]
    fn reject_http_acs_url() {
        let err = SamlConfig::new(
            "https://sp.example.com",
            "http://sp.example.com/acs",
            "https://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap_err();
        assert!(err.to_string().contains("HTTPS"), "got: {err}");
    }

    #[test]
    fn reject_http_idp_sso_url() {
        let err = SamlConfig::new(
            "https://sp.example.com",
            "https://sp.example.com/acs",
            "http://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap_err();
        assert!(err.to_string().contains("HTTPS"), "got: {err}");
    }

    // --- Error Display ---

    #[test]
    fn error_display_format() {
        let err = SamlConfigError {
            field: "acs_url",
            reason: "must use HTTPS",
        };
        assert_eq!(err.to_string(), "saml config: acs_url must use HTTPS");
    }

    #[test]
    fn error_implements_std_error() {
        let err: Box<dyn std::error::Error> = Box::new(SamlConfigError {
            field: "test",
            reason: "test reason",
        });
        let _ = err.to_string();
    }

    // --- Clock skew configuration ---

    #[test]
    fn default_clock_skew_is_60() {
        let cfg = SamlConfig::new(
            "https://sp.example.com",
            "https://sp.example.com/acs",
            "https://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap();
        assert_eq!(cfg.clock_skew_secs(), 60);
    }

    #[test]
    fn with_clock_skew_secs_overrides_default() {
        let cfg = SamlConfig::new(
            "https://sp.example.com",
            "https://sp.example.com/acs",
            "https://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap()
        .with_clock_skew_secs(120);
        assert_eq!(cfg.clock_skew_secs(), 120);
    }

    #[test]
    fn with_clock_skew_secs_zero() {
        let cfg = SamlConfig::new(
            "https://sp.example.com",
            "https://sp.example.com/acs",
            "https://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap()
        .with_clock_skew_secs(0);
        assert_eq!(cfg.clock_skew_secs(), 0);
    }
}