saml-rs 0.3.0

Pure-Rust SAML 2.0 Service Provider and Identity Provider support.
Documentation
use crate::constants::{namespace, ParserType};
use crate::error::SamlError;
use crate::xml::dom::XmlLimits;
use quick_xml::events::{BytesStart, Event};
use quick_xml::name::ResolveResult;
use quick_xml::{NsReader, XmlVersion};

const XML_ENCRYPTION_NS: &[u8] = b"http://www.w3.org/2001/04/xmlenc#";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NamespaceKind {
    Protocol,
    Assertion,
    Dsig,
    XmlEncryption,
    Unbound,
    Other,
    Unknown,
}

#[derive(Debug)]
struct ExpandedName {
    local: Vec<u8>,
    namespace: NamespaceKind,
}

impl ExpandedName {
    fn is(&self, local: &[u8], namespace: NamespaceKind) -> bool {
        self.local == local && self.namespace == namespace
    }
}

fn classify_namespace(resolved: ResolveResult<'_>) -> NamespaceKind {
    match resolved {
        ResolveResult::Bound(value) if value.as_ref() == namespace::PROTOCOL.as_bytes() => {
            NamespaceKind::Protocol
        }
        ResolveResult::Bound(value) if value.as_ref() == namespace::ASSERTION.as_bytes() => {
            NamespaceKind::Assertion
        }
        ResolveResult::Bound(value) if value.as_ref() == namespace::DSIG.as_bytes() => {
            NamespaceKind::Dsig
        }
        ResolveResult::Bound(value) if value.as_ref() == XML_ENCRYPTION_NS => {
            NamespaceKind::XmlEncryption
        }
        ResolveResult::Bound(_) => NamespaceKind::Other,
        ResolveResult::Unbound => NamespaceKind::Unbound,
        ResolveResult::Unknown(_) => NamespaceKind::Unknown,
    }
}

fn profile_error(message: impl Into<String>) -> SamlError {
    SamlError::ProtocolProfile(message.into())
}

fn parser_root(parser_type: ParserType) -> &'static [u8] {
    match parser_type {
        ParserType::SamlRequest => b"AuthnRequest",
        ParserType::SamlResponse => b"Response",
        ParserType::LogoutRequest => b"LogoutRequest",
        ParserType::LogoutResponse => b"LogoutResponse",
    }
}

fn element_label(element: &BytesStart<'_>) -> String {
    String::from_utf8_lossy(element.local_name().as_ref()).into_owned()
}

fn validate_unqualified_attributes(
    reader: &NsReader<&[u8]>,
    element: &BytesStart<'_>,
    consumed: &[&[u8]],
    required: &[&[u8]],
) -> Result<Vec<(Vec<u8>, String)>, SamlError> {
    let mut values = Vec::new();
    for attribute in element.attributes() {
        let attribute = attribute.map_err(|error| SamlError::Xml(error.to_string()))?;
        let (resolved, local) = reader.resolver().resolve_attribute(attribute.key);
        if !consumed.contains(&local.as_ref()) {
            continue;
        }
        if !matches!(resolved, ResolveResult::Unbound) {
            return Err(profile_error(format!(
                "attribute {} on {} must be unqualified",
                String::from_utf8_lossy(local.as_ref()),
                element_label(element),
            )));
        }
        let value = attribute
            .decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
            .map_err(|error| SamlError::Xml(error.to_string()))?
            .into_owned();
        values.push((local.as_ref().to_vec(), value));
    }

    for required_name in required {
        if !values.iter().any(|(name, _)| name == required_name) {
            return Err(profile_error(format!(
                "{} is missing required unqualified attribute {}",
                element_label(element),
                String::from_utf8_lossy(required_name),
            )));
        }
    }
    Ok(values)
}

fn require_version_2(
    values: &[(Vec<u8>, String)],
    element: &BytesStart<'_>,
) -> Result<(), SamlError> {
    let version = values
        .iter()
        .find_map(|(name, value)| (name == b"Version").then_some(value.as_str()));
    if version != Some("2.0") {
        return Err(profile_error(format!(
            "{} Version must be 2.0",
            element_label(element),
        )));
    }
    Ok(())
}

