Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-auth-saml/src/sp/mod.rs

//! # `sp` — a minimal, strict, pure-Rust SAML 2.0 Service Provider
//!
//! The HTTP-POST binding only: the IdP's browser-posted `SAMLResponse` is
//! verified here, and the SP's `AuthnRequest` goes out over HTTP-Redirect.
//!
//! ## Why not samael, why not a general XML-security library
//!
//! * **samael** links `xmlsec1`, `libxml2` and OpenSSL — C, built against
//!   system packages. A guarded, static appliance cannot take that: gunnar's
//!   dependency guard forbids `openssl` everywhere, and its server binary is
//!   one static file. (This crate's samael path is deleted, A34-14.)
//! * **bergshamra** (pure Rust) is a broad, eight-month-old, single-maintainer
//!   framework — PKCS#12, XML Encryption, HSMs, post-quantum algorithms — with
//!   far more surface than an SP needs, all of which would have to be trusted.
//!
//! An SP needs a small part of XML-DSig: ONE enveloped signature, exclusive
//! C14N, RSA-SHA2 or ECDSA-P-256, verified against a certificate it was
//! configured with. That is what this module is, and it is checked byte for
//! byte against libxml2's canonical form (`tests/sp_interop.rs`, fixtures signed
//! by signxml).
//!
//! ## What a response must be (each rule has a test)
//!
//! * no DOCTYPE, at most [`MAX_RESPONSE_BYTES`]; a `samlp:Response`, Version
//!   2.0, `StatusCode` Success; IDs unique in the document;
//! * **exactly ONE `saml:Assertion` in the whole document**, a direct child of
//!   the Response (no `EncryptedAssertion`);
//! * the Assertion covered by a VALID signature: a `ds:Signature` that is a
//!   direct child of the Assertion (Reference `#<Assertion ID>`) or of the
//!   Response (Reference `#<Response ID>`); every `ds:Signature` present must be
//!   one of those two and must verify; the key is ONLY a configured
//!   certificate, never the message's `KeyInfo`;
//! * every value — Issuer, NameID, conditions, attributes — read from the
//!   verified Assertion node itself (signature wrapping cannot point the reader
//!   elsewhere), text as ALL of an element's text (a comment cannot split it);
//! * Issuer = the IdP, Destination = the ACS URL (required on a signed
//!   Response, optional when only the Assertion is signed), a bearer
//!   `SubjectConfirmationData` with Recipient = the ACS URL, unexpired, and
//!   `InResponseTo` = the request (when one was sent); Conditions in their
//!   window and the SP in every `AudienceRestriction`; clock skew as given,
//!   at most [`MAX_SKEW_SECS`].
//!
//! The caller keeps the replay cache: [`Assertion::assertion_id`] until
//! [`Assertion::not_on_or_after_unix`].

use std::collections::BTreeMap;
use std::fmt;

use base64::Engine as _;
use roxmltree::{Document, Node, ParsingOptions};

pub mod c14n;
pub(crate) mod dsig;
mod time;

pub use time::{format_instant, parse_instant};

pub(crate) const PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
pub(crate) const ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
pub(crate) const METADATA: &str = "urn:oasis:names:tc:SAML:2.0:metadata";
pub(crate) const DSIG: &str = "http://www.w3.org/2000/09/xmldsig#";
const SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
const BEARER: &str = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
const HTTP_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
const HTTP_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";

/// The largest decoded `SAMLResponse` accepted.
pub const MAX_RESPONSE_BYTES: usize = 512 * 1024;
/// The largest node count a parsed response may have.
const MAX_NODES: u32 = 50_000;
/// The longest `RelayState` the binding allows.
pub const MAX_RELAY_STATE: usize = 80;
/// The most clock skew honoured, whatever [`Expect::skew_secs`] asks for
/// (A34-35; OIDC clamps to the same).
pub const MAX_SKEW_SECS: i64 = 300;

/// Why a response, a metadata document or a certificate was refused. The
/// words never carry a secret; they may name IDs, issuers and algorithms.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpError {
    /// Not the shape a SAML message must have.
    Malformed(String),
    /// A signature is missing, does not cover the assertion, or does not verify.
    Signature(String),
    /// Signed, but not for this SP, not now, or not from this IdP.
    Condition(String),
    /// A shape or algorithm this SP deliberately does not accept.
    Unsupported(String),
    /// The IdP answered with a non-Success status. The words are fixed, by
    /// the status code only: the unsigned StatusMessage is never carried.
    Status(String),
}

