mod cdc;
mod service;
pub use cdc::{COMMON_DOMAIN_COOKIE_NAME, CommonDomainCookie};
pub use service::{
DiscoveryRequest, ParsedDiscoveryRequest, build_discovery_request_url,
build_discovery_response_url, parse_discovery_request_query, parse_discovery_response_query,
validate_discovery_return_url,
};
use crate::error::Error;
use crate::metadata::parse::{MD_NS, parse_optional_bool_value};
use crate::xml::parse::Element;
pub const IDPDISC_NS: &str = "urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol";
pub const DISCOVERY_POLICY_SINGLE: &str =
"urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol:single";
pub const DEFAULT_RETURN_ID_PARAM: &str = "entityID";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveryResponseEndpoint {
pub url: String,
pub index: u16,
pub is_default: bool,
}
impl DiscoveryResponseEndpoint {
pub fn new(url: impl Into<String>, index: u16, is_default: bool) -> Self {
Self {
url: url.into(),
index,
is_default,
}
}
}
pub(crate) fn parse_discovery_response_extensions(
sp_descriptor: &Element,
) -> Result<Vec<DiscoveryResponseEndpoint>, Error> {
let Some(extensions) = sp_descriptor.child_element(Some(MD_NS), "Extensions") else {
return Ok(Vec::new());
};
let mut endpoints = Vec::new();
for child in extensions.all_child_elements(Some(IDPDISC_NS), "DiscoveryResponse") {
let binding = child
.attribute(None, "Binding")
.ok_or(Error::InvalidConfiguration {
reason: "DiscoveryResponse missing Binding",
})?;
if binding != IDPDISC_NS {
return Err(Error::InvalidConfiguration {
reason: "DiscoveryResponse Binding is not the idp-discovery protocol URI",
});
}
let url = child
.attribute(None, "Location")
.ok_or(Error::InvalidConfiguration {
reason: "DiscoveryResponse missing Location",
})?
.to_owned();
let index = child
.attribute(None, "index")
.ok_or(Error::InvalidConfiguration {
reason: "DiscoveryResponse missing index",
})?
.parse::<u16>()
.map_err(|_parse_err| Error::InvalidConfiguration {
reason: "DiscoveryResponse index is not a u16",
})?;
let is_default =
parse_optional_bool_value(child.attribute(None, "isDefault"))?.unwrap_or(false);
endpoints.push(DiscoveryResponseEndpoint {
url,
index,
is_default,
});
}
Ok(endpoints)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::xml::parse::Document;
fn parse_sp_role(extensions_body: &str) -> Result<Vec<DiscoveryResponseEndpoint>, Error> {
let xml = format!(
r#"<md:SPSSODescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:idpdisc="{IDPDISC_NS}"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
{extensions_body}
</md:SPSSODescriptor>"#
);
let doc = Document::parse(xml.as_bytes())?;
parse_discovery_response_extensions(doc.root())
}
#[test]
fn absent_extensions_yields_empty_list() {
assert_eq!(parse_sp_role("").unwrap(), vec![]);
}
#[test]
fn extensions_without_discovery_entries_yields_empty_list() {
let got = parse_sp_role(
r#"<md:Extensions><other:Thing xmlns:other="urn:x-other"/></md:Extensions>"#,
)
.unwrap();
assert_eq!(got, vec![]);
}
#[test]
fn parses_multiple_endpoints_with_index_and_default() {
let got = parse_sp_role(&format!(
r#"<md:Extensions>
<idpdisc:DiscoveryResponse Binding="{IDPDISC_NS}"
Location="https://sp.example.com/disco" index="0" isDefault="true"/>
<idpdisc:DiscoveryResponse Binding="{IDPDISC_NS}"
Location="https://sp.example.com/disco2" index="1"/>
</md:Extensions>"#
))
.unwrap();
assert_eq!(
got,
vec![
DiscoveryResponseEndpoint::new("https://sp.example.com/disco", 0, true),
DiscoveryResponseEndpoint::new("https://sp.example.com/disco2", 1, false),
]
);
}
#[test]
fn rejects_wrong_binding() {
let err = parse_sp_role(
r#"<md:Extensions>
<idpdisc:DiscoveryResponse Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://sp.example.com/disco" index="0"/>
</md:Extensions>"#,
)
.unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn rejects_missing_index() {
let err = parse_sp_role(&format!(
r#"<md:Extensions>
<idpdisc:DiscoveryResponse Binding="{IDPDISC_NS}"
Location="https://sp.example.com/disco"/>
</md:Extensions>"#
))
.unwrap_err();
assert!(matches!(
err,
Error::InvalidConfiguration {
reason: "DiscoveryResponse missing index"
}
));
}
}