use crate::error::Error;
use crate::logout::{LogoutStatus, SAML_NS, SAMLP_NS, saml_qname, samlp_qname};
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 BuildLogoutResponse<'a> {
pub id: &'a str,
pub issue_instant: SystemTime,
pub issuer_entity_id: &'a str,
pub destination: Option<&'a str>,
pub in_response_to: &'a str,
pub status: LogoutStatus,
pub status_message: Option<&'a str>,
}
pub(crate) fn build_logout_response_element(
input: &BuildLogoutResponse<'_>,
) -> Result<Element, Error> {
let mut builder = Element::build(samlp_qname("LogoutResponse"))
.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)?,
)
.with_attribute(
QName::new(None, "InResponseTo"),
input.in_response_to.to_owned(),
);
if let Some(dest) = input.destination {
builder = builder.with_attribute(QName::new(None, "Destination"), dest.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));
builder = builder.with_child(Node::Element(build_status(
input.status,
input.status_message,
)));
Ok(builder.finish())
}
fn build_status(status: LogoutStatus, message: Option<&str>) -> Element {
let mut top_code = Element::build(samlp_qname("StatusCode"))
.with_attribute(QName::new(None, "Value"), status.top_level_uri().to_owned());
if let Some(second) = status.second_level_uri() {
let nested = Element::build(samlp_qname("StatusCode"))
.with_attribute(QName::new(None, "Value"), second.to_owned())
.finish();
top_code = top_code.with_child(Node::Element(nested));
}
let mut status_el =
Element::build(samlp_qname("Status")).with_child(Node::Element(top_code.finish()));
if let Some(msg) = message {
let m = Element::build(samlp_qname("StatusMessage"))
.with_text(msg.to_owned())
.finish();
status_el = status_el.with_child(Node::Element(m));
}
status_el.finish()
}
pub(crate) fn build_logout_response_xml(input: &BuildLogoutResponse<'_>) -> Result<Vec<u8>, Error> {
let element = build_logout_response_element(input)?;
let doc = Document::new(element)?;
let xml = emit_document(&doc)?;
Ok(xml.into_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
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<'a>(status: LogoutStatus) -> BuildLogoutResponse<'a> {
BuildLogoutResponse {
id: "_resp-1",
issue_instant: fixed_instant(),
issuer_entity_id: "https://idp.example.com/saml",
destination: Some("https://sp.example.com/slo"),
in_response_to: "_req-1",
status,
status_message: None,
}
}
fn emit_and_reparse(input: &BuildLogoutResponse<'_>) -> Document {
let xml = build_logout_response_xml(input).expect("build");
Document::parse(&xml).expect("re-parse")
}
fn extract_status_codes(doc: &Document) -> (String, Option<String>) {
let status = doc
.root()
.child_element(Some(SAMLP_NS), "Status")
.expect("Status present");
let top = status
.child_element(Some(SAMLP_NS), "StatusCode")
.expect("top StatusCode");
let top_value = top.attribute(None, "Value").expect("Value").to_owned();
let nested = top
.child_element(Some(SAMLP_NS), "StatusCode")
.map(|n| n.attribute(None, "Value").expect("Value").to_owned());
(top_value, nested)
}
#[test]
fn success_status_emits_single_top_level_code() {
let doc = emit_and_reparse(&minimal_input(LogoutStatus::Success));
let root = doc.root();
assert_eq!(root.qname().namespace(), Some(SAMLP_NS));
assert_eq!(root.qname().local(), "LogoutResponse");
assert_eq!(root.attribute(None, "ID"), Some("_resp-1"));
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, "InResponseTo"), Some("_req-1"));
assert_eq!(
root.attribute(None, "Destination"),
Some("https://sp.example.com/slo")
);
let issuer = root.child_element(Some(SAML_NS), "Issuer").unwrap();
assert_eq!(issuer.text_content(), "https://idp.example.com/saml");
let (top, second) = extract_status_codes(&doc);
assert_eq!(top, "urn:oasis:names:tc:SAML:2.0:status:Success");
assert!(second.is_none());
}
#[test]
fn requester_status_emits_top_level_only() {
let doc = emit_and_reparse(&minimal_input(LogoutStatus::Requester));
let (top, second) = extract_status_codes(&doc);
assert_eq!(top, "urn:oasis:names:tc:SAML:2.0:status:Requester");
assert!(second.is_none());
}
#[test]
fn responder_status_emits_top_level_only() {
let doc = emit_and_reparse(&minimal_input(LogoutStatus::Responder));
let (top, second) = extract_status_codes(&doc);
assert_eq!(top, "urn:oasis:names:tc:SAML:2.0:status:Responder");
assert!(second.is_none());
}
#[test]
fn partial_logout_nests_under_responder() {
let doc = emit_and_reparse(&minimal_input(LogoutStatus::PartialLogout));
let (top, second) = extract_status_codes(&doc);
assert_eq!(top, "urn:oasis:names:tc:SAML:2.0:status:Responder");
assert_eq!(
second.as_deref(),
Some("urn:oasis:names:tc:SAML:2.0:status:PartialLogout")
);
}
#[test]
fn request_denied_nests_under_requester() {
let doc = emit_and_reparse(&minimal_input(LogoutStatus::RequestDenied));
let (top, second) = extract_status_codes(&doc);
assert_eq!(top, "urn:oasis:names:tc:SAML:2.0:status:Requester");
assert_eq!(
second.as_deref(),
Some("urn:oasis:names:tc:SAML:2.0:status:RequestDenied")
);
}
#[test]
fn status_message_emitted_when_provided() {
let mut input = minimal_input(LogoutStatus::PartialLogout);
input.status_message = Some("SP-B unreachable, retried 3x");
let doc = emit_and_reparse(&input);
let status = doc.root().child_element(Some(SAMLP_NS), "Status").unwrap();
let msg = status
.child_element(Some(SAMLP_NS), "StatusMessage")
.expect("StatusMessage present");
assert_eq!(msg.text_content(), "SP-B unreachable, retried 3x");
}
#[test]
fn destination_optional() {
let mut input = minimal_input(LogoutStatus::Success);
input.destination = None;
let doc = emit_and_reparse(&input);
assert_eq!(doc.root().attribute(None, "Destination"), None);
}
#[test]
fn xml_well_formed_with_all_optional_fields() {
let input = BuildLogoutResponse {
id: "_full-resp",
issue_instant: fixed_instant(),
issuer_entity_id: "https://idp.example.com/saml",
destination: Some("https://sp.example.com/slo"),
in_response_to: "_req-full",
status: LogoutStatus::PartialLogout,
status_message: Some("partial"),
};
let xml = build_logout_response_xml(&input).unwrap();
let doc = Document::parse(&xml).unwrap();
assert_eq!(doc.root().qname().local(), "LogoutResponse");
let status = doc.root().child_element(Some(SAMLP_NS), "Status").unwrap();
assert!(status.child_element(Some(SAMLP_NS), "StatusCode").is_some());
assert!(
status
.child_element(Some(SAMLP_NS), "StatusMessage")
.is_some()
);
}
}