impl fmt::Display for SpError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SpError::Malformed(m) => write!(f, "malformed SAML: {m}"),
            SpError::Signature(m) => write!(f, "SAML signature refused: {m}"),
            SpError::Condition(m) => write!(f, "SAML assertion refused: {m}"),
            SpError::Unsupported(m) => write!(f, "unsupported SAML: {m}"),
            SpError::Status(m) => write!(f, "the IdP did not sign the user in: {m}"),
        }
    }
}

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

/// What an IdP's metadata says, as far as an SP needs it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdpMetadata {
    pub entity_id: String,
    /// The `SingleSignOnService` for the HTTP-Redirect binding.
    pub sso_redirect_url: String,
    /// The signing certificates (DER), each with a supported key.
    pub certificates_der: Vec<Vec<u8>>,
}

/// What the SP expects of one response.
#[derive(Debug, Clone)]
pub struct Expect<'a> {
    pub idp_entity_id: &'a str,
    /// The audience: the SP's entity id.
    pub sp_entity_id: &'a str,
    /// The Assertion Consumer Service URL: the Destination and the Recipient.
    pub acs_url: &'a str,
    /// The `AuthnRequest` ID this response must answer; `None` accepts an
    /// IdP-initiated response (any or no `InResponseTo`).
    pub request_id: Option<&'a str>,
    pub now_unix: i64,
    /// Clock skew allowed on every time check, clamped to
    /// `0..=`[`MAX_SKEW_SECS`].
    pub skew_secs: i64,
    /// The IdP's signing certificates (DER).
    pub certificates_der: &'a [Vec<u8>],
}

/// A verified assertion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Assertion {
    pub assertion_id: String,
    pub issuer: String,
    pub name_id: String,
    pub name_id_format: Option<String>,
    pub session_index: Option<String>,
    /// Attribute `Name` → its values, in document order.
    pub attributes: BTreeMap<String, Vec<String>>,
    /// The earliest `NotOnOrAfter` that applies: keep the ID in a replay cache
    /// until then.
    pub not_on_or_after_unix: i64,
}

// ── small tree helpers ─────────────────────────────────────────────────────

pub(crate) fn child_elements<'a, 'i>(node: Node<'a, 'i>) -> impl Iterator<Item = Node<'a, 'i>> {
    node.children().filter(Node::is_element)
}

fn is(node: &Node, ns: &str, local: &str) -> bool {
    node.is_element() && node.tag_name().namespace() == Some(ns) && node.tag_name().name() == local
}

fn children<'a, 'i>(
    node: Node<'a, 'i>,
    ns: &'a str,
    local: &'a str,
) -> impl Iterator<Item = Node<'a, 'i>> + 'a {
    child_elements(node).filter(move |n| is(n, ns, local))
}

/// The one direct child `ns:local`; none or several is malformed.
fn only_child<'a, 'i>(
    node: Node<'a, 'i>,
    ns: &'a str,
    local: &'a str,
) -> Result<Node<'a, 'i>, SpError> {
    let mut it = children(node, ns, local);
    let first = it.next().ok_or_else(|| {
        SpError::Malformed(format!("<{}> has no <{local}>", node.tag_name().name()))
    })?;
    if it.next().is_some() {
        return Err(SpError::Malformed(format!(
            "<{}> has more than one <{local}>",
            node.tag_name().name()
        )));
    }
    Ok(first)
}

fn optional_child<'a, 'i>(
    node: Node<'a, 'i>,
    ns: &'a str,
    local: &'a str,
) -> Result<Option<Node<'a, 'i>>, SpError> {
    let mut it = children(node, ns, local);
    let first = it.next();
    if it.next().is_some() {
        return Err(SpError::Malformed(format!(
            "<{}> has more than one <{local}>",
            node.tag_name().name()
        )));
    }
    Ok(first)
}

/// ALL of an element's text, in document order — never just the first text
/// node, so `a@evil<!---->.example` reads as the signed `a@evil.example`.
pub(crate) fn text_of(node: Node) -> String {
    node.descendants()
        .filter(Node::is_text)
        .filter_map(|n| n.text())
        .collect()
}

