saml 0.0.1-alpha.1

Stateless, async-native SAML 2.0 toolkit with no libxml2/xmlsec C build chain
Documentation
//! Build outbound `<samlp:LogoutRequest>` per SAML 2.0 Core §3.7.1.
//!
//! Used by both `ServiceProvider::start_logout` and
//! `IdentityProvider::start_logout` (RFC-007 §2 / §3). The output of
//! `build_logout_request_element` is an `Element` ready to be wrapped in
//! a `Document` (and optionally signed via `dsig::sign::sign_element`)
//! before being handed to the binding layer.
//!
//! The XML shape produced is intentionally minimal: only the schema-required
//! attributes and the optional ones the caller explicitly populated. Omitting
//! a `Reason` is legal (no default behavior on the receiver) and omitting
//! `NotOnOrAfter` means the request never expires — both are honored verbatim
//! to keep the wire form predictable.

use crate::error::Error;
use crate::logout::{SAML_NS, SAMLP_NS, saml_qname, samlp_qname};
use crate::nameid::NameId;
use crate::time::format_xs_datetime;
use crate::xml::emit::emit_document;
use crate::xml::parse::{Document, Element, Node, QName};
use std::time::SystemTime;

/// Inputs for building a `<samlp:LogoutRequest>` element.
pub(crate) struct BuildLogoutRequest<'a> {
    /// `"_<hex16>"` generated by the caller. SAML IDs must start with a
    /// non-digit; `_` is conventional.
    pub id: &'a str,
    pub issue_instant: SystemTime,
    pub issuer_entity_id: &'a str,
    /// Peer SLO endpoint URL (the receiver's `Destination`).
    pub destination: Option<&'a str>,
    /// Optional expiration. If absent the receiver should accept the request
    /// without time-bound rejection (only its own clock-skew window applies).
    pub not_on_or_after: Option<SystemTime>,
    /// Optional reason URI per SAML 2.0 Core §3.7.1.
    pub reason: Option<&'a str>,
    pub name_id: &'a NameId,
    pub session_index: Option<&'a str>,
}

/// Build the `<samlp:LogoutRequest>` element. The returned [`Element`] is NOT
/// yet wrapped in a [`Document`]; the caller wraps via `Document::new` and may
/// then call `dsig::sign::sign_element` before emitting.
pub(crate) fn build_logout_request_element(
    input: &BuildLogoutRequest<'_>,
) -> Result<Element, Error> {
    // Element ordering inside <samlp:LogoutRequest> is fixed by the schema:
    //   <saml:Issuer> → (Extensions)? → <saml:NameID|EncryptedID|BaseID>
    //   → <samlp:SessionIndex>*.
    // We don't emit Extensions / BaseID / EncryptedID at v0.1 (per RFC-007
    // out-of-scope list).

    let mut builder = Element::build(samlp_qname("LogoutRequest"))
        .with_namespace(Some("samlp".to_owned()), SAMLP_NS)
        .with_namespace(Some("saml".to_owned()), SAML_NS)
        .with_attribute(QName::new(None, "ID"), input.id.to_owned())
        .with_attribute(QName::new(None, "Version"), "2.0")
        .with_attribute(
            QName::new(None, "IssueInstant"),
            format_xs_datetime(input.issue_instant)?,
        );

    if let Some(dest) = input.destination {
        builder = builder.with_attribute(QName::new(None, "Destination"), dest.to_owned());
    }
    if let Some(not_after) = input.not_on_or_after {
        builder = builder.with_attribute(
            QName::new(None, "NotOnOrAfter"),
            format_xs_datetime(not_after)?,
        );
    }
    if let Some(reason) = input.reason {
        builder = builder.with_attribute(QName::new(None, "Reason"), reason.to_owned());
    }

    // <saml:Issuer>
    let issuer = Element::build(saml_qname("Issuer"))
        .with_text(input.issuer_entity_id.to_owned())
        .finish();
    builder = builder.with_child(Node::Element(issuer));

    // <saml:NameID Format="..." NameQualifier="..." SPNameQualifier="...">value</saml:NameID>
    let mut nameid_b = Element::build(saml_qname("NameID")).with_attribute(
        QName::new(None, "Format"),
        input.name_id.format.as_uri().to_owned(),
    );
    if let Some(nq) = &input.name_id.name_qualifier {
        nameid_b = nameid_b.with_attribute(QName::new(None, "NameQualifier"), nq.clone());
    }
    if let Some(spnq) = &input.name_id.sp_name_qualifier {
        nameid_b = nameid_b.with_attribute(QName::new(None, "SPNameQualifier"), spnq.clone());
    }
    if let Some(spid) = &input.name_id.sp_provided_id {
        nameid_b = nameid_b.with_attribute(QName::new(None, "SPProvidedID"), spid.clone());
    }
    nameid_b = nameid_b.with_text(input.name_id.value.clone());
    builder = builder.with_child(Node::Element(nameid_b.finish()));

    // <samlp:SessionIndex>...</samlp:SessionIndex>
    if let Some(sess) = input.session_index {
        let si = Element::build(samlp_qname("SessionIndex"))
            .with_text(sess.to_owned())
            .finish();
        builder = builder.with_child(Node::Element(si));
    }

    Ok(builder.finish())
}

