saml-rs 0.3.0

Pure-Rust SAML 2.0 Service Provider and Identity Provider support.
Documentation
use super::attributes::{Attribute, AttributeValue, Attributes};
use super::endpoint::EndpointUrl;
use super::identifiers::{MessageId, SamlInstant, SessionIndex};
use super::session::AuthnSession;
use super::subject::{NameIdPolicy, SubjectConfirmation};
use crate::config::{EntityId, NameIdFormat};
use crate::constants::name_id_format;
use crate::error::SamlError;
use crate::util::Value;
use crate::validator::conditions_time_bounds;

pub(super) fn required_str<'a>(extract: &'a Value, path: &str) -> Result<&'a str, SamlError> {
    extract
        .get_str(path)
        .ok_or_else(|| SamlError::Invalid(format!("missing extracted field {path}")))
}

pub(super) fn optional_request_id(
    extract: &Value,
    path: &str,
) -> Result<Option<MessageId>, SamlError> {
    extract
        .get_str(path)
        .filter(|value| !value.is_empty())
        .map(MessageId::try_new)
        .transpose()
}

pub(super) fn optional_endpoint(
    extract: &Value,
    path: &str,
) -> Result<Option<EndpointUrl>, SamlError> {
    extract.get_str(path).map(EndpointUrl::try_new).transpose()
}

pub(super) fn optional_u16(extract: &Value, path: &str) -> Result<Option<u16>, SamlError> {
    extract
        .get_str(path)
        .map(|value| {
            value.parse::<u16>().map_err(|_| {
                SamlError::Invalid(format!("{path} must be an unsigned 16-bit integer"))
            })
        })
        .transpose()
}

pub(super) fn conditions_instants(
    extract: &Value,
) -> Result<(Option<SamlInstant>, Option<SamlInstant>), SamlError> {
    let (not_before, not_on_or_after) = conditions_time_bounds(extract)?;
    Ok((
        not_before.map(SamlInstant::try_new).transpose()?,
        not_on_or_after.map(SamlInstant::try_new).transpose()?,
    ))
}

pub(super) fn name_id_policy_from_extract(
    extract: &Value,
) -> Result<Option<NameIdPolicy>, SamlError> {
    let format = extract
        .get_str("nameIDPolicy.format")
        .map(name_id_format_from_uri);
    let allow_create = extract
        .get_str("nameIDPolicy.allowCreate")
        .map(parse_bool)
        .transpose()?;
    Ok(NameIdPolicy::from_parsed(format, allow_create))
}

fn parse_bool(value: &str) -> Result<bool, SamlError> {
    match value {
        "true" | "1" => Ok(true),
        "false" | "0" => Ok(false),
        _ => Err(SamlError::Invalid(
            "NameIDPolicy AllowCreate must be a boolean".into(),
        )),
    }
}

pub(super) fn name_id_format_from_uri(uri: &str) -> NameIdFormat {
    match uri {
        name_id_format::EMAIL_ADDRESS => NameIdFormat::EmailAddress,
        name_id_format::PERSISTENT => NameIdFormat::Persistent,
        name_id_format::TRANSIENT => NameIdFormat::Transient,
        name_id_format::ENTITY => NameIdFormat::Entity,
        name_id_format::UNSPECIFIED => NameIdFormat::Unspecified,
        name_id_format::KERBEROS => NameIdFormat::Kerberos,
        name_id_format::WINDOWS_DOMAIN_QUALIFIED_NAME => NameIdFormat::WindowsDomainQualifiedName,
        name_id_format::X509_SUBJECT_NAME => NameIdFormat::X509SubjectName,
        _ => NameIdFormat::Custom(uri.to_string()),
    }
}

fn value_strings(value: &Value) -> Vec<String> {
    match value {
        Value::Str(value) => vec![value.clone()],
        Value::Array(values) => values
            .iter()
            .filter_map(Value::as_str)
            .map(str::to_string)
            .collect(),
        Value::Null | Value::Object(_) => Vec::new(),
    }
}