fn refuse_doctype(xml: &str) -> Result<(), SpError> {
    if xml.contains("<!DOCTYPE") || xml.contains("<!ENTITY") {
        return Err(SpError::Malformed(
            "a DOCTYPE or ENTITY declaration is refused".into(),
        ));
    }
    Ok(())
}

fn parse(xml: &str) -> Result<Document<'_>, SpError> {
    refuse_doctype(xml)?;
    Document::parse_with_options(
        xml,
        ParsingOptions {
            allow_dtd: false,
            nodes_limit: MAX_NODES,
        },
    )
    .map_err(|e| SpError::Malformed(format!("not well-formed XML: {e}")))
}

fn instant(node: Node, attr: &str) -> Result<Option<i64>, SpError> {
    node.attribute(attr).map(parse_instant).transpose()
}

// ── the response ───────────────────────────────────────────────────────────

/// Verify a browser-posted `SAMLResponse` (base64; whitespace tolerated).
pub fn verify_response_b64(saml_response_b64: &str, expect: &Expect) -> Result<Assertion, SpError> {
    if saml_response_b64.len() > MAX_RESPONSE_BYTES * 4 / 3 + 4096 {
        return Err(SpError::Malformed("the SAMLResponse is too large".into()));
    }
    let raw = dsig::b64(saml_response_b64, "the SAMLResponse")?;
    if raw.len() > MAX_RESPONSE_BYTES {
        return Err(SpError::Malformed("the SAMLResponse is too large".into()));
    }
    let xml = String::from_utf8(raw)
        .map_err(|_| SpError::Malformed("the SAMLResponse is not UTF-8".into()))?;
    verify_response_xml(&xml, expect)
}

/// Verify a `samlp:Response` document.
pub fn verify_response_xml(xml: &str, expect: &Expect) -> Result<Assertion, SpError> {
    if xml.len() > MAX_RESPONSE_BYTES {
        return Err(SpError::Malformed("the SAMLResponse is too large".into()));
    }
    let doc = parse(xml)?;
    let response = doc.root_element();
    if !is(&response, PROTOCOL, "Response") {
        return Err(SpError::Malformed(format!(
            "the document is <{}>, not a samlp:Response",
            response.tag_name().name()
        )));
    }
    if response.attribute("Version") != Some("2.0") {
        return Err(SpError::Malformed(
            "the Response is not SAML Version 2.0".into(),
        ));
    }

    // IDs are unique across the whole document: a duplicate is how a wrapped
    // copy and the original would be told apart by a sloppier resolver.
    let mut ids = std::collections::BTreeSet::new();
    for node in doc.descendants().filter(Node::is_element) {
        if let Some(id) = node.attribute("ID") {
            if !ids.insert(id) {
                return Err(SpError::Malformed(format!("the ID {id:?} appears twice")));
            }
        }
    }

    // The status, before anything else is looked at: a refusal is an answer.
    let status = only_child(response, PROTOCOL, "Status")?;
    let code = only_child(status, PROTOCOL, "StatusCode")?;
    let value = code.attribute("Value").unwrap_or("");
    if value != SUCCESS {
        // The Status is not signed: its StatusMessage (and any URI that is no
        // SAML status) is whatever the poster wrote, so only a known code's
        // fixed name reaches the words an admin or a user sees (A34-36).
        let second = children(code, PROTOCOL, "StatusCode")
            .next()
            .and_then(|c| c.attribute("Value"));
        return Err(SpError::Status(status_words(value, second)));
    }

    if doc
        .descendants()
        .any(|n| is(&n, ASSERTION, "EncryptedAssertion"))
    {
        return Err(SpError::Unsupported(
            "encrypted assertions are not accepted".into(),
        ));
    }
    let assertions: Vec<Node> = doc
        .descendants()
        .filter(|n| is(n, ASSERTION, "Assertion"))
        .collect();
    let assertion = match assertions.as_slice() {
        [one] => *one,
        [] => {
            return Err(SpError::Malformed(
                "the Response carries no Assertion".into(),
            ))
        }
        more => {
            return Err(SpError::Malformed(format!(
                "the document carries {} Assertions; exactly one is accepted",
                more.len()
            )))
        }
    };
    if assertion.parent_element() != Some(response) {
        return Err(SpError::Malformed(
            "the Assertion is not a direct child of the Response".into(),
        ));
    }

    // Every signature in the document is one of exactly two, and verifies.
    let signatures: Vec<Node> = doc
        .descendants()
        .filter(|n| is(n, DSIG, "Signature"))
        .collect();
    let mut on_response = 0;
    let mut on_assertion = 0;
    for sig in &signatures {
        let parent = sig.parent_element();
        if parent == Some(response) {
            on_response += 1;
        } else if parent == Some(assertion) {
            on_assertion += 1;
        } else {
            return Err(SpError::Malformed(
                "a ds:Signature sits somewhere other than on the Response or the Assertion".into(),
            ));
        }
    }
    if on_response > 1 || on_assertion > 1 {
        return Err(SpError::Malformed(
            "an element carries more than one ds:Signature".into(),
        ));
    }
    if signatures.is_empty() {
        return Err(SpError::Signature(
            "neither the Response nor the Assertion is signed".into(),
        ));
    }
    for sig in &signatures {
        let signed = sig.parent_element().expect("checked above");
        dsig::verify_enveloped(&doc, signed, *sig, expect.certificates_der)?;
    }

    read_assertion(response, assertion, on_response == 1, expect)
}

