Skip to main content

rws_server/sso/saml/
metadata.rs

1//! IdP metadata: entity ID, SSO endpoint, and signing certificate.
2
3use crate::rsa_key::RsaPublicKey;
4
5use super::der::extract_subject_public_key_info;
6use super::xml;
7use crate::sso::pkce::base64url_decode;
8use crate::sso::SsoError;
9
10/// Parsed IdP SAML metadata — everything the SP needs to build an
11/// `AuthnRequest` and verify a signed `Response`.
12pub struct SamlIdpMetadata {
13    /// The IdP's `entityID`, used as the expected `Issuer` on responses.
14    pub entity_id: String,
15    /// The IdP's SSO endpoint (`SingleSignOnService/@Location`) the SP
16    /// redirects the user to. Prefers an `HTTP-POST` binding entry (this SP
17    /// only implements the POST binding for `AuthnRequest`s — see
18    /// [`super::SamlSp`]'s module docs); falls back to the first
19    /// `SingleSignOnService` entry found if no POST binding is advertised.
20    pub sso_url: String,
21    /// The IdP's signing certificate's RSA public key, used to verify
22    /// `Response`/`Assertion` signatures. Only RSA signing certificates are
23    /// supported (see [`super::assertion`]'s module docs).
24    pub signing_key: RsaPublicKey,
25}
26
27const POST_BINDING: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
28
29impl SamlIdpMetadata {
30    /// Parse IdP metadata already read into memory (e.g. from a file).
31    pub fn from_str(xml_str: &str) -> Result<Self, SsoError> {
32        let root = xml::parse(xml_str).map_err(SsoError)?;
33        let entity_descriptor = if root.name == "EntityDescriptor" { &root } else { root.find("EntityDescriptor").ok_or_else(|| SsoError("metadata is missing EntityDescriptor".into()))? };
34        let entity_id = entity_descriptor
35            .attr("entityID")
36            .ok_or_else(|| SsoError("EntityDescriptor is missing entityID".into()))?
37            .to_string();
38
39        let idp_descriptor = entity_descriptor
40            .find("IDPSSODescriptor")
41            .ok_or_else(|| SsoError("metadata is missing IDPSSODescriptor".into()))?;
42
43        let mut sso_services = Vec::new();
44        idp_descriptor.find_all("SingleSignOnService", &mut sso_services);
45        if sso_services.is_empty() {
46            return Err(SsoError("metadata has no SingleSignOnService".into()));
47        }
48        let sso_url = sso_services
49            .iter()
50            .find(|s| s.attr("Binding") == Some(POST_BINDING))
51            .or_else(|| sso_services.first())
52            .and_then(|s| s.attr("Location"))
53            .ok_or_else(|| SsoError("SingleSignOnService is missing Location".into()))?
54            .to_string();
55
56        let mut key_descriptors = Vec::new();
57        idp_descriptor.find_all("KeyDescriptor", &mut key_descriptors);
58        let signing_descriptor = key_descriptors
59            .iter()
60            .find(|k| k.attr("use") == Some("signing"))
61            .or_else(|| key_descriptors.first())
62            .ok_or_else(|| SsoError("metadata has no KeyDescriptor with a signing certificate".into()))?;
63        let cert_b64 = signing_descriptor
64            .find("X509Certificate")
65            .map(|n| n.text.clone())
66            .ok_or_else(|| SsoError("KeyDescriptor is missing X509Certificate".into()))?;
67        let cert_der = decode_base64_flexible(&cert_b64)?;
68        let spki_der = extract_subject_public_key_info(&cert_der).map_err(SsoError)?;
69        let signing_key = RsaPublicKey::from_public_key_der(&spki_der)
70            .map_err(|e| SsoError(format!("signing certificate does not contain an RSA public key: {e}")))?;
71
72        Ok(SamlIdpMetadata { entity_id, sso_url, signing_key })
73    }
74
75    /// Read and parse IdP metadata from a local file.
76    pub fn from_file(path: &str) -> Result<Self, SsoError> {
77        let contents = std::fs::read_to_string(path).map_err(|e| SsoError(format!("failed to read {path}: {e}")))?;
78        Self::from_str(&contents)
79    }
80
81    /// Fetch and parse IdP metadata from a URL.
82    pub fn from_url(url: &str) -> Result<Self, SsoError> {
83        let resp = crate::http_client::Client::new()
84            .get(url)
85            .timeout_ms(10_000)
86            .send()
87            .map_err(|e| SsoError(format!("metadata fetch failed: {e}")))?;
88        if !resp.is_success() {
89            return Err(SsoError(format!("metadata endpoint returned {}", resp.status())));
90        }
91        let body = resp.text().map_err(|e| SsoError(e.to_string()))?;
92        Self::from_str(&body)
93    }
94}
95
96/// X.509 certificates in metadata are standard base64 (not base64url) and
97/// commonly wrap at 64/76 columns — strip whitespace before decoding.
98/// `base64url_decode` already accepts the `+`/`/` alphabet as aliases for
99/// `-`/`_`, so it doubles as a standard-base64 decoder once whitespace is
100/// removed.
101#[doc(hidden)]
102pub fn decode_base64_flexible(s: &str) -> Result<Vec<u8>, SsoError> {
103    let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
104    base64url_decode(&cleaned).map_err(SsoError)
105}