Skip to main content

rws_server/sso/saml/
mod.rs

1//! `SamlSp` — a SAML 2.0 Service Provider middleware.
2//!
3//! Enterprise / B2B SSO (AD FS, Okta SAML, Google Workspace SAML), as an
4//! alternative to the OIDC-based [`super::OidcAuth`] for IdPs that only
5//! speak SAML.
6//!
7//! # Usage
8//!
9//! ```rust,no_run
10//! # extern crate rws_server as rust_web_server;
11//! use std::sync::Arc;
12//! use rust_web_server::app::App;
13//! use rust_web_server::core::New;
14//! use rust_web_server::session::SessionStore;
15//! use rust_web_server::sso::saml::{SamlSp, SamlConfig, SamlIdpMetadata, AttributeMap};
16//!
17//! let saml_sp = SamlSp::new(SamlConfig {
18//!     sp_entity_id: "https://myapp.com/saml/metadata".into(),
19//!     sp_acs_url:   "https://myapp.com/saml/acs".into(),
20//!     idp_metadata: SamlIdpMetadata::from_file("idp-metadata.xml").unwrap(),
21//!     sessions:     Arc::new(SessionStore::new(86_400)),
22//! })
23//! .attribute_map(
24//!     AttributeMap::new()
25//!         .map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "email")
26//!         .map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "name"),
27//! );
28//!
29//! let app = App::new().wrap(saml_sp);
30//! // Registers: GET /saml/metadata, GET /saml/login, POST /saml/acs, GET /saml/logout
31//! ```
32//!
33//! # Deviations from the original design sketch
34//!
35//! - **No `quick-xml` dependency.** This module hand-rolls a small,
36//!   purpose-built XML parser ([`xml`], private) instead — see that
37//!   module's docs for why. `sso-saml` adds no new dependency beyond what
38//!   `sso` already pulls in.
39//! - **Signature verification is byte-exact, not full XML C14N** — see
40//!   [`assertion`]'s (private) module docs for the detailed rationale and
41//!   its fail-closed-not-fail-open safety argument. Only `RSA-SHA256` is
42//!   supported.
43//! - **`AuthnRequest`s use the HTTP-POST binding, not HTTP-Redirect.**
44//!   HTTP-Redirect requires DEFLATE-compressing the request, and this
45//!   crate has no compression dependency to reuse (`gzip` output
46//!   elsewhere in this crate is hand-rolled for *responses*, not a
47//!   general-purpose DEFLATE encoder suitable for reuse here). HTTP-POST
48//!   is an equally spec-compliant binding — an auto-submitting HTML form
49//!   POSTs the base64-encoded, *unsigned* `AuthnRequest` XML to the IdP.
50//! - **`AuthnRequest`s are never signed** — `sign_requests`/
51//!   `sp_private_key` from the design sketch don't exist. This crate has
52//!   no private-key PEM/DER parser (the same reason [`super::server`]'s
53//!   `AuthServer` signs its own tokens HS256 instead of RSA/EC), and most
54//!   IdPs (Okta, Azure AD, Google Workspace, Keycloak) accept unsigned
55//!   `AuthnRequest`s by default — the flow's security rests on the
56//!   IdP-signed *Assertion*, which this module does verify.
57//! - **Logout is local-only.** `/saml/logout` destroys the SP's own
58//!   session and redirects home; there is no SP-initiated
59//!   `LogoutRequest`/`LogoutResponse` exchange with the IdP (true SAML
60//!   Single Logout). This mirrors [`super::OidcAuth`]'s own logout, which
61//!   draws the identical boundary for OIDC's `end_session_endpoint`.
62//! - **`AttributeMap` maps into a plain `name_id`/`attributes` struct
63//!   ([`SamlClaims`]), not [`super::OidcClaims`].** The design sketch's own
64//!   example maps a `groups` attribute, but `OidcClaims` (Phase 2) was
65//!   deliberately built without a `groups` field — reusing it here would
66//!   silently drop exactly the attribute the sketch's own example asks to
67//!   map. SAML's free-form attribute model is a better fit for a plain map
68//!   than OIDC's fixed claim set.
69
70mod assertion;
71mod der;
72mod metadata;
73mod xml;
74
75#[cfg(any(fuzzing, test, feature = "test-support"))]
76#[doc(hidden)]
77pub fn xml_parse_for_tests(input: &str) -> Result<(), String> {
78    xml::parse(input).map(|_| ())
79}
80
81#[cfg(fuzzing)]
82#[doc(hidden)]
83pub fn xml_parse_for_fuzzing(input: &str) -> Result<(), String> {
84    xml::parse(input).map(|_| ())
85}
86
87#[cfg(test)]
88mod tests;
89
90use std::collections::HashMap;
91use std::sync::Arc;
92use std::time::{SystemTime, UNIX_EPOCH};
93
94use rand_core::{OsRng, RngCore};
95use serde::{Deserialize, Serialize};
96
97use crate::application::Application;
98use crate::body::form_urlencoded::FormUrlEncoded;
99use crate::core::New;
100use crate::extract::{FromRequest, Query};
101use crate::header::Header;
102use crate::middleware::Middleware;
103use crate::mime_type::MimeType;
104use crate::range::Range;
105use crate::request::{Request, METHOD};
106use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
107use crate::server::ConnectionInfo;
108use crate::session::{destroy_cookie, session_cookie, session_id_from_request, SessionStore};
109
110pub use metadata::SamlIdpMetadata;
111
112use metadata::decode_base64_flexible;
113
114const SESSION_COOKIE: &str = "_rws_saml_sid";
115const SESSION_TTL: u64 = 86_400;
116/// Request header name used to pass SAML claims into downstream handlers.
117pub const CLAIMS_HEADER: &str = "X-Rws-Saml-Claims";
118const METADATA_PATH: &str = "/saml/metadata";
119const LOGIN_PATH: &str = "/saml/login";
120const ACS_PATH: &str = "/saml/acs";
121const LOGOUT_PATH: &str = "/saml/logout";
122
123/// Maps IdP-specific SAML attribute names to caller-chosen field names.
124///
125/// # Example
126///
127/// ```rust
128/// # extern crate rws_server as rust_web_server;
129/// use rust_web_server::sso::saml::AttributeMap;
130///
131/// let map = AttributeMap::new()
132///     .map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "email")
133///     .map("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups", "groups");
134/// ```
135#[derive(Clone, Default)]
136pub struct AttributeMap {
137    mappings: Vec<(String, String)>,
138}
139
140impl AttributeMap {
141    /// An empty map — attributes are exposed only under their raw SAML
142    /// `Attribute/@Name`.
143    pub fn new() -> Self {
144        AttributeMap { mappings: Vec::new() }
145    }
146
147    /// Map a raw SAML attribute name to a field name in [`SamlClaims::attributes`].
148    pub fn map(mut self, saml_attribute_name: &str, field: &str) -> Self {
149        self.mappings.push((saml_attribute_name.to_string(), field.to_string()));
150        self
151    }
152
153    fn apply(&self, raw: &HashMap<String, String>) -> HashMap<String, String> {
154        let mut out = HashMap::new();
155        for (saml_name, field) in &self.mappings {
156            if let Some(v) = raw.get(saml_name) {
157                out.insert(field.clone(), v.clone());
158            }
159        }
160        out
161    }
162}
163
164/// The verified identity from a completed SAML login, injected as the
165/// [`CLAIMS_HEADER`] request header (JSON).
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct SamlClaims {
168    /// The `Subject/NameID` value.
169    pub name_id: String,
170    /// Attributes translated via [`AttributeMap`] (mapped field name → value).
171    pub attributes: HashMap<String, String>,
172}
173
174/// Configuration for [`SamlSp`].
175pub struct SamlConfig {
176    /// This SP's own entity ID (an arbitrary, globally-unique URI —
177    /// conventionally its own metadata URL).
178    pub sp_entity_id: String,
179    /// This SP's Assertion Consumer Service URL — must exactly match what
180    /// is registered with the IdP and what `SubjectConfirmationData/@Recipient`
181    /// names on every assertion.
182    pub sp_acs_url: String,
183    /// The IdP's metadata (entity ID, SSO URL, signing certificate).
184    pub idp_metadata: SamlIdpMetadata,
185    /// Session store used to track the pre-login `AuthnRequest` ID and,
186    /// after a successful login, the established [`SamlClaims`].
187    pub sessions: Arc<SessionStore>,
188}
189
190/// A SAML 2.0 Service Provider — see the [module docs](self).
191pub struct SamlSp {
192    config: Arc<SamlConfig>,
193    attribute_map: AttributeMap,
194    post_login_redirect: String,
195    login_path: String,
196    acs_path: String,
197    logout_path: String,
198    metadata_path: String,
199}
200
201impl SamlSp {
202    /// Create a new `SamlSp`. Defaults `post_login_redirect` to `/` and the
203    /// four intercepted paths to `/saml/{metadata,login,acs,logout}`.
204    pub fn new(config: SamlConfig) -> Self {
205        SamlSp {
206            config: Arc::new(config),
207            attribute_map: AttributeMap::new(),
208            post_login_redirect: "/".to_string(),
209            login_path: LOGIN_PATH.to_string(),
210            acs_path: ACS_PATH.to_string(),
211            logout_path: LOGOUT_PATH.to_string(),
212            metadata_path: METADATA_PATH.to_string(),
213        }
214    }
215
216    /// Set the [`AttributeMap`] translating IdP attribute names to claim
217    /// field names.
218    pub fn attribute_map(mut self, map: AttributeMap) -> Self {
219        self.attribute_map = map;
220        self
221    }
222
223    /// Override the default post-login redirect path (default: `/`).
224    pub fn post_login_redirect(mut self, path: &str) -> Self {
225        self.post_login_redirect = path.to_string();
226        self
227    }
228
229    /// Override the login path (default: `/saml/login`).
230    pub fn login_path(mut self, path: &str) -> Self {
231        self.login_path = path.to_string();
232        self
233    }
234
235    /// Override the ACS path (default: `/saml/acs`).
236    pub fn acs_path(mut self, path: &str) -> Self {
237        self.acs_path = path.to_string();
238        self
239    }
240
241    /// Override the logout path (default: `/saml/logout`).
242    pub fn logout_path(mut self, path: &str) -> Self {
243        self.logout_path = path.to_string();
244        self
245    }
246
247    /// Override the SP metadata path (default: `/saml/metadata`).
248    pub fn metadata_path(mut self, path: &str) -> Self {
249        self.metadata_path = path.to_string();
250        self
251    }
252
253    /// Read the SAML claims injected by `SamlSp` from a request. Returns
254    /// `None` if the request did not pass through an authenticated
255    /// `SamlSp` layer.
256    pub fn claims(req: &Request) -> Option<SamlClaims> {
257        req.headers
258            .iter()
259            .find(|h| h.name.eq_ignore_ascii_case(CLAIMS_HEADER))
260            .and_then(|h| serde_json::from_str(&h.value).ok())
261    }
262
263    /// Shortcut: return the `NameID` from the injected claims.
264    pub fn name_id(req: &Request) -> Option<String> {
265        Self::claims(req).map(|c| c.name_id)
266    }
267
268    /// Shortcut: return a mapped attribute field from the injected claims.
269    pub fn attr(req: &Request, field: &str) -> Option<String> {
270        Self::claims(req).and_then(|c| c.attributes.get(field).cloned())
271    }
272
273    // ── route handlers ────────────────────────────────────────────────────────
274
275    fn handle_metadata(&self) -> Response {
276        let xml = format!(
277            r#"<?xml version="1.0" encoding="UTF-8"?><EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entity}"><SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnabled="urn:oasis:names:tc:SAML:2.0:protocol"><AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{acs}" index="0" isDefault="true"/></SPSSODescriptor></EntityDescriptor>"#,
278            entity = xml_escape(&self.config.sp_entity_id),
279            acs = xml_escape(&self.config.sp_acs_url),
280        );
281        let mut r = Response::new();
282        r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
283        r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
284        r.content_range_list = vec![Range::get_content_range(xml.into_bytes(), "application/samlmetadata+xml".to_string())];
285        r
286    }
287
288    fn handle_login(&self, request: &Request) -> Response {
289        let query = Query::from_request(request).map(|q| q.0).unwrap_or_default();
290        let return_to = query.get("return_to").cloned().unwrap_or_else(|| self.post_login_redirect.clone());
291
292        let request_id = format!("_{}", random_hex(16));
293        let authn_request_xml = format!(
294            r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{id}" Version="2.0" IssueInstant="{issued}" Destination="{dest}" AssertionConsumerServiceURL="{acs}" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"><saml:Issuer>{issuer}</saml:Issuer></samlp:AuthnRequest>"#,
295            id = request_id,
296            issued = iso8601_now(),
297            dest = xml_escape(&self.config.idp_metadata.sso_url),
298            acs = xml_escape(&self.config.sp_acs_url),
299            issuer = xml_escape(&self.config.sp_entity_id),
300        );
301        let saml_request_b64 = base64_standard_encode(authn_request_xml.as_bytes());
302
303        let mut session = self.config.sessions.create();
304        session.set("_saml_request_id", &request_id);
305        session.set("_saml_return_to", &return_to);
306        self.config.sessions.save(&session);
307
308        let html = format!(
309            r#"<!doctype html><html><body onload="document.forms[0].submit()"><noscript><p>Click the button to continue.</p></noscript><form method="POST" action="{action}"><input type="hidden" name="SAMLRequest" value="{req}"/><noscript><button type="submit">Continue</button></noscript></form></body></html>"#,
310            action = xml_escape(&self.config.idp_metadata.sso_url),
311            req = xml_escape(&saml_request_b64),
312        );
313        let mut r = Response::new();
314        r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
315        r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
316        r.content_range_list = vec![Range::get_content_range(html.into_bytes(), MimeType::TEXT_HTML.to_string())];
317        r.headers.push(Header { name: "Set-Cookie".into(), value: session_cookie(&session.id, SESSION_COOKIE, SESSION_TTL).into() });
318        r
319    }
320
321    fn handle_acs(&self, request: &Request) -> Response {
322        let sid = match session_id_from_request(request, SESSION_COOKIE) {
323            Some(id) => id,
324            None => return Self::error_response("missing SP session cookie"),
325        };
326        let mut session = match self.config.sessions.load(&sid) {
327            Some(s) => s,
328            None => return Self::error_response("unknown or expired SP session"),
329        };
330        let request_id = session.get("_saml_request_id").map(|s| s.to_string());
331        let return_to = session.get("_saml_return_to").unwrap_or(&self.post_login_redirect).to_string();
332
333        let form = match FormUrlEncoded::parse(request.body.to_vec()) {
334            Ok(f) => f,
335            Err(e) => return Self::error_response(&format!("malformed ACS request body: {e}")),
336        };
337        let saml_response_b64 = match form.get("SAMLResponse") {
338            Some(v) => v,
339            None => return Self::error_response("missing SAMLResponse field"),
340        };
341        let raw_xml_bytes = match decode_base64_flexible(saml_response_b64) {
342            Ok(b) => b,
343            Err(e) => return Self::error_response(&e.0),
344        };
345        let raw_xml = match String::from_utf8(raw_xml_bytes) {
346            Ok(s) => s,
347            Err(_) => return Self::error_response("SAMLResponse is not valid UTF-8"),
348        };
349
350        let verified = assertion::parse_and_verify(
351            &raw_xml,
352            &self.config.idp_metadata.entity_id,
353            &self.config.idp_metadata.signing_key,
354            &self.config.sp_entity_id,
355            &self.config.sp_acs_url,
356            request_id.as_deref(),
357            unix_now(),
358        );
359        let verified = match verified {
360            Ok(v) => v,
361            Err(e) => return Self::error_response(&e.0),
362        };
363
364        let claims = SamlClaims {
365            name_id: verified.name_id,
366            attributes: self.attribute_map.apply(&verified.attributes),
367        };
368
369        session.remove("_saml_request_id");
370        session.remove("_saml_return_to");
371        let claims_json = serde_json::to_string(&claims).unwrap_or_default();
372        session.set("_saml_claims", &claims_json);
373        self.config.sessions.save(&session);
374
375        let mut r = Self::redirect(&return_to);
376        r.headers.push(Header { name: "Set-Cookie".into(), value: session_cookie(&session.id, SESSION_COOKIE, SESSION_TTL).into() });
377        r
378    }
379
380    fn handle_logout(&self, request: &Request) -> Response {
381        if let Some(sid) = session_id_from_request(request, SESSION_COOKIE) {
382            self.config.sessions.destroy(&sid);
383        }
384        let mut r = Self::redirect("/");
385        r.headers.push(Header { name: "Set-Cookie".into(), value: destroy_cookie(SESSION_COOKIE).into() });
386        r
387    }
388
389    fn redirect(url: &str) -> Response {
390        let mut r = Response::new();
391        r.status_code = *STATUS_CODE_REASON_PHRASE.n302_found.status_code;
392        r.reason_phrase = STATUS_CODE_REASON_PHRASE.n302_found.reason_phrase.to_string();
393        r.headers.push(Header { name: "Location".into(), value: url.to_string().into() });
394        r
395    }
396
397    fn error_response(msg: &str) -> Response {
398        crate::error::problem::problem_with_text(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, msg, msg)
399    }
400}
401
402impl Middleware for SamlSp {
403    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
404        let path = request.request_uri.split('?').next().unwrap_or("");
405        let is_get = request.method.eq_ignore_ascii_case(METHOD.get);
406        let is_post = request.method.eq_ignore_ascii_case(METHOD.post);
407
408        if path == self.metadata_path && is_get {
409            return Ok(self.handle_metadata());
410        }
411        if path == self.login_path && is_get {
412            return Ok(self.handle_login(request));
413        }
414        if path == self.acs_path && is_post {
415            return Ok(self.handle_acs(request));
416        }
417        if path == self.logout_path && is_get {
418            return Ok(self.handle_logout(request));
419        }
420
421        if let Some(sid) = session_id_from_request(request, SESSION_COOKIE) {
422            if let Some(session) = self.config.sessions.load(&sid) {
423                if let Some(claims_json) = session.get("_saml_claims") {
424                    let mut req = request.clone();
425                    req.headers.push(Header { name: CLAIMS_HEADER.to_string().into(), value: claims_json.to_string().into() });
426                    return match crate::authz::Identity::from_claims(claims_json, "name_id", "saml") {
427                        Some(identity) => crate::authz::with_identity(identity, || next.execute(&req, connection)),
428                        None => next.execute(&req, connection),
429                    };
430                }
431            }
432        }
433
434        let return_to = crate::sso::client::url_encode(&request.request_uri);
435        Ok(Self::redirect(&format!("{}?return_to={}", self.login_path, return_to)))
436    }
437}
438
439fn unix_now() -> u64 {
440    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
441}
442
443fn iso8601_now() -> String {
444    format_iso8601(unix_now())
445}
446
447/// Format Unix seconds as `xs:dateTime` UTC (`2024-01-01T00:00:00Z`).
448fn format_iso8601(total_secs: u64) -> String {
449    let days = total_secs / 86_400;
450    let secs_of_day = total_secs % 86_400;
451    let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
452    let (y, m, d) = days_to_ymd(days);
453    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
454}
455
456/// Inverse of `http::date::ymd_to_days` (days since the Unix epoch to
457/// a proleptic-Gregorian calendar date) — Howard Hinnant's algorithm.
458fn days_to_ymd(days_since_epoch: u64) -> (u32, u32, u32) {
459    let z = days_since_epoch as i64 + 719_468;
460    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
461    let doe = (z - era * 146_097) as u64;
462    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
463    let y = yoe as i64 + era * 400;
464    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
465    let mp = (5 * doy + 2) / 153;
466    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
467    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
468    let y = if m <= 2 { y + 1 } else { y };
469    (y as u32, m, d)
470}
471
472fn random_hex(n_bytes: usize) -> String {
473    let mut bytes = vec![0u8; n_bytes];
474    OsRng.fill_bytes(&mut bytes);
475    bytes.iter().map(|b| format!("{:02x}", b)).collect()
476}
477
478const STD_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
479
480/// Standard (not base64url) base64 with `=` padding — the alphabet the
481/// `SAMLRequest`/`SAMLResponse` form fields and `X509Certificate` elements
482/// use per the SAML/XML-DSig specs.
483fn base64_standard_encode(bytes: &[u8]) -> String {
484    let mut out = String::with_capacity(((bytes.len() + 2) / 3) * 4);
485    let mut i = 0;
486    while i + 3 <= bytes.len() {
487        let (b0, b1, b2) = (bytes[i] as usize, bytes[i + 1] as usize, bytes[i + 2] as usize);
488        out.push(STD_TABLE[b0 >> 2] as char);
489        out.push(STD_TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char);
490        out.push(STD_TABLE[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
491        out.push(STD_TABLE[b2 & 0x3f] as char);
492        i += 3;
493    }
494    let rem = bytes.len() - i;
495    if rem == 1 {
496        let b0 = bytes[i] as usize;
497        out.push(STD_TABLE[b0 >> 2] as char);
498        out.push(STD_TABLE[(b0 & 3) << 4] as char);
499        out.push_str("==");
500    } else if rem == 2 {
501        let (b0, b1) = (bytes[i] as usize, bytes[i + 1] as usize);
502        out.push(STD_TABLE[b0 >> 2] as char);
503        out.push(STD_TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char);
504        out.push(STD_TABLE[(b1 & 0xf) << 2] as char);
505        out.push('=');
506    }
507    out
508}
509
510/// Minimal XML attribute/text escaping for values this SP embeds into XML
511/// or HTML it generates itself (entity IDs, URLs, base64 payloads).
512fn xml_escape(s: &str) -> String {
513    let mut out = String::with_capacity(s.len());
514    for c in s.chars() {
515        match c {
516            '&' => out.push_str("&amp;"),
517            '<' => out.push_str("&lt;"),
518            '>' => out.push_str("&gt;"),
519            '"' => out.push_str("&quot;"),
520            '\'' => out.push_str("&apos;"),
521            _ => out.push(c),
522        }
523    }
524    out
525}