/// The SAML 2.0 status codes (core 3.2.2.2): top level, then second level.
const TOP_STATUS: [&str; 3] = ["Requester", "Responder", "VersionMismatch"];
const SECOND_STATUS: [&str; 19] = [
    "AuthnFailed",
    "InvalidAttrNameOrValue",
    "InvalidNameIDPolicy",
    "NoAuthnContext",
    "NoAvailableIDP",
    "NoPassive",
    "NoSupportedIDP",
    "PartialLogout",
    "ProxyCountExceeded",
    "RequestDenied",
    "RequestUnsupported",
    "RequestVersionDeprecated",
    "RequestVersionTooHigh",
    "RequestVersionTooLow",
    "ResourceNotRecognized",
    "TooManyResponses",
    "UnknownAttrProfile",
    "UnknownPrincipal",
    "UnsupportedBinding",
];

/// A status URI's fixed short name when it is one of `known`, else `None`.
fn known_status(value: &str, known: &[&'static str]) -> Option<&'static str> {
    let name = value.strip_prefix("urn:oasis:names:tc:SAML:2.0:status:")?;
    known.iter().copied().find(|k| *k == name)
}

/// The fixed words for a refusal status: the IdP refused, and its code.
fn status_words(top: &str, second: Option<&str>) -> String {
    let Some(top) = known_status(top, &TOP_STATUS) else {
        return "the IdP refused the sign-in with an unknown status".into();
    };
    match second.and_then(|s| known_status(s, &SECOND_STATUS)) {
        Some(second) => format!("the IdP refused the sign-in (status {top}, {second})"),
        None => format!("the IdP refused the sign-in (status {top})"),
    }
}

/// Every value out of the VERIFIED assertion node.
fn read_assertion(
    response: Node,
    assertion: Node,
    response_signed: bool,
    expect: &Expect,
) -> Result<Assertion, SpError> {
    let now = expect.now_unix;
    let skew = expect.skew_secs.clamp(0, MAX_SKEW_SECS);
    if assertion.attribute("Version") != Some("2.0") {
        return Err(SpError::Malformed(
            "the Assertion is not SAML Version 2.0".into(),
        ));
    }
    let assertion_id = assertion.attribute("ID").unwrap_or("").to_owned();
    if assertion_id.is_empty() {
        return Err(SpError::Malformed("the Assertion has no ID".into()));
    }

    let issuer = text_of(only_child(assertion, ASSERTION, "Issuer")?)
        .trim()
        .to_owned();
    if issuer != expect.idp_entity_id {
        return Err(SpError::Condition(format!(
            "the assertion was issued by {issuer:?}, not by {:?}",
            expect.idp_entity_id
        )));
    }
    if let Some(outer) = optional_child(response, ASSERTION, "Issuer")? {
        let outer = text_of(outer).trim().to_owned();
        if outer != expect.idp_entity_id {
            return Err(SpError::Condition(format!(
                "the Response was issued by {outer:?}, not by the IdP"
            )));
        }
    }
    match response.attribute("Destination") {
        Some(destination) if destination != expect.acs_url => {
            return Err(SpError::Condition(format!(
                "the Response is for {destination:?}, not for this SP's ACS URL"
            )));
        }
        Some(_) => {}
        // SAML profiles 4.1.4.5: a signed Response carries a Destination
        // (A34-35); an Assertion signed alone may leave it out.
        None if response_signed => {
            return Err(SpError::Condition(
                "the Response is signed but names no Destination".into(),
            ));
        }
        None => {}
    }

    // ── the subject ──────────────────────────────────────────────────────
    let subject = only_child(assertion, ASSERTION, "Subject")?;
    if child_elements(subject)
        .any(|n| is(&n, ASSERTION, "EncryptedID") || is(&n, ASSERTION, "BaseID"))
    {
        return Err(SpError::Unsupported(
            "only a plain NameID subject is accepted".into(),
        ));
    }
    let name_id_node = only_child(subject, ASSERTION, "NameID")?;
    if child_elements(name_id_node).next().is_some() {
        return Err(SpError::Malformed("the NameID holds elements".into()));
    }
    let name_id = text_of(name_id_node).trim().to_owned();
    if name_id.is_empty() {
        return Err(SpError::Malformed("the NameID is empty".into()));
    }
    let name_id_format = name_id_node.attribute("Format").map(str::to_owned);

    let mut confirmed: Option<i64> = None;
    let mut why = "the subject has no bearer SubjectConfirmation".to_owned();
    for confirmation in children(subject, ASSERTION, "SubjectConfirmation") {
        if confirmation.attribute("Method") != Some(BEARER) {
            continue;
        }
        let Some(data) = optional_child(confirmation, ASSERTION, "SubjectConfirmationData")? else {
            why = "the bearer confirmation carries no SubjectConfirmationData".into();
            continue;
        };
        match confirm(data, expect, now, skew)? {
            Ok(until) => {
                confirmed = Some(until);
                break;
            }
            Err(e) => why = e,
        }
    }
    let Some(confirmed_until) = confirmed else {
        return Err(SpError::Condition(why));
    };

    // ── the conditions ───────────────────────────────────────────────────
    let conditions = only_child(assertion, ASSERTION, "Conditions")?;
    if let Some(not_before) = instant(conditions, "NotBefore")? {
        if not_before > now + skew {
            return Err(SpError::Condition(
                "the assertion is not valid yet (Conditions NotBefore)".into(),
            ));
        }
    }
    let mut until = confirmed_until;
    if let Some(not_after) = instant(conditions, "NotOnOrAfter")? {
        if not_after <= now - skew {
            return Err(SpError::Condition(
                "the assertion has expired (Conditions NotOnOrAfter)".into(),
            ));
        }
        until = until.min(not_after);
    }
    let restrictions: Vec<Node> = children(conditions, ASSERTION, "AudienceRestriction").collect();
    if restrictions.is_empty() {
        return Err(SpError::Condition("the assertion names no audience".into()));
    }
    for restriction in restrictions {
        if !children(restriction, ASSERTION, "Audience")
            .any(|a| text_of(a).trim() == expect.sp_entity_id)
        {
            return Err(SpError::Condition(format!(
                "the assertion is not for this SP: {:?} is not in its audience",
                expect.sp_entity_id
            )));
        }
    }

    let session_index = children(assertion, ASSERTION, "AuthnStatement")
        .next()
        .and_then(|s| s.attribute("SessionIndex"))
        .map(str::to_owned);

    let mut attributes: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for statement in children(assertion, ASSERTION, "AttributeStatement") {
        for attribute in children(statement, ASSERTION, "Attribute") {
            let Some(name) = attribute.attribute("Name") else {
                continue;
            };
            let values = attributes.entry(name.to_owned()).or_default();
            for value in children(attribute, ASSERTION, "AttributeValue") {
                values.push(text_of(value).trim().to_owned());
            }
        }
    }

    Ok(Assertion {
        assertion_id,
        issuer,
        name_id,
        name_id_format,
        session_index,
        attributes,
        not_on_or_after_unix: until,
    })
}

/// A `SubjectConfirmationData` time: a malformed one refuses the whole
/// response by name, as a malformed Conditions time does (A34-34).
fn confirmation_instant(data: Node, attr: &str) -> Result<Option<i64>, SpError> {
    data.attribute(attr)
        .map(|text| {
            parse_instant(text).map_err(|e| {
                SpError::Malformed(format!("the SubjectConfirmationData {attr}: {e}"))
            })
        })
        .transpose()
}

/// One bearer `SubjectConfirmationData`: its NotOnOrAfter when it holds,
/// why not when it does not (another confirmation may still hold), and a
/// hard refusal when it is malformed.
fn confirm(
    data: Node,
    expect: &Expect,
    now: i64,
    skew: i64,
) -> Result<Result<i64, String>, SpError> {
    let not_on_or_after = confirmation_instant(data, "NotOnOrAfter")?;
    let not_before = confirmation_instant(data, "NotBefore")?;
    let recipient = data.attribute("Recipient").unwrap_or("");
    if recipient != expect.acs_url {
        return Ok(Err(format!(
            "the bearer confirmation is for {recipient:?}, not for this SP's ACS URL"
        )));
    }
    let Some(until) = not_on_or_after else {
        return Ok(Err("the bearer confirmation has no NotOnOrAfter".into()));
    };
    if until <= now - skew {
        return Ok(Err(
            "the assertion has expired (SubjectConfirmationData NotOnOrAfter)".into(),
        ));
    }
    if let Some(not_before) = not_before {
        if not_before > now + skew {
            return Ok(Err("the bearer confirmation is not valid yet".into()));
        }
    }
    if let Some(request) = expect.request_id {
        match data.attribute("InResponseTo") {
            Some(answered) if answered == request => {}
            Some(answered) => {
                return Ok(Err(format!(
                    "the assertion answers {answered:?}, not this sign-in's request"
                )))
            }
            None => {
                return Ok(Err(
                    "the assertion answers no request, and this sign-in sent one".into(),
                ))
            }
        }
    }
    Ok(Ok(until))
}

// ── metadata, certificates, the request ────────────────────────────────────

/// An IdP's SAML metadata: its entity id, its HTTP-Redirect SSO endpoint and
/// its signing certificates. The metadata's own signature (if any) is not
/// checked: the operator vouches for it by pasting it, or it came over HTTPS.
pub fn parse_idp_metadata(xml: &str) -> Result<IdpMetadata, SpError> {
    let doc = parse(xml)?;
    let entities: Vec<Node> = doc
        .descendants()
        .filter(|n| is(n, METADATA, "EntityDescriptor"))
        .filter(|n| children(*n, METADATA, "IDPSSODescriptor").next().is_some())
        .collect();
    let entity = match entities.as_slice() {
        [one] => *one,
        [] => {
            return Err(SpError::Malformed(
                "the metadata describes no IdP (IDPSSODescriptor)".into(),
            ))
        }
        _ => {
            return Err(SpError::Malformed(
                "the metadata describes several IdPs; give the metadata of the one".into(),
            ))
        }
    };
    let entity_id = entity.attribute("entityID").unwrap_or("").trim().to_owned();
    if entity_id.is_empty() {
        return Err(SpError::Malformed(
            "the IdP's EntityDescriptor has no entityID".into(),
        ));
    }
    let idp = children(entity, METADATA, "IDPSSODescriptor")
        .next()
        .expect("filtered above");
    let mut certificates_der: Vec<Vec<u8>> = Vec::new();
    for key in children(idp, METADATA, "KeyDescriptor") {
        if !matches!(key.attribute("use"), None | Some("signing")) {
            continue;
        }
        for cert in key.descendants().filter(|n| is(n, DSIG, "X509Certificate")) {
            let der = dsig::b64(&text_of(cert), "an X509Certificate")?;
            dsig::public_key(&der)?;
            if !certificates_der.contains(&der) {
                certificates_der.push(der);
            }
        }
    }
    if certificates_der.is_empty() {
        return Err(SpError::Malformed(
            "the metadata carries no signing certificate".into(),
        ));
    }
    let sso_redirect_url = children(idp, METADATA, "SingleSignOnService")
        .find(|s| s.attribute("Binding") == Some(HTTP_REDIRECT))
        .and_then(|s| s.attribute("Location"))
        .map(|l| l.trim().to_owned())
        .filter(|l| !l.is_empty())
        .ok_or_else(|| {
            SpError::Unsupported("the IdP offers no HTTP-Redirect SingleSignOnService".into())
        })?;
    Ok(IdpMetadata {
        entity_id,
        sso_redirect_url,
        certificates_der,
    })
}

/// A certificate an operator pasted — PEM, or bare base64 — as DER. It must
/// be X.509 with an RSA (≥ 2048 bits) or P-256 key.
pub fn parse_certificate_pem_or_b64(text: &str) -> Result<Vec<u8>, SpError> {
    let body: String = text
        .lines()
        .filter(|l| !l.trim_start().starts_with("-----"))
        .collect::<Vec<_>>()
        .join("");
    if body.trim().is_empty() {
        return Err(SpError::Malformed("the certificate is empty".into()));
    }
    let der = dsig::b64(&body, "the certificate")?;
    dsig::public_key(&der)?;
    Ok(der)
}

pub(crate) fn escape(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for c in text.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&apos;"),
            c => out.push(c),
        }
    }
    out
}