fn root_consumed_attributes(parser_type: ParserType) -> &'static [&'static [u8]] {
    match parser_type {
        ParserType::SamlRequest => &[
            b"ID",
            b"Version",
            b"IssueInstant",
            b"Destination",
            b"AssertionConsumerServiceURL",
            b"ProtocolBinding",
            b"AssertionConsumerServiceIndex",
        ],
        ParserType::SamlResponse => &[
            b"ID",
            b"Version",
            b"IssueInstant",
            b"Destination",
            b"InResponseTo",
        ],
        ParserType::LogoutRequest => &[b"ID", b"Version", b"IssueInstant", b"Destination"],
        ParserType::LogoutResponse => &[
            b"ID",
            b"Version",
            b"IssueInstant",
            b"Destination",
            b"InResponseTo",
        ],
    }
}

fn validate_root(
    reader: &NsReader<&[u8]>,
    element: &BytesStart<'_>,
    element_namespace: NamespaceKind,
    parser_type: ParserType,
) -> Result<(), SamlError> {
    let expected = parser_root(parser_type);
    if element.local_name().as_ref() != expected || element_namespace != NamespaceKind::Protocol {
        return Err(profile_error(format!(
            "expected root {{{}}}{}, got {}",
            namespace::PROTOCOL,
            String::from_utf8_lossy(expected),
            element_label(element),
        )));
    }
    let attributes = validate_unqualified_attributes(
        reader,
        element,
        root_consumed_attributes(parser_type),
        &[b"ID", b"Version"],
    )?;
    require_version_2(&attributes, element)
}

fn expected_child_namespace(stack: &[ExpandedName], child: &[u8]) -> Option<NamespaceKind> {
    let parent = stack.last()?;
    let root = stack.first()?;

    if stack.len() == 1 && parent.namespace == NamespaceKind::Protocol {
        if matches!(child, b"Issuer") {
            return Some(NamespaceKind::Assertion);
        }
        if matches!(child, b"Signature") {
            return Some(NamespaceKind::Dsig);
        }
        if root.is(b"AuthnRequest", NamespaceKind::Protocol) {
            return match child {
                b"NameIDPolicy" | b"RequestedAuthnContext" => Some(NamespaceKind::Protocol),
                b"AuthnContextClassRef" => Some(NamespaceKind::Assertion),
                _ => None,
            };
        }
        if root.is(b"Response", NamespaceKind::Protocol) {
            return match child {
                b"Status" => Some(NamespaceKind::Protocol),
                b"Assertion" | b"EncryptedAssertion" => Some(NamespaceKind::Assertion),
                _ => None,
            };
        }
        if root.is(b"LogoutRequest", NamespaceKind::Protocol) {
            return match child {
                b"NameID" => Some(NamespaceKind::Assertion),
                b"SessionIndex" => Some(NamespaceKind::Protocol),
                _ => None,
            };
        }
        if root.is(b"LogoutResponse", NamespaceKind::Protocol) && child == b"Status" {
            return Some(NamespaceKind::Protocol);
        }
    }

    if parent.is(b"Status", NamespaceKind::Protocol)
        || parent.is(b"StatusCode", NamespaceKind::Protocol)
    {
        return (child == b"StatusCode").then_some(NamespaceKind::Protocol);
    }
    if parent.is(b"RequestedAuthnContext", NamespaceKind::Protocol) {
        return (child == b"AuthnContextClassRef").then_some(NamespaceKind::Assertion);
    }
    if parent.is(b"Assertion", NamespaceKind::Assertion) {
        return match child {
            b"Signature" => Some(NamespaceKind::Dsig),
            b"Issuer" | b"Subject" | b"Conditions" | b"AuthnStatement" | b"AttributeStatement" => {
                Some(NamespaceKind::Assertion)
            }
            _ => None,
        };
    }
    if parent.is(b"Subject", NamespaceKind::Assertion) {
        return match child {
            b"NameID" | b"SubjectConfirmation" => Some(NamespaceKind::Assertion),
            _ => None,
        };
    }
    if parent.is(b"SubjectConfirmation", NamespaceKind::Assertion) {
        return (child == b"SubjectConfirmationData").then_some(NamespaceKind::Assertion);
    }
    if parent.is(b"Conditions", NamespaceKind::Assertion) {
        return (child == b"AudienceRestriction").then_some(NamespaceKind::Assertion);
    }
    if parent.is(b"AudienceRestriction", NamespaceKind::Assertion) {
        return (child == b"Audience").then_some(NamespaceKind::Assertion);
    }
    if parent.is(b"AttributeStatement", NamespaceKind::Assertion) {
        return (child == b"Attribute").then_some(NamespaceKind::Assertion);
    }
    if parent.is(b"Attribute", NamespaceKind::Assertion) {
        return (child == b"AttributeValue").then_some(NamespaceKind::Assertion);
    }
    if parent.is(b"EncryptedAssertion", NamespaceKind::Assertion) {
        return (child == b"EncryptedData").then_some(NamespaceKind::XmlEncryption);
    }
    None
}

