SAML 2.0 SSO
SAML 2.0 Service Provider — ACS handler, XML signature verification, and attribute mapping, for enterprise IdPs that don't speak OIDC.
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 = "18", 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:
| Path | Method | Purpose |
|---|---|---|
/saml/metadata |
GET |
SP metadata XML — give this URL to the IdP |
/saml/login |
GET |
Auto-submitting HTML form POSTing an AuthnRequest to the IdP |
/saml/acs |
POST |
Assertion Consumer Service — receives and verifies the IdP's Response |
/saml/logout |
GET |
Destroys 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>.
The paths can be changed with .metadata_path(), .login_path(), .acs_path() and .logout_path(); .post_login_redirect(path) sets where a login without return_to lands (default /).
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")?;
// Or from an XML string you already have:
let meta = SamlIdpMetadata::from_str(&xml)?;All three 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-SHA256only) over theAssertion - Exactly one
Assertionin the response (protects against signature-wrapping attacks — see below) Issuermatches the configured IdP entity IDConditions/@NotBefore/@NotOnOrAftertime window (60s leeway)AudienceRestriction/Audiencematches the SP's own entity IDInResponseTomatches theAuthnRequestID stored at/saml/logintime (anti-replay)SubjectConfirmation/@Methodisurn:oasis:names:tc:SAML:2.0:cm:bearerSubjectConfirmationData/@Recipientmatches 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.
The design for this feature originally sketched mapping SAML attributes into the same OidcClaims shape OidcAuth uses — but OidcClaims was deliberately built (see the OIDC SSO page) without a groups field, and this feature's own attribute-mapping example maps exactly a groups attribute. Reusing OidcClaims would silently drop it. SamlClaims { name_id, attributes: HashMap<String, String> } is a plain, free-form map instead — a better fit for SAML's attribute model anyway.
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:
Correct XML-DSig verification canonicalizes (C14N) the signed XML before checking digests/signatures, so reformatting whitespace or attribute order doesn't invalidate an otherwise-unchanged signature. This module verifies against the literal bytes as transmitted instead of implementing C14N (a large undertaking prone to subtle bugs). The practical effect is fail-closed, not fail-open: an IdP that reformats XML between signing and transmission would have legitimate logins rejected (loud, immediate, fixable) — never would a forged assertion be accepted. No mainstream IdP (Okta, Azure AD, Google Workspace, Keycloak) reformats between signing and transmission in this flow.
- No
quick-xmldependency. 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-SHA256only — no SHA-1 (deprecated), no EC-signed assertions.EncryptedAssertionis 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 unsignedAuthnRequests by default; the flow's security rests on the IdP-signed assertion, which is fully verified.- Logout is local-only —
/saml/logoutdestroys the SP's own session; there is no SP-initiatedLogoutRequest/LogoutResponseexchange with the IdP. This mirrorsOidcAuth'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.