/// An `AuthnRequest` asking for an HTTP-POST response at `acs_url`. `id` must
/// be an XML NCName (start with a letter or `_`); it is what the response's
/// `InResponseTo` must answer.
pub fn authn_request_xml(
    id: &str,
    issue_instant: &str,
    sp_entity_id: &str,
    acs_url: &str,
    idp_sso_url: &str,
) -> String {
    format!(
        "<samlp:AuthnRequest xmlns:samlp=\"{PROTOCOL}\" xmlns:saml=\"{ASSERTION}\" ID=\"{}\" Version=\"2.0\" \
         IssueInstant=\"{}\" Destination=\"{}\" AssertionConsumerServiceURL=\"{}\" ProtocolBinding=\"{HTTP_POST}\">\
         <saml:Issuer>{}</saml:Issuer><samlp:NameIDPolicy AllowCreate=\"true\"/></samlp:AuthnRequest>",
        escape(id),
        escape(issue_instant),
        escape(idp_sso_url),
        escape(acs_url),
        escape(sp_entity_id),
    )
}

/// Percent-encode everything but the unreserved characters.
fn percent(text: &str) -> String {
    let mut out = String::with_capacity(text.len() * 3);
    for b in text.bytes() {
        if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
            out.push(b as char);
        } else {
            out.push_str(&format!("%{b:02X}"));
        }
    }
    out
}