fn consumed_attributes(element: &ExpandedName) -> &'static [&'static [u8]] {
    match (element.namespace, element.local.as_slice()) {
        (NamespaceKind::Assertion, b"Assertion") => &[b"ID", b"Version", b"IssueInstant"],
        (NamespaceKind::Protocol, b"StatusCode") => &[b"Value"],
        (NamespaceKind::Protocol, b"NameIDPolicy") => &[b"Format", b"AllowCreate"],
        (NamespaceKind::Assertion, b"Conditions") => &[b"NotBefore", b"NotOnOrAfter"],
        (NamespaceKind::Assertion, b"NameID") => &[b"Format"],
        (NamespaceKind::Assertion, b"SubjectConfirmation") => &[b"Method"],
        (NamespaceKind::Assertion, b"SubjectConfirmationData") => {
            &[b"NotOnOrAfter", b"Recipient", b"InResponseTo"]
        }
        (NamespaceKind::Assertion, b"AuthnStatement") => {
            &[b"AuthnInstant", b"SessionNotOnOrAfter", b"SessionIndex"]
        }
        (NamespaceKind::Assertion, b"Attribute") => &[b"Name"],
        _ => &[],
    }
}

fn validate_element(
    reader: &NsReader<&[u8]>,
    element: &BytesStart<'_>,
    element_namespace: NamespaceKind,
    stack: &[ExpandedName],
    parser_type: ParserType,
) -> Result<(), SamlError> {
    if stack.is_empty() {
        return validate_root(reader, element, element_namespace, parser_type);
    }

    let local = element.local_name();
    let expected_namespace = expected_child_namespace(stack, local.as_ref());
    if let Some(expected) = expected_namespace {
        if element_namespace != expected {
            return Err(profile_error(format!(
                "{} has an invalid namespace",
                element_label(element),
            )));
        }
    }
    if expected_namespace.is_none() {
        return Ok(());
    }

    let expanded = ExpandedName {
        local: local.as_ref().to_vec(),
        namespace: element_namespace,
    };
    let consumed = consumed_attributes(&expanded);
    if expanded.is(b"Assertion", NamespaceKind::Assertion) {
        let attributes =
            validate_unqualified_attributes(reader, element, consumed, &[b"ID", b"Version"])?;
        require_version_2(&attributes, element)?;
    } else if !consumed.is_empty() {
        validate_unqualified_attributes(reader, element, consumed, &[])?;
    }
    Ok(())
}

pub(crate) fn validate_protocol_profile(
    xml: &str,
    parser_type: ParserType,
    limits: XmlLimits,
) -> Result<(), SamlError> {
    limits.check_input_bytes(xml.len())?;
    let mut reader = NsReader::from_str(xml);
    reader
        .resolver_mut()
        .set_max_declarations_per_element(limits.max_attributes_per_element);
    let mut stack = Vec::new();
    let mut saw_root = false;

    loop {
        let (resolved, event) = reader
            .read_resolved_event()
            .map_err(|error| SamlError::Xml(error.to_string()))?;
        let element_namespace = classify_namespace(resolved);
        match event {
            Event::Start(element) => {
                validate_element(&reader, &element, element_namespace, &stack, parser_type)?;
                saw_root = true;
                stack.push(ExpandedName {
                    local: element.local_name().as_ref().to_vec(),
                    namespace: element_namespace,
                });
            }
            Event::Empty(element) => {
                validate_element(&reader, &element, element_namespace, &stack, parser_type)?;
                saw_root = true;
            }
            Event::End(_) => {
                stack.pop();
            }
            Event::Eof => break,
            Event::Decl(_)
            | Event::Text(_)
            | Event::CData(_)
            | Event::Comment(_)
            | Event::PI(_)
            | Event::DocType(_)
            | Event::GeneralRef(_) => {}
        }
    }

    if !saw_root {
        return Err(profile_error("missing SAML protocol root"));
    }
    Ok(())
}