/// Build the `<samlp:LogoutRequest>` element, wrap it in a [`Document`], and
/// serialize. Returns the raw XML bytes — NOT yet base64-encoded; the binding
/// layer is responsible for any wire encoding.
pub(crate) fn build_logout_request_xml(input: &BuildLogoutRequest<'_>) -> Result<Vec<u8>, Error> {
    let element = build_logout_request_element(input)?;
    let doc = Document::new(element)?;
    let xml = emit_document(&doc)?;
    Ok(xml.into_bytes())
}

// =============================================================================
// Tests
// =============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::nameid::{NameId, NameIdFormat};
    use std::time::{Duration, UNIX_EPOCH};

    fn fixed_instant() -> SystemTime {
        // 2026-05-26T12:34:56Z, same vector as time.rs round-trip tests.
        UNIX_EPOCH
            .checked_add(Duration::from_secs(1_779_798_896))
            .expect("UNIX_EPOCH + small duration fits in SystemTime")
    }

    fn minimal_input(name_id: &NameId) -> BuildLogoutRequest<'_> {
        BuildLogoutRequest {
            id: "_logout-abc",
            issue_instant: fixed_instant(),
            issuer_entity_id: "https://sp.example.com/saml",
            destination: Some("https://idp.example.com/slo"),
            not_on_or_after: None,
            reason: None,
            name_id,
            session_index: None,
        }
    }

    /// Emit and re-parse so we can interrogate structurally — string contains
    /// on raw XML would be brittle against attribute-order or prefix-choice
    /// changes in the emitter.
    fn emit_and_reparse(input: &BuildLogoutRequest<'_>) -> Document {
        let xml = build_logout_request_xml(input).expect("build");
        Document::parse(&xml).expect("re-parse")
    }

    #[test]
    fn minimal_request_carries_required_envelope_and_nameid() {
        let nid = NameId::email("alice@example.com");
        let doc = emit_and_reparse(&minimal_input(&nid));
        let root = doc.root();
        assert_eq!(root.qname().namespace(), Some(SAMLP_NS));
        assert_eq!(root.qname().local(), "LogoutRequest");

        assert_eq!(root.attribute(None, "ID"), Some("_logout-abc"));
        assert_eq!(root.attribute(None, "Version"), Some("2.0"));
        assert_eq!(
            root.attribute(None, "IssueInstant"),
            Some("2026-05-26T12:34:56Z")
        );
        assert_eq!(
            root.attribute(None, "Destination"),
            Some("https://idp.example.com/slo")
        );
        assert_eq!(root.attribute(None, "NotOnOrAfter"), None);
        assert_eq!(root.attribute(None, "Reason"), None);

        let issuer = root.child_element(Some(SAML_NS), "Issuer").unwrap();
        assert_eq!(issuer.text_content(), "https://sp.example.com/saml");

        let nameid = root.child_element(Some(SAML_NS), "NameID").unwrap();
        assert_eq!(
            nameid.attribute(None, "Format"),
            Some("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress")
        );
        assert_eq!(nameid.text_content(), "alice@example.com");

        // No SessionIndex when None.
        assert!(root.child_element(Some(SAMLP_NS), "SessionIndex").is_none());
    }

    #[test]
    fn session_index_emitted_when_provided() {
        let nid = NameId::new("opaque-x", NameIdFormat::Persistent);
        let mut input = minimal_input(&nid);
        input.session_index = Some("sess-123");

        let doc = emit_and_reparse(&input);
        let si = doc
            .root()
            .child_element(Some(SAMLP_NS), "SessionIndex")
            .expect("SessionIndex present");
        assert_eq!(si.text_content(), "sess-123");
    }

    #[test]
    fn not_on_or_after_formatted_as_xs_datetime() {
        let nid = NameId::email("bob@example.com");
        let mut input = minimal_input(&nid);
        // +5 minutes from fixed_instant.
        input.not_on_or_after = Some(fixed_instant() + Duration::from_mins(5));

        let doc = emit_and_reparse(&input);
        assert_eq!(
            doc.root().attribute(None, "NotOnOrAfter"),
            Some("2026-05-26T12:39:56Z")
        );
    }

    #[test]
    fn reason_attribute_emitted() {
        let nid = NameId::email("c@example.com");
        let mut input = minimal_input(&nid);
        input.reason = Some("urn:oasis:names:tc:SAML:2.0:logout:user");

        let doc = emit_and_reparse(&input);
        assert_eq!(
            doc.root().attribute(None, "Reason"),
            Some("urn:oasis:names:tc:SAML:2.0:logout:user")
        );
    }

    #[test]
    fn destination_optional() {
        let nid = NameId::email("d@example.com");
        let mut input = minimal_input(&nid);
        input.destination = None;

        let doc = emit_and_reparse(&input);
        assert_eq!(doc.root().attribute(None, "Destination"), None);
    }

    #[test]
    fn nameid_qualifiers_propagate() {
        let nid = NameId {
            value: "user-99".to_owned(),
            format: NameIdFormat::Persistent,
            name_qualifier: Some("https://idp.example.com/saml".to_owned()),
            sp_name_qualifier: Some("https://sp.example.com/saml".to_owned()),
            sp_provided_id: Some("sp-internal-99".to_owned()),
        };
        let input = minimal_input(&nid);
        let doc = emit_and_reparse(&input);
        let nameid = doc.root().child_element(Some(SAML_NS), "NameID").unwrap();
        assert_eq!(
            nameid.attribute(None, "Format"),
            Some("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent")
        );
        assert_eq!(
            nameid.attribute(None, "NameQualifier"),
            Some("https://idp.example.com/saml")
        );
        assert_eq!(
            nameid.attribute(None, "SPNameQualifier"),
            Some("https://sp.example.com/saml")
        );
        assert_eq!(
            nameid.attribute(None, "SPProvidedID"),
            Some("sp-internal-99")
        );
        assert_eq!(nameid.text_content(), "user-99");
    }

    #[test]
    fn xml_well_formed_when_all_fields_present() {
        let nid = NameId::email("e@example.com");
        let input = BuildLogoutRequest {
            id: "_full",
            issue_instant: fixed_instant(),
            issuer_entity_id: "https://sp.example.com/saml",
            destination: Some("https://idp.example.com/slo"),
            not_on_or_after: Some(fixed_instant() + Duration::from_mins(1)),
            reason: Some("urn:oasis:names:tc:SAML:2.0:logout:user"),
            name_id: &nid,
            session_index: Some("sess-XYZ"),
        };
        let xml = build_logout_request_xml(&input).unwrap();
        // Re-parse must succeed and the structural elements must be present.
        let doc = Document::parse(&xml).unwrap();
        assert_eq!(doc.root().qname().local(), "LogoutRequest");
        assert!(doc.root().child_element(Some(SAML_NS), "Issuer").is_some());
        assert!(doc.root().child_element(Some(SAML_NS), "NameID").is_some());
        assert!(
            doc.root()
                .child_element(Some(SAMLP_NS), "SessionIndex")
                .is_some()
        );
    }
}