Skip to content

Commit

Permalink
Serialize logout messages (#36)
Browse files Browse the repository at this point in the history
* Add required NameID field to LogoutRequest

According to the specification the `<LogoutRequest>` element is required
to contain `<saml:BaseID>` or `<saml:NameID>` or `<saml:EncryptedID>`.
This commit adds support for `<saml:NameID>` being parsed optionally to
still parse messages containing one of the other two successfully.

Support for the other two wasn't added since they are also not supported
for the `<Subject>` element which would allow them as well.

This could have been combined with the `SubjectNameID` struct but their
serialization implemented later will be different so this was added as a
separate struct.

* Implement serialization for logout request and response

The code for `NameID` was just copied from `SubjectNameID` and extended
by how `AttributeStatement` handles the XML namespace.

Also added tests.
  • Loading branch information
ServiusHack authored Oct 13, 2023
1 parent 4974e40 commit bdb8737
Show file tree
Hide file tree
Showing 3 changed files with 253 additions and 1 deletion.
241 changes: 240 additions & 1 deletion src/schema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,57 @@ use crate::signature::Signature;
use chrono::prelude::*;
use serde::Deserialize;

use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Writer;

use std::io::Cursor;
use std::str::FromStr;

use thiserror::Error;

#[derive(Clone, Debug, Deserialize, Hash, Eq, PartialEq, Ord, PartialOrd)]
pub struct NameID {
#[serde(rename = "Format")]
pub format: Option<String>,

#[serde(rename = "$value")]
pub value: String,
}

impl NameID {
fn name() -> &'static str {
"saml2:NameID"
}

fn schema() -> &'static [(&'static str, &'static str)] {
&[("xmlns:saml2", "urn:oasis:names:tc:SAML:2.0:assertion")]
}
}

impl TryFrom<&NameID> for Event<'_> {
type Error = Box<dyn std::error::Error>;

fn try_from(value: &NameID) -> Result<Self, Self::Error> {
let mut write_buf = Vec::new();
let mut writer = Writer::new(Cursor::new(&mut write_buf));
let mut root = BytesStart::new(NameID::name());

for attr in NameID::schema() {
root.push_attribute((attr.0, attr.1));
}

if let Some(format) = &value.format {
root.push_attribute(("Format", format.as_ref()));
}

writer.write_event(Event::Start(root))?;
writer.write_event(Event::Text(BytesText::from_escaped(value.value.as_str())))?;
writer.write_event(Event::End(BytesEnd::new(NameID::name())))?;
Ok(Event::Text(BytesText::from_escaped(String::from_utf8(
write_buf,
)?)))
}
}

#[derive(Clone, Debug, Deserialize, Hash, Eq, PartialEq, Ord, PartialOrd)]
pub struct LogoutRequest {
Expand All @@ -38,6 +85,81 @@ pub struct LogoutRequest {
pub signature: Option<Signature>,
#[serde(rename = "@SessionIndex")]
pub session_index: Option<String>,
#[serde(rename = "NameID")]
pub name_id: Option<NameID>,
}

#[derive(Debug, Error)]
pub enum LogoutRequestError {
#[error("Failed to deserialize LogoutRequest: {:?}", source)]
ParseError {
#[from]
source: quick_xml::DeError,
},
}

impl FromStr for LogoutRequest {
type Err = LogoutRequestError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(quick_xml::de::from_str(s)?)
}
}

const LOGOUT_REQUEST_NAME: &str = "saml2p:LogoutRequest";
const SESSION_INDEX_NAME: &str = "saml2p:SessionIndex";
const PROTOCOL_SCHEMA: (&str, &str) = ("xmlns:saml2p", "urn:oasis:names:tc:SAML:2.0:protocol");

impl LogoutRequest {
pub fn to_xml(&self) -> Result<String, Box<dyn std::error::Error>> {
let mut write_buf = Vec::new();
let mut writer = Writer::new(Cursor::new(&mut write_buf));
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;

let mut root = BytesStart::new(LOGOUT_REQUEST_NAME);
root.push_attribute(PROTOCOL_SCHEMA);
if let Some(id) = &self.id {
root.push_attribute(("ID", id.as_ref()));
}
if let Some(version) = &self.version {
root.push_attribute(("Version", version.as_ref()));
}
if let Some(issue_instant) = &self.issue_instant {
root.push_attribute((
"IssueInstant",
issue_instant
.to_rfc3339_opts(SecondsFormat::Millis, true)
.as_ref(),
));
}
if let Some(destination) = &self.destination {
root.push_attribute(("Destination", destination.as_ref()));
}

writer.write_event(Event::Start(root))?;

if let Some(issuer) = &self.issuer {
let event: Event<'_> = issuer.try_into()?;
writer.write_event(event)?;
}
if let Some(signature) = &self.signature {
let event: Event<'_> = signature.try_into()?;
writer.write_event(event)?;
}

if let Some(session) = &self.session_index {
writer.write_event(Event::Start(BytesStart::new(SESSION_INDEX_NAME)))?;
writer.write_event(Event::Text(BytesText::new(session)))?;
writer.write_event(Event::End(BytesEnd::new(SESSION_INDEX_NAME)))?;
}
if let Some(name_id) = &self.name_id {
let event: Event<'_> = name_id.try_into()?;
writer.write_event(event)?;
}

writer.write_event(Event::End(BytesEnd::new(LOGOUT_REQUEST_NAME)))?;
Ok(String::from_utf8(write_buf)?)
}
}

