use crate::constants::{elements_order, name_id_format, namespace, Binding};
use crate::util::{is_non_empty_array, normalize_cert_string};
#[derive(Debug, Clone)]
pub struct Endpoint {
pub binding: Binding,
pub location: String,
pub is_default: bool,
}
impl Endpoint {
pub fn new(binding: Binding, location: impl Into<String>) -> Self {
Self {
binding,
location: location.into(),
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>,
}
fn key_descriptor(use_: &str, cert: &str) -> String {
format!(
"<KeyDescriptor use=\"{use_}\"><ds:KeyInfo xmlns:ds=\"{dsig}\"><ds:X509Data><ds:X509Certificate>{cert}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></KeyDescriptor>",
dsig = namespace::DSIG,
cert = normalize_cert_string(cert),
)
}
fn key_descriptors(signing: &[String], encrypt: &[String]) -> String {
let mut out = String::new();
for cert in signing {
out.push_str(&key_descriptor("signing", cert));
}
for cert in encrypt {
out.push_str(&key_descriptor("encryption", cert));
}
out
}
fn name_id_formats(formats: &[String]) -> String {
let defaulted: Vec<String> = if is_non_empty_array(formats) {
formats.to_vec()
} else {
vec![name_id_format::EMAIL_ADDRESS.to_string()]
};
defaulted
.iter()
.map(|f| format!("<NameIDFormat>{f}</NameIDFormat>"))
.collect()
}
fn endpoint_attrs(e: &Endpoint, index: Option<usize>) -> String {
use crate::binding::xml_escape;
let mut attrs = String::new();
if let Some(i) = index {
attrs.push_str(&format!(" index=\"{i}\""));
}
if e.is_default {
attrs.push_str(" isDefault=\"true\"");
}
attrs.push_str(&format!(
" Binding=\"{}\" Location=\"{}\"",
e.binding.urn(),
xml_escape(&e.location)
));
attrs
}
fn single_logout(endpoints: &[Endpoint]) -> String {
endpoints
.iter()
.map(|e| format!("<SingleLogoutService{}/>", endpoint_attrs(e, None)))
.collect()
}
pub fn generate_sp_metadata(cfg: &SpMetadataConfig) -> String {
let order = cfg.elements_order.clone().unwrap_or_else(|| {
elements_order::DEFAULT
.iter()
.map(|s| s.to_string())
.collect()
});
let acs: String = cfg
.assertion_consumer_service
.iter()
.enumerate()
.map(|(i, e)| format!("<AssertionConsumerService{}/>", endpoint_attrs(e, Some(i))))
.collect();
let mut body = String::new();
for name in &order {
match name.as_str() {
"KeyDescriptor" => {
body.push_str(&key_descriptors(&cfg.signing_certs, &cfg.encrypt_certs))
}
"NameIDFormat" => body.push_str(&name_id_formats(&cfg.name_id_format)),
"SingleLogoutService" => body.push_str(&single_logout(&cfg.single_logout_service)),
"AssertionConsumerService" => body.push_str(&acs),
_ => {}
}
}
format!(
"<EntityDescriptor entityID=\"{entity}\" xmlns=\"{md}\" xmlns:assertion=\"{assertion}\" xmlns:ds=\"{dsig}\"><SPSSODescriptor AuthnRequestsSigned=\"{ars}\" WantAssertionsSigned=\"{was}\" protocolSupportEnumeration=\"{protocol}\">{body}</SPSSODescriptor></EntityDescriptor>",
entity = cfg.entity_id,
md = namespace::METADATA,
assertion = namespace::ASSERTION,
dsig = namespace::DSIG,
ars = cfg.authn_requests_signed,
was = cfg.want_assertions_signed,
protocol = namespace::PROTOCOL,
)
}
pub fn generate_idp_metadata(cfg: &IdpMetadataConfig) -> String {
let sso: String = cfg
.single_sign_on_service
.iter()
.map(|e| format!("<SingleSignOnService{}/>", endpoint_attrs(e, None)))
.collect();
let body = format!(
"{keys}{formats}{sso}{slo}",
keys = key_descriptors(&cfg.signing_certs, &cfg.encrypt_certs),
formats = if is_non_empty_array(&cfg.name_id_format) {
name_id_formats(&cfg.name_id_format)
} else {
String::new()
},
sso = sso,
slo = single_logout(&cfg.single_logout_service),
);
format!(
"<EntityDescriptor entityID=\"{entity}\" xmlns=\"{md}\" xmlns:assertion=\"{assertion}\" xmlns:ds=\"{dsig}\"><IDPSSODescriptor WantAuthnRequestsSigned=\"{wars}\" protocolSupportEnumeration=\"{protocol}\">{body}</IDPSSODescriptor></EntityDescriptor>",
entity = cfg.entity_id,
md = namespace::METADATA,
assertion = namespace::ASSERTION,
dsig = namespace::DSIG,
wars = cfg.want_authn_requests_signed,
protocol = namespace::PROTOCOL,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::CertUse;
use crate::metadata::{IdpMetadata, SpMetadata};
#[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(),
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(())
}
}