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;
pub(crate) struct BuildLogoutRequest<'a> {
pub id: &'a str,
pub issue_instant: SystemTime,
pub issuer_entity_id: &'a str,
pub destination: Option<&'a str>,
pub not_on_or_after: Option<SystemTime>,
pub reason: Option<&'a str>,
pub name_id: &'a NameId,
pub session_index: Option<&'a str>,
}
pub(crate) fn build_logout_request_element(
input: &BuildLogoutRequest<'_>,
) -> Result<Element, Error> {
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());
}
let issuer = Element::build(saml_qname("Issuer"))
.with_text(input.issuer_entity_id.to_owned())
.finish();
builder = builder.with_child(Node::Element(issuer));
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()));
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())
}
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())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nameid::{NameId, NameIdFormat};
use std::time::{Duration, UNIX_EPOCH};
fn fixed_instant() -> SystemTime {
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,
}
}
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");
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);
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();
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()
);
}
}