pub(super) fn attributes_from_extract(extract: &Value) -> Attributes {
    let Some(Value::Object(entries)) = extract.get("attributes") else {
        return Attributes::default();
    };
    let attributes = entries
        .iter()
        .map(|(name, value)| {
            let values = value_strings(value)
                .into_iter()
                .map(AttributeValue::new)
                .collect();
            Attribute::new(name.clone(), None, values)
        })
        .collect();
    Attributes::new(attributes)
}

pub(super) fn subject_confirmations_from_extract(extract: &Value) -> Vec<SubjectConfirmation> {
    match extract.get("subjectConfirmation") {
        Some(Value::Str(xml)) => vec![SubjectConfirmation::from_raw_xml(xml.clone())],
        Some(Value::Array(values)) => values
            .iter()
            .filter_map(Value::as_str)
            .map(SubjectConfirmation::from_raw_xml)
            .collect(),
        Some(Value::Null | Value::Object(_)) | None => Vec::new(),
    }
}

fn authn_statement_values(extract: &Value) -> Result<Vec<&Value>, SamlError> {
    match extract.get("sessionIndex") {
        Some(value @ Value::Object(_)) => Ok(vec![value]),
        Some(Value::Array(values)) => values
            .iter()
            .map(|value| match value {
                Value::Object(_) => Ok(value),
                Value::Null | Value::Str(_) | Value::Array(_) => Err(SamlError::Invalid(
                    "extracted AuthnStatement array must contain only objects".into(),
                )),
            })
            .collect(),
        Some(Value::Str(_)) => Err(SamlError::Invalid(
            "extracted AuthnStatement must be an object or array of objects".into(),
        )),
        Some(Value::Null) | None => Ok(Vec::new()),
    }
}

fn optional_authn_statement_str<'a>(
    statement: &'a Value,
    field: &str,
) -> Result<Option<&'a str>, SamlError> {
    match statement.get(field) {
        Some(Value::Str(value)) => Ok(Some(value)),
        Some(Value::Null) | None => Ok(None),
        Some(Value::Array(_) | Value::Object(_)) => Err(SamlError::Invalid(format!(
            "extracted AuthnStatement {field} must be a string"
        ))),
    }
}

pub(crate) fn authn_statement_not_on_or_after_values(
    extract: &Value,
) -> Result<Vec<&str>, SamlError> {
    authn_statement_values(extract)?
        .into_iter()
        .map(|statement| optional_authn_statement_str(statement, "sessionNotOnOrAfter"))
        .filter_map(Result::transpose)
        .collect()
}

pub(super) fn authn_sessions_from_extract(extract: &Value) -> Result<Vec<AuthnSession>, SamlError> {
    authn_statement_values(extract)?
        .into_iter()
        .map(|statement| {
            let session_index = optional_authn_statement_str(statement, "sessionIndex")?
                .map(SessionIndex::try_new)
                .transpose()?;
            let authn_instant = optional_authn_statement_str(statement, "authnInstant")?
                .map(SamlInstant::try_new)
                .transpose()?;
            let not_on_or_after = optional_authn_statement_str(statement, "sessionNotOnOrAfter")?
                .map(SamlInstant::try_new)
                .transpose()?;
            Ok(AuthnSession::new(
                session_index,
                authn_instant,
                not_on_or_after,
            ))
        })
        .collect()
}

pub(super) fn entity_ids_from_value(value: Option<&Value>) -> Result<Vec<EntityId>, SamlError> {
    let Some(value) = value else {
        return Ok(Vec::new());
    };
    value_strings(value)
        .into_iter()
        .map(EntityId::try_new)
        .collect()
}

pub(super) fn session_indexes_from_value(
    value: Option<&Value>,
) -> Result<Vec<SessionIndex>, SamlError> {
    let Some(value) = value else {
        return Ok(Vec::new());
    };
    value_strings(value)
        .into_iter()
        .map(SessionIndex::try_new)
        .collect()
}