Skip to content

SAML 2.0 SSO

The sso-saml feature (implies sso) adds SamlSp, a SAML 2.0 Service Provider middleware — the enterprise/B2B alternative to OAuth2 / OIDC SSO for identity providers that only speak SAML (Active Directory Federation Services, Okta SAML, Google Workspace SAML, Keycloak).

[dependencies]
rust-web-server = { version = "17", features = ["sso-saml"] }

Quick start

use std::sync::Arc;
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::session::SessionStore;
use rust_web_server::sso::saml::{SamlSp, SamlConfig, SamlIdpMetadata, AttributeMap};
let saml_sp = SamlSp::new(SamlConfig {
sp_entity_id: "https://myapp.com/saml/metadata".into(),
sp_acs_url: "https://myapp.com/saml/acs".into(),
idp_metadata: SamlIdpMetadata::from_file("idp-metadata.xml")?,
sessions: Arc::new(SessionStore::new(86_400)),
})
.attribute_map(
AttributeMap::new()
.map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "email")
.map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "name"),
);
let app = App::new().wrap(saml_sp);

SamlSp intercepts four paths and passes everything else through:

PathMethodPurpose
/saml/metadataGETSP metadata XML — give this URL to the IdP
/saml/loginGETAuto-submitting HTML form POSTing an AuthnRequest to the IdP
/saml/acsPOSTAssertion Consumer Service — receives and verifies the IdP’s Response
/saml/logoutGETDestroys the local session and redirects home

Any other path checks the session: if a completed login exists, claims are injected as the X-Rws-Saml-Claims header (JSON) and the request proceeds; otherwise the browser is redirected to /saml/login?return_to=<original path>.

Reading claims in a handler

use rust_web_server::sso::saml::SamlSp;
use rust_web_server::request::Request;
fn dashboard(req: &Request) {
let name_id = SamlSp::name_id(req).unwrap(); // Subject/NameID
let email = SamlSp::attr(req, "email"); // mapped via AttributeMap
let claims = SamlSp::claims(req).unwrap(); // full SamlClaims { name_id, attributes }
}

Loading IdP metadata

use rust_web_server::sso::saml::SamlIdpMetadata;
// From a downloaded/exported metadata file:
let meta = SamlIdpMetadata::from_file("idp-metadata.xml")?;
// Or fetch it directly from the IdP's metadata URL:
let meta = SamlIdpMetadata::from_url("https://idp.corp.com/metadata")?;

Both parse the same shape: entityID, the SingleSignOnService endpoint (an HTTP-POST binding entry is preferred if the metadata advertises one — see below for why), and the signing certificate under KeyDescriptor[use=signing].

What assertion validation checks

Every POST /saml/acs request runs the full set the SAML spec calls for:

  • XML signature verification (RSA-SHA256 only) over the Assertion
  • Exactly one Assertion in the response (protects against signature-wrapping attacks — see below)
  • Issuer matches the configured IdP entity ID
  • Conditions/@NotBefore / @NotOnOrAfter time window (60s leeway)
  • AudienceRestriction/Audience matches the SP’s own entity ID
  • InResponseTo matches the AuthnRequest ID stored at /saml/login time (anti-replay)
  • SubjectConfirmation/@Method is urn:oasis:names:tc:SAML:2.0:cm:bearer
  • SubjectConfirmationData/@Recipient matches the configured ACS URL

Attribute mapping

SAML attributes have IdP-specific names — often long URIs. AttributeMap translates them into the field names your application actually wants to read:

use rust_web_server::sso::saml::AttributeMap;
let map = AttributeMap::new()
.map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "email")
.map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "name")
.map("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups", "groups");

An attribute with no mapping entry is simply not exposed under a friendly name — read it via its raw SAML name from SamlClaims::attributes directly if needed. Multi-valued SAML attributes keep only their first value.

Scope and deviations from a full SAML implementation

SAML 2.0 is a large, XML-heavy spec. This implementation covers the common enterprise SP flow — receiving and verifying IdP-initiated or SP-initiated bearer assertions — and makes several deliberate, documented scope decisions rather than attempting full spec coverage:

  • No quick-xml dependency. A small, purpose-built XML parser is hand-rolled instead, consistent with this crate’s existing philosophy of not taking dependencies for formats it can parse itself (JSON, HTTP, form-urlencoded are all hand-rolled too). It never processes <!DOCTYPE> — rejected outright, eliminating XXE by never implementing entity resolution at all.
  • RSA-SHA256 only — no SHA-1 (deprecated), no EC-signed assertions.
  • EncryptedAssertion is rejected outright, not silently ignored — no XML decryption is implemented.
  • AuthnRequests use the HTTP-POST binding, not HTTP-Redirect (which requires DEFLATE compression this crate has no dependency for), and are never signed — most IdPs accept unsigned AuthnRequests by default; the flow’s security rests on the IdP-signed assertion, which is fully verified.
  • Logout is local-only/saml/logout destroys the SP’s own session; there is no SP-initiated LogoutRequest/LogoutResponse exchange with the IdP. This mirrors OidcAuth’s own logout, which draws the identical boundary.

None of these are silent gaps — each one either fails closed (rejects rather than accepts something suspicious) or is a compatibility boundary (an unsupported binding/algorithm), never a case where something insecure is accepted as valid.