use crate::error::Error;
use crate::logout::{LogoutOutcome, SAML_NS, SAMLP_NS};
use crate::time::parse_xs_datetime;
use crate::xml::parse::{Document, Element, ElementId};
#[derive(Debug, Clone)]
pub(crate) struct ParsedLogoutResponse {
pub issuer: String,
pub destination: Option<String>,
pub in_response_to: String,
pub status_code: String,
pub second_level_status_code: Option<String>,
pub status_message: Option<String>,
}
impl ParsedLogoutResponse {
pub(crate) fn to_outcome(&self) -> LogoutOutcome {
const SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
const PARTIAL: &str = "urn:oasis:names:tc:SAML:2.0:status:PartialLogout";
if self.status_code == SUCCESS {
return LogoutOutcome::Success;
}
if self.status_code == PARTIAL || self.second_level_status_code.as_deref() == Some(PARTIAL)
{
return LogoutOutcome::PartialLogout {
message: self.status_message.clone(),
};
}
let status = self
.second_level_status_code
.clone()
.unwrap_or_else(|| self.status_code.clone());
LogoutOutcome::Failure {
status,
message: self.status_message.clone(),
}
}
}
pub(crate) fn parse_logout_response(
document: &Document,
) -> Result<(ParsedLogoutResponse, ElementId), Error> {
let root = document.root();
if root.qname().namespace() != Some(SAMLP_NS) || root.qname().local() != "LogoutResponse" {
return Err(Error::XmlParse(format!(
"expected <samlp:LogoutResponse>, got <{}>",
root.qname()
)));
}
#[cfg(feature = "xsd-validate")]
crate::schema::validate_logout_response(root)?;
let version = root
.attribute(None, "Version")
.ok_or_else(|| Error::XmlParse("LogoutResponse missing Version".to_string()))?;
if version != "2.0" {
return Err(Error::XmlParse(format!(
"unsupported LogoutResponse Version: {version}"
)));
}
root.attribute(None, "ID")
.ok_or_else(|| Error::XmlParse("LogoutResponse missing ID".to_string()))?;
let issue_instant_str = root
.attribute(None, "IssueInstant")
.ok_or_else(|| Error::XmlParse("LogoutResponse missing IssueInstant".to_string()))?;
parse_xs_datetime(issue_instant_str)?;
let destination = root.attribute(None, "Destination").map(str::to_owned);
let in_response_to = root
.attribute(None, "InResponseTo")
.ok_or_else(|| Error::XmlParse("LogoutResponse missing InResponseTo".to_string()))?
.to_owned();
let issuer_el = root
.child_element(Some(SAML_NS), "Issuer")
.ok_or_else(|| Error::XmlParse("LogoutResponse missing <saml:Issuer>".to_string()))?;
let issuer = issuer_el.text_content();
if issuer.trim().is_empty() {
return Err(Error::XmlParse(
"LogoutResponse <saml:Issuer> is empty".to_string(),
));
}
let status_el = root
.child_element(Some(SAMLP_NS), "Status")
.ok_or_else(|| Error::XmlParse("LogoutResponse missing <samlp:Status>".to_string()))?;
let top_code_el = status_el
.child_element(Some(SAMLP_NS), "StatusCode")
.ok_or_else(|| {
Error::XmlParse("LogoutResponse Status missing <samlp:StatusCode>".to_string())
})?;
let status_code = top_code_el
.attribute(None, "Value")
.ok_or_else(|| Error::XmlParse("StatusCode missing Value attribute".to_string()))?
.to_owned();
let second_level_status_code = top_code_el
.child_element(Some(SAMLP_NS), "StatusCode")
.and_then(|el| el.attribute(None, "Value").map(str::to_owned));
let status_message = status_el
.child_element(Some(SAMLP_NS), "StatusMessage")
.map(Element::text_content);
let parsed = ParsedLogoutResponse {
issuer,
destination,
in_response_to,
status_code,
second_level_status_code,
status_message,
};
Ok((parsed, root.id()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logout::LogoutStatus;
use crate::logout::response_build::{BuildLogoutResponse, build_logout_response_xml};
use std::time::{Duration, SystemTime, 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 parse(xml: &str) -> Result<(ParsedLogoutResponse, ElementId), Error> {
let doc = Document::parse(xml.as_bytes())?;
parse_logout_response(&doc)
}
#[test]
fn parses_success_response() {
let xml = r#"<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_r" Version="2.0" IssueInstant="2026-05-26T12:34:56Z"
Destination="https://sp.example.com/slo" InResponseTo="_req-1">
<saml:Issuer>https://idp.example.com/saml</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
</samlp:LogoutResponse>"#;
let (resp, eid) = parse(xml).expect("parse");
assert_eq!(resp.issuer, "https://idp.example.com/saml");
assert_eq!(
resp.destination.as_deref(),
Some("https://sp.example.com/slo")
);
assert_eq!(resp.in_response_to, "_req-1");
assert_eq!(
resp.status_code,
"urn:oasis:names:tc:SAML:2.0:status:Success"
);
assert!(resp.second_level_status_code.is_none());
assert!(resp.status_message.is_none());
let outcome = resp.to_outcome();
assert!(matches!(outcome, LogoutOutcome::Success));
let doc = Document::parse(xml.as_bytes()).unwrap();
assert_eq!(eid, doc.root().id());
}
#[test]
fn partial_logout_at_second_level_maps_to_outcome() {
let xml = r#"<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_r" Version="2.0" IssueInstant="2026-05-26T12:34:56Z" InResponseTo="_req-1">
<saml:Issuer>idp</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Responder">
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:PartialLogout"/>
</samlp:StatusCode>
<samlp:StatusMessage>SP-B was unreachable</samlp:StatusMessage>
</samlp:Status>
</samlp:LogoutResponse>"#;
let (resp, _) = parse(xml).unwrap();
assert_eq!(
resp.status_code,
"urn:oasis:names:tc:SAML:2.0:status:Responder"
);
assert_eq!(
resp.second_level_status_code.as_deref(),
Some("urn:oasis:names:tc:SAML:2.0:status:PartialLogout")
);
assert_eq!(resp.status_message.as_deref(), Some("SP-B was unreachable"));
match resp.to_outcome() {
LogoutOutcome::PartialLogout { message } => {
assert_eq!(message.as_deref(), Some("SP-B was unreachable"));
}
other => panic!("expected PartialLogout, got {other:?}"),
}
}
#[test]
fn request_denied_failure_with_message() {
let xml = r#"<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_r" Version="2.0" IssueInstant="2026-05-26T12:34:56Z" InResponseTo="_req-1">
<saml:Issuer>idp</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Requester">
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:RequestDenied"/>
</samlp:StatusCode>
<samlp:StatusMessage>session not found</samlp:StatusMessage>
</samlp:Status>
</samlp:LogoutResponse>"#;
let (resp, _) = parse(xml).unwrap();
match resp.to_outcome() {
LogoutOutcome::Failure { status, message } => {
assert_eq!(status, "urn:oasis:names:tc:SAML:2.0:status:RequestDenied");
assert_eq!(message.as_deref(), Some("session not found"));
}
other => panic!("expected Failure, got {other:?}"),
}
}
#[test]
fn responder_only_failure_no_message() {
let xml = r#"<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_r" Version="2.0" IssueInstant="2026-05-26T12:34:56Z" InResponseTo="_req-1">
<saml:Issuer>idp</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Responder"/>
</samlp:Status>
</samlp:LogoutResponse>"#;
let (resp, _) = parse(xml).unwrap();
match resp.to_outcome() {
LogoutOutcome::Failure { status, message } => {
assert_eq!(status, "urn:oasis:names:tc:SAML:2.0:status:Responder");
assert!(message.is_none());
}
other => panic!("expected Failure, got {other:?}"),
}
}
#[test]
fn partial_logout_at_top_level_also_maps() {
let xml = r#"<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_r" Version="2.0" IssueInstant="2026-05-26T12:34:56Z" InResponseTo="_req-1">
<saml:Issuer>idp</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:PartialLogout"/>
</samlp:Status>
</samlp:LogoutResponse>"#;
let (resp, _) = parse(xml).unwrap();
assert!(matches!(
resp.to_outcome(),
LogoutOutcome::PartialLogout { .. }
));
}
#[test]
fn wrong_root_element_rejected() {
let xml = r#"<samlp:LogoutRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="_x" Version="2.0" IssueInstant="2026-05-26T12:34:56Z"/>"#;
let err = parse(xml).unwrap_err();
match err {
Error::XmlParse(msg) => assert!(msg.contains("LogoutResponse"), "got: {msg}"),
other => panic!("expected XmlParse, got {other:?}"),
}
}
#[test]
fn missing_in_response_to_rejected() {
let xml = r#"<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_r" Version="2.0" IssueInstant="2026-05-26T12:34:56Z">
<saml:Issuer>idp</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
</samlp:LogoutResponse>"#;
let err = parse(xml).unwrap_err();
match err {
Error::XmlParse(msg) => assert!(msg.contains("InResponseTo"), "got: {msg}"),
other => panic!("expected XmlParse, got {other:?}"),
}
}
#[test]
fn missing_status_rejected() {
let xml = r#"<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_r" Version="2.0" IssueInstant="2026-05-26T12:34:56Z" InResponseTo="_req-1">
<saml:Issuer>idp</saml:Issuer>
</samlp:LogoutResponse>"#;
let err = parse(xml).unwrap_err();
match err {
Error::XmlParse(msg) => assert!(msg.contains("Status"), "got: {msg}"),
Error::SchemaViolation { reason, .. } => {
assert!(reason.contains("Status"), "got: {reason}");
}
other => panic!("expected XmlParse or SchemaViolation, got {other:?}"),
}
}
#[test]
fn missing_status_code_value_rejected() {
let xml = r#"<samlp:LogoutResponse
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_r" Version="2.0" IssueInstant="2026-05-26T12:34:56Z" InResponseTo="_req-1">
<saml:Issuer>idp</saml:Issuer>
<samlp:Status>
<samlp:StatusCode/>
</samlp:Status>
</samlp:LogoutResponse>"#;
let err = parse(xml).unwrap_err();
match err {
Error::XmlParse(msg) => assert!(msg.contains("Value"), "got: {msg}"),
Error::SchemaViolation { reason, .. } => {
assert!(reason.contains("Value"), "got: {reason}");
}
other => panic!("expected XmlParse or SchemaViolation, got {other:?}"),
}
}
fn build_and_parse(status: LogoutStatus, msg: Option<&str>) -> LogoutOutcome {
let input = BuildLogoutResponse {
id: "_round",
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: msg,
};
let xml = build_logout_response_xml(&input).unwrap();
let (resp, _) = parse(std::str::from_utf8(&xml).unwrap()).unwrap();
resp.to_outcome()
}
#[test]
fn round_trip_success() {
assert!(matches!(
build_and_parse(LogoutStatus::Success, None),
LogoutOutcome::Success
));
}
#[test]
fn round_trip_partial_logout_carries_message() {
match build_and_parse(LogoutStatus::PartialLogout, Some("3 of 5 OK")) {
LogoutOutcome::PartialLogout { message } => {
assert_eq!(message.as_deref(), Some("3 of 5 OK"));
}
other => panic!("expected PartialLogout, got {other:?}"),
}
}
#[test]
fn round_trip_request_denied_failure() {
match build_and_parse(LogoutStatus::RequestDenied, Some("nope")) {
LogoutOutcome::Failure { status, message } => {
assert_eq!(status, "urn:oasis:names:tc:SAML:2.0:status:RequestDenied");
assert_eq!(message.as_deref(), Some("nope"));
}
other => panic!("expected Failure, got {other:?}"),
}
}
#[test]
fn round_trip_responder_failure() {
match build_and_parse(LogoutStatus::Responder, None) {
LogoutOutcome::Failure { status, message } => {
assert_eq!(status, "urn:oasis:names:tc:SAML:2.0:status:Responder");
assert!(message.is_none());
}
other => panic!("expected Failure, got {other:?}"),
}
}
#[test]
fn round_trip_requester_failure() {
match build_and_parse(LogoutStatus::Requester, None) {
LogoutOutcome::Failure { status, message } => {
assert_eq!(status, "urn:oasis:names:tc:SAML:2.0:status:Requester");
assert!(message.is_none());
}
other => panic!("expected Failure, got {other:?}"),
}
}
}