#[derive(Clone, Debug, Deserialize, Hash, Eq, PartialEq, Ord, PartialOrd)]
Expand Down Expand Up @@ -475,3 +597,120 @@ pub struct LogoutResponse {
#[serde(rename = "Status")]
pub status: Option<Status>,
}

#[derive(Debug, Error)]
pub enum LogoutResponseError {
#[error("Failed to deserialize LogoutResponse: {:?}", source)]
ParseError {
#[from]
source: quick_xml::DeError,
},
}

impl FromStr for LogoutResponse {
type Err = LogoutResponseError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(quick_xml::de::from_str(s)?)
}
}

const LOGOUT_RESPONSE_NAME: &str = "saml2p:LogoutResponse";

impl LogoutResponse {
pub fn to_xml(&self) -> Result<String, Box<dyn std::error::Error>> {
let mut write_buf = Vec::new();
let mut writer = Writer::new(Cursor::new(&mut write_buf));
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;

let mut root = BytesStart::new(LOGOUT_RESPONSE_NAME);
root.push_attribute(PROTOCOL_SCHEMA);
if let Some(id) = &self.id {
root.push_attribute(("ID", id.as_ref()));
}
if let Some(resp_to) = &self.in_response_to {
root.push_attribute(("InResponseTo", resp_to.as_ref()));
}
if let Some(version) = &self.version {
root.push_attribute(("Version", version.as_ref()));
}
if let Some(issue_instant) = &self.issue_instant {
root.push_attribute((
"IssueInstant",
issue_instant
.to_rfc3339_opts(SecondsFormat::Millis, true)
.as_ref(),
));
}
if let Some(destination) = &self.destination {
root.push_attribute(("Destination", destination.as_ref()));
}
if let Some(consent) = &self.consent {
root.push_attribute(("Consent", consent.as_ref()));
}

writer.write_event(Event::Start(root))?;

if let Some(issuer) = &self.issuer {
let event: Event<'_> = issuer.try_into()?;
writer.write_event(event)?;
}
if let Some(signature) = &self.signature {
let event: Event<'_> = signature.try_into()?;
writer.write_event(event)?;
}

if let Some(status) = &self.status {
let event: Event<'_> = status.try_into()?;
writer.write_event(event)?;
}

writer.write_event(Event::End(BytesEnd::new(LOGOUT_RESPONSE_NAME)))?;
Ok(String::from_utf8(write_buf)?)
}
}

#[cfg(test)]
mod test {
use super::issuer::Issuer;
use super::{LogoutRequest, LogoutResponse, NameID, Status, StatusCode};
use chrono::TimeZone;

#[test]
fn test_deserialize_serialize_logout_request() {
let request_xml = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/test_vectors/logout_request.xml",
));
let expected_request: LogoutRequest = request_xml
.parse()
.expect("failed to parse logout_request.xml");
let serialized_request = expected_request
.to_xml()
.expect("failed to convert request to xml");
let actual_request: LogoutRequest = serialized_request
.parse()
.expect("failed to re-parse request");

assert_eq!(expected_request, actual_request);
}

#[test]
fn test_deserialize_serialize_logout_response() {
let response_xml = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/test_vectors/logout_response.xml",
));
let expected_response: LogoutResponse = response_xml
.parse()
.expect("failed to parse logout_response.xml");
let serialized_response = expected_response
.to_xml()
.expect("failed to convert Response to xml");
let actual_response: LogoutResponse = serialized_response
.parse()
.expect("failed to re-parse Response");

assert_eq!(expected_response, actual_response);
}
}
6 changes: 6 additions & 0 deletions test_vectors/logout_request.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="123" Version="2.0" IssueInstant="2023-10-07T08:55:36.000Z">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">http://sp.example.com/demo1/metadata.php</saml2:Issuer>
<samlp:SessionIndex>session-index-1</samlp:SessionIndex>
<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">[email protected]</saml2:NameID>
</samlp:LogoutRequest>
7 changes: 7 additions & 0 deletions test_vectors/logout_response.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="123" InResponseTo="id-2282157865" Version="2.0" IssueInstant="2023-10-07T10:31:49.000Z">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">http://sp.example.com/demo1/metadata.php</saml2:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
</samlp:LogoutResponse>

0 comments on commit bdb8737

Please sign in to comment.