use crate::constants::{elements_order, name_id_format, namespace, Binding};
use crate::error::SamlError;
use crate::metadata::write::MetadataWriter;
use crate::util::{is_non_empty_array, normalize_cert_string};
#[derive(Debug, Clone)]
pub struct Endpoint {
pub binding: Binding,
pub location: String,
pub index: Option<u16>,
pub is_default: bool,
}
impl Endpoint {
pub fn new(binding: Binding, location: impl Into<String>) -> Self {
Self {
binding,
location: location.into(),
index: None,
is_default: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SpMetadataConfig {
pub entity_id: String,
pub signing_certs: Vec<String>,
pub encrypt_certs: Vec<String>,
pub authn_requests_signed: bool,
pub want_assertions_signed: bool,
pub name_id_format: Vec<String>,
pub single_logout_service: Vec<Endpoint>,
pub assertion_consumer_service: Vec<Endpoint>,
pub elements_order: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default)]
pub struct IdpMetadataConfig {
pub entity_id: String,
pub signing_certs: Vec<String>,
pub encrypt_certs: Vec<String>,
pub want_authn_requests_signed: bool,
pub name_id_format: Vec<String>,
pub single_sign_on_service: Vec<Endpoint>,
pub single_logout_service: Vec<Endpoint>,
pub elements_order: Option<Vec<String>>,
}
fn write_key_descriptor(w: &mut MetadataWriter, use_: &str, cert: &str) {
let cert = normalize_cert_string(cert);
w.start("KeyDescriptor", &[("use", use_)]);
w.start("ds:KeyInfo", &[("xmlns:ds", namespace::DSIG)]);
w.start("ds:X509Data", &[]);
w.text_element("ds:X509Certificate", &cert);
w.end("ds:X509Data");
w.end("ds:KeyInfo");
w.end("KeyDescriptor");
}
fn write_key_descriptors(w: &mut MetadataWriter, signing: &[String], encrypt: &[String]) {
for cert in signing {
write_key_descriptor(w, "signing", cert);
}
for cert in encrypt {
write_key_descriptor(w, "encryption", cert);
}
}
fn write_name_id_formats(w: &mut MetadataWriter, formats: &[String], default_to_email: bool) {
if is_non_empty_array(formats) {
for format in formats {
w.text_element("NameIDFormat", format);
}
} else if default_to_email {
w.text_element("NameIDFormat", name_id_format::EMAIL_ADDRESS);
}
}
fn write_endpoint_attrs(w: &mut MetadataWriter, name: &str, e: &Endpoint, index: Option<String>) {
let mut attrs = Vec::with_capacity(4);
if let Some(index) = index.as_deref() {
attrs.push(("index", index));
}
if e.is_default {
attrs.push(("isDefault", "true"));
}
attrs.push(("Binding", e.binding.urn()));
attrs.push(("Location", e.location.as_str()));
w.empty(name, &attrs);
}
fn write_single_logout(w: &mut MetadataWriter, endpoints: &[Endpoint]) {
for endpoint in endpoints {
write_endpoint_attrs(w, "SingleLogoutService", endpoint, None);
}
}
fn write_assertion_consumer_service(w: &mut MetadataWriter, endpoints: &[Endpoint]) {
for (i, endpoint) in endpoints.iter().enumerate() {
let index = endpoint
.index
.map(|index| index.to_string())
.unwrap_or_else(|| i.to_string());
write_endpoint_attrs(w, "AssertionConsumerService", endpoint, Some(index));
}
}
fn write_single_sign_on_service(w: &mut MetadataWriter, endpoints: &[Endpoint]) {
for endpoint in endpoints {
write_endpoint_attrs(w, "SingleSignOnService", endpoint, None);
}
}
fn write_sp_group(w: &mut MetadataWriter, cfg: &SpMetadataConfig, name: &str) {
match name {
"KeyDescriptor" => write_key_descriptors(w, &cfg.signing_certs, &cfg.encrypt_certs),
"NameIDFormat" => write_name_id_formats(w, &cfg.name_id_format, true),
"SingleLogoutService" => write_single_logout(w, &cfg.single_logout_service),
"AssertionConsumerService" => {
write_assertion_consumer_service(w, &cfg.assertion_consumer_service)
}
_ => {}
}
}
fn idp_group_has_content(cfg: &IdpMetadataConfig, name: &str) -> bool {
match name {
"KeyDescriptor" => {
is_non_empty_array(&cfg.signing_certs) || is_non_empty_array(&cfg.encrypt_certs)
}
"NameIDFormat" => is_non_empty_array(&cfg.name_id_format),
"SingleSignOnService" => is_non_empty_array(&cfg.single_sign_on_service),
"SingleLogoutService" => is_non_empty_array(&cfg.single_logout_service),
_ => false,
}
}
fn write_idp_group(w: &mut MetadataWriter, cfg: &IdpMetadataConfig, name: &str) {
match name {
"KeyDescriptor" => write_key_descriptors(w, &cfg.signing_certs, &cfg.encrypt_certs),
"NameIDFormat" => write_name_id_formats(w, &cfg.name_id_format, false),
"SingleSignOnService" => write_single_sign_on_service(w, &cfg.single_sign_on_service),
"SingleLogoutService" => write_single_logout(w, &cfg.single_logout_service),
_ => {}
}
}
fn elements_order_or_default(order: &Option<Vec<String>>, default: &[&str]) -> Vec<String> {
if let Some(order) = order {
order.clone()
} else {
default.iter().map(|s| s.to_string()).collect()
}
}
fn bool_str(value: bool) -> &'static str {
if value {
"true"
} else {
"false"
}
}
pub fn generate_sp_metadata(cfg: &SpMetadataConfig) -> String {
let is_custom_order = cfg.elements_order.is_some();
let order = elements_order_or_default(&cfg.elements_order, elements_order::DEFAULT);
let mut w = MetadataWriter::new();
w.start(
"EntityDescriptor",
&[
("entityID", cfg.entity_id.as_str()),
("xmlns", namespace::METADATA),
("xmlns:assertion", namespace::ASSERTION),
("xmlns:ds", namespace::DSIG),
],
);
w.start(
"SPSSODescriptor",
&[
("AuthnRequestsSigned", bool_str(cfg.authn_requests_signed)),
("WantAssertionsSigned", bool_str(cfg.want_assertions_signed)),
("protocolSupportEnumeration", namespace::PROTOCOL),
],
);
for name in &order {
write_sp_group(&mut w, cfg, name);
}
if is_custom_order
&& !order
.iter()
.any(|ordered| ordered == "AssertionConsumerService")
{
write_sp_group(&mut w, cfg, "AssertionConsumerService");
}
w.end("SPSSODescriptor");
w.end("EntityDescriptor");
w.finish()
}
pub fn generate_idp_metadata(cfg: &IdpMetadataConfig) -> String {
let is_custom_order = cfg.elements_order.is_some();
let order = elements_order_or_default(&cfg.elements_order, elements_order::idp::DEFAULT);
let descriptors = [
"KeyDescriptor",
"NameIDFormat",
"SingleSignOnService",
"SingleLogoutService",
];
let mut w = MetadataWriter::new();
w.start(
"EntityDescriptor",
&[
("entityID", cfg.entity_id.as_str()),
("xmlns", namespace::METADATA),
("xmlns:assertion", namespace::ASSERTION),
("xmlns:ds", namespace::DSIG),
],
);
w.start(
"IDPSSODescriptor",
&[
(
"WantAuthnRequestsSigned",
bool_str(cfg.want_authn_requests_signed),
),
("protocolSupportEnumeration", namespace::PROTOCOL),
],
);
for name in &order {
if descriptors.contains(&name.as_str()) && idp_group_has_content(cfg, name) {
write_idp_group(&mut w, cfg, name);
}
}
if is_custom_order {
if !order.iter().any(|ordered| ordered == "SingleSignOnService") {
write_idp_group(&mut w, cfg, "SingleSignOnService");
}
} else {
for name in descriptors {
if idp_group_has_content(cfg, name) && !order.iter().any(|ordered| ordered == name) {
write_idp_group(&mut w, cfg, name);
}
}
}
w.end("IDPSSODescriptor");
w.end("EntityDescriptor");
w.finish()
}
pub fn try_generate_idp_metadata(cfg: &IdpMetadataConfig) -> Result<String, SamlError> {
if !is_non_empty_array(&cfg.single_sign_on_service) {
return Err(SamlError::MissingMetadata(
"SingleSignOnService".to_string(),
));
}
Ok(generate_idp_metadata(cfg))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::CertUse;
use crate::entity::EntitySetting;
use crate::metadata::{IdpMetadata, SpMetadata};
use crate::sp::ServiceProvider;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[test]
fn sp_metadata_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let cfg = SpMetadataConfig {
entity_id: "https://sp.example.com/metadata".into(),
signing_certs: vec!["MIIBsigning".into()],
encrypt_certs: vec!["MIIBencrypt".into()],
authn_requests_signed: true,
want_assertions_signed: true,
name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
single_logout_service: vec![Endpoint::new(
Binding::Redirect,
"https://sp.example.com/slo",
)],
assertion_consumer_service: vec![
Endpoint {
binding: Binding::Post,
location: "https://sp.example.com/acs".into(),
index: None,
is_default: true,
},
Endpoint::new(Binding::Redirect, "https://sp.example.com/acs-redirect"),
],
elements_order: None,
};
let xml = generate_sp_metadata(&cfg);
let parsed = SpMetadata::from_xml(&xml)?;
assert_eq!(
parsed.get_entity_id(),
Some("https://sp.example.com/metadata")
);
assert!(parsed.is_authn_request_signed());
assert!(parsed.is_want_assertions_signed());
assert_eq!(
parsed
.get_assertion_consumer_service(Binding::Post)
.as_deref(),
Some("https://sp.example.com/acs")
);
assert_eq!(
parsed
.get_single_logout_service(Binding::Redirect)
.as_deref(),
Some("https://sp.example.com/slo")
);
assert_eq!(
parsed.get_x509_certificate(CertUse::Signing).as_deref(),
Some("MIIBsigning")
);
assert_eq!(
parsed.get_x509_certificate(CertUse::Encryption).as_deref(),
Some("MIIBencrypt")
);
Ok(())
}
#[test]
fn sp_elements_order_respected() {
let cfg = SpMetadataConfig {
entity_id: "x".into(),
single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://sp/slo")],
assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
..Default::default()
};
let xml = generate_sp_metadata(&cfg);
let slo = xml.find("SingleLogoutService").unwrap_or(usize::MAX);
let acs = xml.find("AssertionConsumerService").unwrap_or(0);
assert!(slo < acs);
}
#[test]
fn idp_metadata_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let cfg = IdpMetadataConfig {
entity_id: "https://idp.example.com/metadata".into(),
signing_certs: vec!["MIIBidp".into()],
want_authn_requests_signed: true,
single_sign_on_service: vec![Endpoint::new(
Binding::Redirect,
"https://idp.example.com/sso",
)],
..Default::default()
};
let xml = generate_idp_metadata(&cfg);
let parsed = IdpMetadata::from_xml(&xml)?;
assert!(parsed.is_want_authn_requests_signed());
assert_eq!(
parsed
.get_single_sign_on_service(Binding::Redirect)
.as_deref(),
Some("https://idp.example.com/sso")
);
assert_eq!(
parsed.get_x509_certificate(CertUse::Signing).as_deref(),
Some("MIIBidp")
);
Ok(())
}
#[test]
fn try_generate_idp_metadata_rejects_missing_sso() {
let cfg = IdpMetadataConfig {
entity_id: "https://idp.example.com/metadata".into(),
..Default::default()
};
let result = try_generate_idp_metadata(&cfg);
assert!(matches!(
result,
Err(SamlError::MissingMetadata(name)) if name == "SingleSignOnService"
));
}
#[test]
fn sp_from_config_escapes_name_id_format_xml_markup() -> TestResult {
let injected_format = format!(
"{}</NameIDFormat><SingleLogoutService Binding=\"{}\" Location=\"https://evil.example/slo\"/><NameIDFormat>",
name_id_format::EMAIL_ADDRESS,
Binding::Redirect.urn()
);
let cfg = SpMetadataConfig {
entity_id: "https://sp.example.com/metadata".into(),
name_id_format: vec![injected_format.clone()],
assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
..Default::default()
};
let sp = ServiceProvider::from_config(&cfg, EntitySetting::default())?;
assert!(sp
.metadata
.get_single_logout_service(Binding::Redirect)
.is_none());
assert_eq!(sp.setting.name_id_format, vec![injected_format]);
assert!(sp.metadata_xml().contains("<SingleLogoutService"));
Ok(())
}
#[test]
fn sp_metadata_escapes_entity_id_attribute_markup() -> TestResult {
let entity_id = "https://sp.example.com/metadata\" ID=\"_evil";
let cfg = SpMetadataConfig {
entity_id: entity_id.into(),
assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
..Default::default()
};
let xml = generate_sp_metadata(&cfg);
let parsed = SpMetadata::from_xml(&xml)?;
assert!(!xml.contains(" ID=\"_evil\""));
assert!(xml.contains("" ID="_evil"));
assert_eq!(parsed.get_entity_id(), Some(entity_id));
Ok(())
}
#[test]
fn sp_metadata_escapes_certificate_text_markup() -> TestResult {
let injected_cert = concat!(
"MIIB</ds:X509Certificate></ds:X509Data></ds:KeyInfo></KeyDescriptor>",
"<NameIDFormat>evil</NameIDFormat>",
"<KeyDescriptor><ds:KeyInfo><ds:X509Data><ds:X509Certificate>tail"
);
let cfg = SpMetadataConfig {
entity_id: "https://sp.example.com/metadata".into(),
signing_certs: vec![injected_cert.into()],
assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
elements_order: Some(vec![
"KeyDescriptor".into(),
"AssertionConsumerService".into(),
]),
..Default::default()
};
let xml = generate_sp_metadata(&cfg);
let parsed = SpMetadata::from_xml(&xml)?;
assert!(xml.contains("<NameIDFormat>evil</NameIDFormat>"));
assert!(parsed.get_name_id_format().is_empty());
assert_eq!(parsed.x509_certificates(CertUse::Signing).len(), 1);
Ok(())
}
#[test]
fn sp_custom_elements_order_preserves_omitted_populated_acs() {
let cfg = SpMetadataConfig {
entity_id: "https://sp.example.com/metadata".into(),
signing_certs: vec!["MIIBsigning".into()],
name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://sp/slo")],
assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
elements_order: Some(vec!["KeyDescriptor".into()]),
..Default::default()
};
let xml = generate_sp_metadata(&cfg);
let key = xml.find("<KeyDescriptor").unwrap_or(usize::MAX);
let acs = xml.find("<AssertionConsumerService").unwrap_or(usize::MAX);
assert_eq!(xml.matches("<AssertionConsumerService").count(), 1);
assert!(key < acs);
assert!(!xml.contains("<NameIDFormat"));
assert!(!xml.contains("<SingleLogoutService"));
}
#[test]
fn idp_metadata_escapes_endpoint_location_attribute_markup() -> TestResult {
let injected_location = format!(
"https://idp/sso\"/><SingleLogoutService Binding=\"{}\" Location=\"https://evil.example/slo",
Binding::Redirect.urn()
);
let cfg = IdpMetadataConfig {
entity_id: "https://idp.example.com/metadata".into(),
single_sign_on_service: vec![Endpoint::new(
Binding::Redirect,
injected_location.clone(),
)],
..Default::default()
};
let xml = try_generate_idp_metadata(&cfg)?;
let parsed = IdpMetadata::from_xml(&xml)?;
assert!(!xml.contains("<SingleLogoutService"));
assert_eq!(
parsed
.get_single_sign_on_service(Binding::Redirect)
.as_deref(),
Some(injected_location.as_str())
);
assert!(parsed
.get_single_logout_service(Binding::Redirect)
.is_none());
Ok(())
}
#[test]
fn idp_default_elements_order_matches_historical_output() {
let cfg = IdpMetadataConfig {
entity_id: "https://idp.example.com/metadata".into(),
signing_certs: vec!["MIIBsigning".into()],
name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://idp/slo")],
..Default::default()
};
let xml = generate_idp_metadata(&cfg);
let key = xml.find("<KeyDescriptor").unwrap_or(usize::MAX);
let name_id = xml.find("<NameIDFormat").unwrap_or(usize::MAX);
let sso = xml.find("<SingleSignOnService").unwrap_or(usize::MAX);
let slo = xml.find("<SingleLogoutService").unwrap_or(usize::MAX);
assert!(key < name_id);
assert!(name_id < sso);
assert!(sso < slo);
}
#[test]
fn idp_custom_elements_order_can_place_sso_before_key_descriptor() {
let cfg = IdpMetadataConfig {
entity_id: "https://idp.example.com/metadata".into(),
signing_certs: vec!["MIIBsigning".into()],
name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://idp/slo")],
elements_order: Some(vec![
"SingleSignOnService".into(),
"KeyDescriptor".into(),
"NameIDFormat".into(),
"SingleLogoutService".into(),
]),
..Default::default()
};
let xml = generate_idp_metadata(&cfg);
let sso = xml.find("<SingleSignOnService").unwrap_or(usize::MAX);
let key = xml.find("<KeyDescriptor").unwrap_or(usize::MAX);
let name_id = xml.find("<NameIDFormat").unwrap_or(usize::MAX);
let slo = xml.find("<SingleLogoutService").unwrap_or(usize::MAX);
assert!(sso < key);
assert!(key < name_id);
assert!(name_id < slo);
}
#[test]
fn idp_elements_order_filters_empty_groups() {
let cfg = IdpMetadataConfig {
entity_id: "https://idp.example.com/metadata".into(),
single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://idp/slo")],
elements_order: Some(vec![
"KeyDescriptor".into(),
"NameIDFormat".into(),
"SingleSignOnService".into(),
"SingleLogoutService".into(),
]),
..Default::default()
};
let xml = generate_idp_metadata(&cfg);
assert!(!xml.contains("<KeyDescriptor"));
assert!(!xml.contains("<NameIDFormat"));
assert!(xml.contains("<SingleSignOnService"));
assert!(xml.contains("<SingleLogoutService"));
}
#[test]
fn idp_custom_elements_order_omits_unlisted_optional_groups() {
let cfg = IdpMetadataConfig {
entity_id: "https://idp.example.com/metadata".into(),
signing_certs: vec!["MIIBsigning".into()],
name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://idp/slo")],
elements_order: Some(vec!["SingleSignOnService".into()]),
..Default::default()
};
let xml = generate_idp_metadata(&cfg);
assert_eq!(xml.matches("<SingleSignOnService").count(), 1);
assert!(!xml.contains("<KeyDescriptor"));
assert!(!xml.contains("<NameIDFormat"));
assert!(!xml.contains("<SingleLogoutService"));
}
#[test]
fn idp_custom_elements_order_preserves_omitted_populated_sso() {
let cfg = IdpMetadataConfig {
entity_id: "https://idp.example.com/metadata".into(),
signing_certs: vec!["MIIBsigning".into()],
single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
elements_order: Some(vec!["KeyDescriptor".into()]),
..Default::default()
};
let xml = generate_idp_metadata(&cfg);
let key = xml.find("<KeyDescriptor").unwrap_or(usize::MAX);
let sso = xml.find("<SingleSignOnService").unwrap_or(usize::MAX);
assert_eq!(xml.matches("<SingleSignOnService").count(), 1);
assert!(key < sso);
}
#[test]
fn idp_elements_order_profiles_match_upstream() {
assert_eq!(
elements_order::idp::DEFAULT,
[
"KeyDescriptor",
"NameIDFormat",
"SingleSignOnService",
"SingleLogoutService",
]
);
assert_eq!(
elements_order::idp::ONELOGIN,
[
"KeyDescriptor",
"NameIDFormat",
"SingleLogoutService",
"SingleSignOnService",
]
);
assert_eq!(
elements_order::idp::SHIBBOLETH,
[
"KeyDescriptor",
"SingleLogoutService",
"NameIDFormat",
"SingleSignOnService",
]
);
}
}