/// The HTTP-Redirect URL that carries `authn_request_xml` to the IdP:
/// raw DEFLATE, base64, percent-encoded, with `RelayState` (at most 80 bytes).
pub fn redirect_url(
    idp_sso_url: &str,
    authn_request_xml: &str,
    relay_state: &str,
) -> Result<String, SpError> {
    use std::io::Write as _;
    if !(idp_sso_url.starts_with("https://") || idp_sso_url.starts_with("http://"))
        || idp_sso_url.contains('#')
    {
        return Err(SpError::Malformed(
            "the IdP's SSO URL must be an http(s) URL without a fragment".into(),
        ));
    }
    if relay_state.len() > MAX_RELAY_STATE {
        return Err(SpError::Malformed(format!(
            "RelayState is longer than {MAX_RELAY_STATE} bytes"
        )));
    }
    let mut deflate =
        flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
    deflate
        .write_all(authn_request_xml.as_bytes())
        .map_err(|e| SpError::Malformed(format!("deflating the request: {e}")))?;
    let packed = deflate
        .finish()
        .map_err(|e| SpError::Malformed(format!("deflating the request: {e}")))?;
    let encoded = base64::engine::general_purpose::STANDARD.encode(packed);
    let sep = if idp_sso_url.contains('?') { '&' } else { '?' };
    let mut url = format!("{idp_sso_url}{sep}SAMLRequest={}", percent(&encoded));
    if !relay_state.is_empty() {
        url.push_str("&RelayState=");
        url.push_str(&percent(relay_state));
    }
    Ok(url)
}

/// This SP's metadata, for the IdP's administrator: the entity id, and one
/// HTTP-POST Assertion Consumer Service; signed assertions wanted.
pub fn sp_metadata_xml(sp_entity_id: &str, acs_url: &str) -> String {
    format!(
        "<md:EntityDescriptor xmlns:md=\"{METADATA}\" entityID=\"{}\">\
         <md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"true\" \
         protocolSupportEnumeration=\"{PROTOCOL}\">\
         <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\
         <md:AssertionConsumerService Binding=\"{HTTP_POST}\" Location=\"{}\" index=\"0\" isDefault=\"true\"/>\
         </md:SPSSODescriptor></md:EntityDescriptor>",
        escape(sp_entity_id),
        escape(acs_url),
    )
}