//! AuthnRequest construction + HTTP-Redirect binding encoding. //! //! For SP-initiated login we build an ``, then encode it for the //! HTTP-Redirect binding: raw DEFLATE (RFC 1951) → base64 → percent-encode, //! appended as the `SAMLRequest` query parameter. AuthnRequests are sent //! unsigned in this release (the IdP must not require client signatures). use std::io::Write as _; use base64::Engine; use flate2::write::DeflateEncoder; use flate2::Compression; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; use super::{SamlError, SpParams}; use crate::encoding::{pct_encode, xml_escape}; const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol"; const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion"; const NAMEID_EMAIL: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"; /// A built AuthnRequest, ready to redirect the browser to the IdP. #[derive(Debug, Clone)] pub struct AuthnRequest { /// The request `ID` — the caller records this so the matching response's /// `InResponseTo` can be correlated (replay/CSRF protection). pub id: String, /// The full IdP URL to 302 the browser to (includes `SAMLRequest` and, /// when supplied, `RelayState`). pub location: String, } /// Build an AuthnRequest targeting `idp_sso_url` and encode it for the /// HTTP-Redirect binding. `relay_state`, if given, round-trips back to us via /// the response (we use it to send the operator to their intended page). pub fn build( sp: &SpParams, idp_sso_url: &str, relay_state: Option<&str>, ) -> Result { let id = format!("_{}", uuid::Uuid::new_v4().simple()); let issue_instant = OffsetDateTime::now_utc() .replace_nanosecond(0) .unwrap_or_else(|_| OffsetDateTime::now_utc()) .format(&Rfc3339) .map_err(|e| SamlError::Timestamp(e.to_string()))?; let xml = format!( r#"{issuer}"#, instant = issue_instant, dest = xml_escape(idp_sso_url), acs = xml_escape(&sp.acs_url), issuer = xml_escape(&sp.entity_id), ); let encoded = deflate_base64(&xml)?; let sep = if idp_sso_url.contains('?') { '&' } else { '?' }; let mut location = format!("{idp_sso_url}{sep}SAMLRequest={}", pct_encode(&encoded)); if let Some(rs) = relay_state { location.push_str("&RelayState="); location.push_str(&pct_encode(rs)); } Ok(AuthnRequest { id, location }) } /// Raw-DEFLATE then base64 — the HTTP-Redirect binding's `SAMLRequest` payload. fn deflate_base64(xml: &str) -> Result { let mut enc = DeflateEncoder::new(Vec::new(), Compression::default()); enc.write_all(xml.as_bytes()) .and_then(|()| enc.try_finish()) .map_err(|e| SamlError::Xml(format!("deflate: {e}")))?; let compressed = enc .finish() .map_err(|e| SamlError::Xml(format!("deflate: {e}")))?; Ok(base64::engine::general_purpose::STANDARD.encode(compressed)) } // `pct_encode` + `xml_escape` now live in `openpxe_core::encoding` (v0.5.4) // — imported above. #[cfg(test)] mod tests { use super::*; use flate2::read::DeflateDecoder; use std::io::Read; fn sp() -> SpParams { SpParams { entity_id: "https://pxe.example.com".into(), acs_url: "https://pxe.example.com/api/sso/acs".into(), } } fn pct_decode(s: &str) -> Vec { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' && i + 2 < bytes.len() { let hi = (bytes[i + 1] as char).to_digit(16).unwrap(); let lo = (bytes[i + 2] as char).to_digit(16).unwrap(); out.push((hi * 16 + lo) as u8); i += 3; } else { out.push(bytes[i]); i += 1; } } out } #[test] fn id_is_ncname_and_location_has_request() { let req = build(&sp(), "https://idp.example.com/sso", Some("/dashboard")).unwrap(); assert!(req.id.starts_with('_')); assert!(req .location .starts_with("https://idp.example.com/sso?SAMLRequest=")); assert!(req.location.contains("&RelayState=%2Fdashboard")); } #[test] fn redirect_payload_round_trips_to_our_authn_request() { let req = build(&sp(), "https://idp.example.com/sso", None).unwrap(); // Pull SAMLRequest value out of the query string. let q = req.location.split("SAMLRequest=").nth(1).unwrap(); let val = q.split('&').next().unwrap(); let compressed = base64::engine::general_purpose::STANDARD .decode(pct_decode(val)) .unwrap(); let mut inflate = DeflateDecoder::new(&compressed[..]); let mut xml = String::new(); inflate.read_to_string(&mut xml).unwrap(); let doc = roxmltree::Document::parse(&xml).unwrap(); let root = doc.root_element(); assert_eq!(root.tag_name().name(), "AuthnRequest"); assert_eq!(root.attribute("ID").unwrap(), req.id); assert_eq!( root.attribute("AssertionConsumerServiceURL").unwrap(), "https://pxe.example.com/api/sso/acs" ); let issuer = root .descendants() .find(|n| n.tag_name().name() == "Issuer") .unwrap(); assert_eq!(issuer.text().unwrap(), "https://pxe.example.com"); } #[test] fn existing_query_uses_ampersand_separator() { let req = build(&sp(), "https://idp.example.com/sso?foo=bar", None).unwrap(); assert!(req.location.contains("?foo=bar&SAMLRequest=")); } }