Skip to main content

saml_rs/metadata/
mod.rs

1//! SAML metadata parsing and shared SP/IdP metadata accessors.
2
3pub mod generate;
4pub mod idp;
5pub mod sp;
6mod write;
7
8#[cfg(feature = "crypto-bergshamra")]
9pub use crate::crypto::MetadataSignatureVerification;
10pub use generate::{
11    generate_idp_metadata, generate_sp_metadata, try_generate_idp_metadata, Endpoint,
12    IdpMetadataConfig, SpMetadataConfig,
13};
14pub use idp::IdpMetadata;
15pub use sp::SpMetadata;
16
17use crate::constants::{Binding, CertUse};
18use crate::error::SamlError;
19use crate::util::Value;
20use crate::xml::{dom, extract_with_limits, ExtractorField, XmlLimits};
21
22fn base_fields() -> Vec<ExtractorField> {
23    vec![
24        ExtractorField::new("entityID", &["EntityDescriptor"]).attrs(&["entityID"]),
25        ExtractorField::new(
26            "sharedCertificate",
27            &[
28                "EntityDescriptor",
29                "~SSODescriptor",
30                "KeyDescriptor",
31                "KeyInfo",
32                "X509Data",
33                "X509Certificate",
34            ],
35        ),
36        ExtractorField::new(
37            "certificate",
38            &["EntityDescriptor", "~SSODescriptor", "KeyDescriptor"],
39        )
40        .aggregate(&["use"], &["KeyInfo", "X509Data", "X509Certificate"]),
41        ExtractorField::new(
42            "singleLogoutService",
43            &["EntityDescriptor", "~SSODescriptor", "SingleLogoutService"],
44        )
45        .attrs(&["Binding", "Location"]),
46        ExtractorField::new(
47            "nameIDFormat",
48            &["EntityDescriptor", "~SSODescriptor", "NameIDFormat"],
49        ),
50    ]
51}
52
53/// Normalise a "single object or array of objects" value into a node list.
54pub(crate) fn as_object_list(value: &Value) -> Vec<&Value> {
55    match value {
56        Value::Array(items) => items.iter().collect(),
57        Value::Object(_) => vec![value],
58        _ => Vec::new(),
59    }
60}
61
62fn location_for_binding(value: Option<&Value>, binding: Binding) -> Option<String> {
63    let value = value?;
64    for obj in as_object_list(value) {
65        if obj.get_str("binding") == Some(binding.urn()) {
66            return obj.get_str("location").map(str::to_string);
67        }
68    }
69    None
70}
71
72/// Parsed entity metadata (the base shared by SP and IdP).
73#[derive(Debug, Clone)]
74pub struct Metadata {
75    xml: String,
76    pub(crate) meta: Value,
77}
78
79impl Metadata {
80    /// Parse `xml`, adding the role-specific `extra` extractor fields.
81    ///
82    /// Rejects documents carrying more than one top-level `<EntityDescriptor>`.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`SamlError`] when XML parsing, parser resource limits,
87    /// extraction, or the single-`EntityDescriptor` check fails.
88    pub fn parse(xml: &str, extra: Vec<ExtractorField>) -> Result<Self, SamlError> {
89        Self::parse_with_limits(xml, extra, XmlLimits::default())
90    }
91
92    /// Parse `xml` with explicit XML parser resource limits.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`SamlError`] when XML parsing, parser resource limits,
97    /// extraction, or the single-`EntityDescriptor` check fails.
98    pub fn parse_with_limits(
99        xml: &str,
100        extra: Vec<ExtractorField>,
101        limits: XmlLimits,
102    ) -> Result<Self, SamlError> {
103        let roots = dom::parse_roots_with_limits(xml, limits)?;
104        if roots
105            .iter()
106            .filter(|n| n.local_name == "EntityDescriptor")
107            .count()
108            > 1
109        {
110            return Err(SamlError::Xml(
111                "ERR_MULTIPLE_METADATA_ENTITYDESCRIPTOR".into(),
112            ));
113        }
114
115        let mut fields = base_fields();
116        fields.extend(extra);
117        let mut meta = extract_with_limits(xml, &fields, limits)?;
118
119        // A single shared certificate is used for both signing and encryption.
120        if let Some(shared) = meta.get_str("sharedCertificate") {
121            let shared = shared.to_string();
122            meta.insert(
123                "certificate",
124                Value::Object(vec![
125                    ("signing".into(), Value::Str(shared.clone())),
126                    ("encryption".into(), Value::Str(shared)),
127                ]),
128            );
129        }
130
131        Ok(Self {
132            xml: xml.to_string(),
133            meta,
134        })
135    }
136
137    /// The original metadata XML.
138    pub fn get_metadata(&self) -> &str {
139        &self.xml
140    }
141
142    /// `entityID`.
143    pub fn get_entity_id(&self) -> Option<&str> {
144        self.meta.get_str("entityID")
145    }
146
147    /// Declared `<NameIDFormat>` values.
148    pub fn get_name_id_format(&self) -> Vec<String> {
149        match self.meta.get("nameIDFormat") {
150            Some(Value::Array(items)) => items
151                .iter()
152                .filter_map(Value::as_str)
153                .map(str::to_string)
154                .collect(),
155            Some(Value::Str(s)) => vec![s.clone()],
156            _ => Vec::new(),
157        }
158    }
159
160    /// All X.509 certificates declared for `use` (raw, as written in metadata).
161    pub fn x509_certificates(&self, use_: CertUse) -> Vec<String> {
162        match self
163            .meta
164            .get("certificate")
165            .and_then(|c| c.get_key(use_.as_str()))
166        {
167            Some(Value::Str(s)) => vec![s.clone()],
168            Some(Value::Array(items)) => items
169                .iter()
170                .filter_map(Value::as_str)
171                .map(str::to_string)
172                .collect(),
173            _ => Vec::new(),
174        }
175    }
176
177    /// First X.509 certificate declared for `use`.
178    pub fn get_x509_certificate(&self, use_: CertUse) -> Option<String> {
179        self.x509_certificates(use_).into_iter().next()
180    }
181
182    /// `SingleLogoutService` location for `binding`.
183    pub fn get_single_logout_service(&self, binding: Binding) -> Option<String> {
184        location_for_binding(self.meta.get("singleLogoutService"), binding)
185    }
186
187    /// Write the metadata XML to `path`.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`std::io::Error`] if the filesystem write fails.
192    pub fn export_metadata(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
193        std::fs::write(path, &self.xml)
194    }
195
196    /// Bindings for which a `SingleLogoutService` endpoint is declared.
197    pub fn get_support_bindings(&self) -> Vec<Binding> {
198        [Binding::Redirect, Binding::Post, Binding::SimpleSign]
199            .into_iter()
200            .filter(|b| self.get_single_logout_service(*b).is_some())
201            .collect()
202    }
203
204    /// Verify this metadata document's enveloped signature against trusted
205    /// certificate(s) (federation trust anchor). Requires `crypto-bergshamra`.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`SamlError`] when XML parsing, certificate loading,
210    /// cryptographic verification, or signed `<EntityDescriptor>` coverage
211    /// checks fail.
212    #[cfg(feature = "crypto-bergshamra")]
213    pub fn verify_signature(&self, trusted_certificates: &[String]) -> Result<bool, SamlError> {
214        self.verify_signature_with_limits(trusted_certificates, XmlLimits::default())
215    }
216
217    /// Verify this metadata document's signature with explicit XML parser limits.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`SamlError`] when XML parsing, certificate loading,
222    /// cryptographic verification, or signed `<EntityDescriptor>` coverage
223    /// checks fail.
224    #[cfg(feature = "crypto-bergshamra")]
225    pub fn verify_signature_with_limits(
226        &self,
227        trusted_certificates: &[String],
228        limits: XmlLimits,
229    ) -> Result<bool, SamlError> {
230        crate::crypto::verify_metadata_signature_with_limits(
231            &self.xml,
232            trusted_certificates,
233            limits,
234        )
235    }
236
237    /// Verify this metadata document's signature and preserve signed
238    /// `<EntityDescriptor>` coverage evidence using default XML parser limits.
239    ///
240    /// # Errors
241    ///
242    /// Returns [`SamlError`] when XML parsing, certificate loading,
243    /// cryptographic verification, transform policy, or signed
244    /// `<EntityDescriptor>` coverage checks fail.
245    #[cfg(feature = "crypto-bergshamra")]
246    pub fn verify_signature_detailed(
247        &self,
248        trusted_certificates: &[String],
249    ) -> Result<crate::crypto::MetadataSignatureVerification, SamlError> {
250        crate::crypto::verify_metadata_signature_detailed(&self.xml, trusted_certificates)
251    }
252
253    /// Verify this metadata document's signature and preserve signed
254    /// `<EntityDescriptor>` coverage evidence.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`SamlError`] when XML parsing, certificate loading,
259    /// cryptographic verification, transform policy, or signed
260    /// `<EntityDescriptor>` coverage checks fail.
261    #[cfg(feature = "crypto-bergshamra")]
262    pub fn verify_signature_detailed_with_limits(
263        &self,
264        trusted_certificates: &[String],
265        limits: XmlLimits,
266    ) -> Result<MetadataSignatureVerification, SamlError> {
267        crate::crypto::verify_metadata_signature_detailed_with_limits(
268            &self.xml,
269            trusted_certificates,
270            limits,
271        )
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    const IDPMETA: &str = include_str!("../../tests/fixtures/idpmeta.xml");
280    const SPMETA: &str = include_str!("../../tests/fixtures/spmeta.xml");
281    const MULTIPLE: &str = include_str!("../../tests/fixtures/multiple_entitydescriptor.xml");
282
283    #[test]
284    fn rejects_multiple_entity_descriptors() {
285        assert!(Metadata::parse(MULTIPLE, Vec::new()).is_err());
286    }
287
288    #[test]
289    fn parses_idp_metadata() -> Result<(), Box<dyn std::error::Error>> {
290        let idp = IdpMetadata::from_xml(IDPMETA)?;
291        assert_eq!(
292            idp.get_entity_id(),
293            Some("https://idp.example.com/metadata")
294        );
295        assert!(idp.is_want_authn_requests_signed());
296        assert_eq!(
297            idp.get_single_sign_on_service(Binding::Redirect).as_deref(),
298            Some("https://idp.example.org/sso/SingleSignOnService")
299        );
300        assert!(idp.get_x509_certificate(CertUse::Signing).is_some());
301        assert!(idp
302            .get_name_id_format()
303            .iter()
304            .any(|f| f.contains("persistent")));
305        Ok(())
306    }
307
308    #[test]
309    fn parses_sp_metadata() -> Result<(), Box<dyn std::error::Error>> {
310        let sp = SpMetadata::from_xml(SPMETA)?;
311        assert_eq!(sp.get_entity_id(), Some("https://sp.example.org/metadata"));
312        assert!(sp.is_want_assertions_signed());
313        assert!(sp.is_authn_request_signed());
314        assert_eq!(
315            sp.get_assertion_consumer_service(Binding::Post).as_deref(),
316            Some("https://sp.example.org/sp/sso")
317        );
318        assert_eq!(
319            sp.get_single_logout_service(Binding::Redirect).as_deref(),
320            Some("https://sp.example.org/sp/slo")
321        );
322        assert!(sp.get_x509_certificate(CertUse::Encryption).is_some());
323        Ok(())
324    }
325
326    #[test]
327    fn support_bindings_and_export() -> Result<(), Box<dyn std::error::Error>> {
328        let sp = SpMetadata::from_xml(SPMETA)?;
329        assert!(sp.get_support_bindings().contains(&Binding::Redirect));
330        let mut path = std::env::temp_dir();
331        path.push(format!("saml_rs_md_{}.xml", std::process::id()));
332        sp.export_metadata(&path)?;
333        assert_eq!(std::fs::read_to_string(&path)?, sp.get_metadata());
334        std::fs::remove_file(&path)?;
335        Ok(())
336    }
337}