feat(saml): wire SAML 2.0 SSO end-to-end (pure-Rust) + Settings/Storage UI consolidation (v0.5.1)
SAML SSO (the config was storage-only since v0.4.5; now it logs you in):
- New openpxe-core::saml — pure-Rust SP built on bergshamra (XML-DSig +
exclusive c14n via RustCrypto, no OpenSSL/xmlsec/libxml2). The static
musl binary stays C-free; samael was rejected for hard-requiring OpenSSL.
* metadata.rs — parse IdP EntityDescriptor (SSO URLs + signing certs),
build our SP metadata.
* authn_request.rs — build + HTTP-Redirect-encode AuthnRequests.
* response.rs — verify the signature against the pinned IdP cert
(trusted_keys_only + strict_verification for XSW),
then enforce Status/Destination/Audience/time-bounds/
signature-scope. Stateless; returns the IDs the HTTP
layer needs.
- http-api saml_routes: GET /api/sso/login (302 to IdP), POST /api/sso/acs
(verify -> InResponseTo correlation / IdP-initiated gating / assertion
replay guard -> mint operator session -> 302), GET /api/sso/metadata.
Added to the pre-auth allowlist; /api/sso config stays gated.
- SsoConfig gains entity_id (SP Entity ID, defaults to public base URL)
and allow_idp_initiated (default off), mirroring FleetDM.
- Access model: any IdP-authenticated, cryptographically-verified user gets
an operator session (single-tier; local admin remains the fallback owner).
- Login page: the "Sign in with <IdP>" button now drives the real flow and
surfaces sso_error redirects.
UI consolidation:
- Removed the Advanced sidebar tab; folded its webhook-notifications +
API-reference cards into a collapsible "Advanced" disclosure at the
bottom of Settings.
- Merged the Storage tab's separate SMB and NFS cards into one "Remote
shares" card with a protocol dropdown and a unified, protocol-badged
table. No backend changes — same /api/smb-shares + /api/nfs-shares.
Tests: 17 SAML core tests (accept + reject tampered/unsigned/wrong-key/
wrong-audience/expired/future/wrong-issuer/non-success) and 6 ACS
integration tests (happy path, IdP-initiated gating, SP correlation,
replay, garbage). Full workspace: 206 tests green, clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
252b557b9c
commit
cbcd63bb14
@@ -0,0 +1,294 @@
|
||||
//! SAMLResponse consumption: signature verification + SP-side validation.
|
||||
//!
|
||||
//! [`consume`] is intentionally **stateless** — it verifies the XML signature
|
||||
//! against the IdP's pinned certificate(s) and enforces every check that can
|
||||
//! be made from the response alone (Status, Destination, Issuer, Audience,
|
||||
//! time bounds, signature scope). It then returns the `assertion_id` and
|
||||
//! `in_response_to` so the HTTP layer can perform the *stateful* checks it
|
||||
//! owns: replay rejection, correlating the request we issued, and gating
|
||||
//! IdP-initiated login.
|
||||
|
||||
use roxmltree::{Document, Node};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use super::metadata::IdpMetadata;
|
||||
use super::{SamlError, SpParams};
|
||||
|
||||
const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
|
||||
|
||||
/// The verified subject of a SAML assertion. OpenPXE is single-tier, so this
|
||||
/// is all an operator session needs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifiedPrincipal {
|
||||
/// The `<NameID>` value (an email, per our requested NameID format).
|
||||
pub name_id: String,
|
||||
/// Email used as the session identity. Equals `name_id` for the
|
||||
/// emailAddress NameID format.
|
||||
pub email: String,
|
||||
/// Human-readable display name, if the IdP sent one as an attribute.
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of a successful [`consume`]. The IDs/expiry feed the HTTP layer's
|
||||
/// stateful checks.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifiedResponse {
|
||||
pub principal: VerifiedPrincipal,
|
||||
/// `InResponseTo` from the response, if present. `None` = unsolicited
|
||||
/// (IdP-initiated) — the HTTP layer only accepts that when the operator
|
||||
/// enabled it.
|
||||
pub in_response_to: Option<String>,
|
||||
/// The assertion's `ID` — used by the caller as the replay-guard key.
|
||||
pub assertion_id: String,
|
||||
/// The assertion's expiry (`Conditions/@NotOnOrAfter`) — the replay
|
||||
/// guard can drop the consumed ID after this instant.
|
||||
pub assertion_expiry: OffsetDateTime,
|
||||
/// `AuthnStatement/@SessionIndex`, if present (useful for future SLO).
|
||||
pub session_index: Option<String>,
|
||||
}
|
||||
|
||||
/// Verify and validate a decoded `SAMLResponse` XML document.
|
||||
pub fn consume(
|
||||
xml: &str,
|
||||
sp: &SpParams,
|
||||
idp: &IdpMetadata,
|
||||
now: OffsetDateTime,
|
||||
clock_skew: Duration,
|
||||
) -> Result<VerifiedResponse, SamlError> {
|
||||
// 1. Cryptographically verify the signature against the pinned IdP cert(s).
|
||||
// `trusted_keys_only` ignores any cert embedded in the document's
|
||||
// KeyInfo, so an attacker can't substitute their own key.
|
||||
let verified_uris = verify_signature(xml, &idp.signing_certs_der)?;
|
||||
|
||||
// 2. Parse for semantic validation.
|
||||
let doc = Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
|
||||
let root = doc.root_element();
|
||||
if root.tag_name().name() != "Response" {
|
||||
return Err(SamlError::MissingElement("Response".into()));
|
||||
}
|
||||
let response_id = root.attribute("ID").map(str::to_owned);
|
||||
let in_response_to = root.attribute("InResponseTo").map(str::to_owned);
|
||||
|
||||
// 3. Status must be Success.
|
||||
let status_value = root
|
||||
.descendants()
|
||||
.find(|n| n.is_element() && n.tag_name().name() == "StatusCode")
|
||||
.and_then(|sc| sc.attribute("Value"))
|
||||
.unwrap_or("");
|
||||
if status_value != STATUS_SUCCESS && !status_value.ends_with(":Success") {
|
||||
return Err(SamlError::Status(status_value.to_owned()));
|
||||
}
|
||||
|
||||
// 4. Destination (if the IdP set one) must be our ACS.
|
||||
if let Some(dest) = root.attribute("Destination") {
|
||||
if !urls_equal(dest, &sp.acs_url) {
|
||||
return Err(SamlError::DestinationMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Exactly one (unencrypted) Assertion.
|
||||
if root
|
||||
.descendants()
|
||||
.any(|n| n.is_element() && n.tag_name().name() == "EncryptedAssertion")
|
||||
{
|
||||
return Err(SamlError::EncryptedAssertionUnsupported);
|
||||
}
|
||||
let assertions: Vec<Node<'_, '_>> = root
|
||||
.children()
|
||||
.filter(|c| c.is_element() && c.tag_name().name() == "Assertion")
|
||||
.collect();
|
||||
if assertions.len() != 1 {
|
||||
return Err(SamlError::AssertionCount(assertions.len()));
|
||||
}
|
||||
let assertion = assertions[0];
|
||||
let assertion_id = assertion
|
||||
.attribute("ID")
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| SamlError::MissingElement("Assertion/@ID".into()))?;
|
||||
|
||||
// 6. The signature must actually cover the assertion we're about to trust:
|
||||
// either the assertion itself, the enclosing response, or the whole
|
||||
// document. (bergshamra's strict_verification already constrains where
|
||||
// the signed element may sit; this ties it to *our* assertion.)
|
||||
let covers_assertion = verified_uris.iter().any(|u| {
|
||||
u.is_empty()
|
||||
|| u == &format!("#{assertion_id}")
|
||||
|| response_id
|
||||
.as_ref()
|
||||
.is_some_and(|rid| u == &format!("#{rid}"))
|
||||
});
|
||||
if !covers_assertion {
|
||||
return Err(SamlError::SignatureScope);
|
||||
}
|
||||
|
||||
// 7. Issuer must be the configured IdP.
|
||||
let issuer = first_child(assertion, "Issuer")
|
||||
.map(text_of)
|
||||
.unwrap_or_default();
|
||||
if !idp.entity_id.is_empty() && issuer != idp.entity_id {
|
||||
return Err(SamlError::IssuerMismatch);
|
||||
}
|
||||
|
||||
// 8. Subject → NameID + SubjectConfirmationData time/recipient checks.
|
||||
let subject = first_child(assertion, "Subject")
|
||||
.ok_or_else(|| SamlError::MissingElement("Subject".into()))?;
|
||||
let name_id = first_child(subject, "NameID")
|
||||
.map(text_of)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| SamlError::MissingElement("NameID".into()))?;
|
||||
if let Some(scd) = subject
|
||||
.descendants()
|
||||
.find(|n| n.is_element() && n.tag_name().name() == "SubjectConfirmationData")
|
||||
{
|
||||
if let Some(recipient) = scd.attribute("Recipient") {
|
||||
if !urls_equal(recipient, &sp.acs_url) {
|
||||
return Err(SamlError::DestinationMismatch);
|
||||
}
|
||||
}
|
||||
if let Some(noa) = scd.attribute("NotOnOrAfter") {
|
||||
let noa = parse_instant(noa)?;
|
||||
if now >= noa + clock_skew {
|
||||
return Err(SamlError::TimeBounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Conditions: time window + audience.
|
||||
let conditions = first_child(assertion, "Conditions");
|
||||
if let Some(cond) = conditions {
|
||||
if let Some(nb) = cond.attribute("NotBefore") {
|
||||
let nb = parse_instant(nb)?;
|
||||
if now < nb - clock_skew {
|
||||
return Err(SamlError::TimeBounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
let assertion_expiry = conditions
|
||||
.and_then(|c| c.attribute("NotOnOrAfter"))
|
||||
.map(parse_instant)
|
||||
.transpose()?
|
||||
.ok_or_else(|| SamlError::MissingElement("Conditions/@NotOnOrAfter".into()))?;
|
||||
if now >= assertion_expiry + clock_skew {
|
||||
return Err(SamlError::TimeBounds);
|
||||
}
|
||||
|
||||
let audience_ok = conditions.is_some_and(|c| {
|
||||
c.descendants()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "Audience")
|
||||
.any(|a| text_of(a) == sp.entity_id)
|
||||
});
|
||||
if !audience_ok {
|
||||
return Err(SamlError::AudienceMismatch);
|
||||
}
|
||||
|
||||
// 10. Optional: SessionIndex + display-name attribute.
|
||||
let session_index = assertion
|
||||
.descendants()
|
||||
.find(|n| n.is_element() && n.tag_name().name() == "AuthnStatement")
|
||||
.and_then(|a| a.attribute("SessionIndex"))
|
||||
.map(str::to_owned);
|
||||
|
||||
let display_name = extract_display_name(assertion);
|
||||
|
||||
Ok(VerifiedResponse {
|
||||
principal: VerifiedPrincipal {
|
||||
email: name_id.clone(),
|
||||
name_id,
|
||||
display_name,
|
||||
},
|
||||
in_response_to,
|
||||
assertion_id,
|
||||
assertion_expiry,
|
||||
session_index,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify the document's XML-DSig against each pinned IdP cert in turn
|
||||
/// (handles key rotation), returning the verified `<Reference>` URIs.
|
||||
fn verify_signature(xml: &str, certs_der: &[Vec<u8>]) -> Result<Vec<String>, SamlError> {
|
||||
let mut last_err = String::from("no signing certificate matched");
|
||||
for der in certs_der {
|
||||
let key = match bergshamra::keys::loader::load_x509_cert_der(der) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
last_err = e.to_string();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut km = bergshamra::keys::KeysManager::new();
|
||||
km.add_key(key);
|
||||
// trusted_keys_only: only ever trust the pinned IdP key, never an
|
||||
// inline KeyInfo cert. strict_verification: XSW positional defense.
|
||||
let ctx = bergshamra::DsigContext::new(km)
|
||||
.with_trusted_keys_only(true)
|
||||
.with_strict_verification(true);
|
||||
match bergshamra::verify(&ctx, xml) {
|
||||
Ok(bergshamra::VerifyResult::Valid { references, .. }) => {
|
||||
return Ok(references.into_iter().map(|r| r.uri).collect());
|
||||
}
|
||||
Ok(bergshamra::VerifyResult::Invalid { reason }) => last_err = reason,
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
}
|
||||
Err(SamlError::Signature(last_err))
|
||||
}
|
||||
|
||||
/// Pull a display name from the assertion's attribute statement, trying the
|
||||
/// common attribute names IdPs use (FleetDM checks the same set).
|
||||
fn extract_display_name(assertion: Node<'_, '_>) -> Option<String> {
|
||||
const WANTED: &[&str] = &[
|
||||
"name",
|
||||
"displayname",
|
||||
"cn",
|
||||
"urn:oid:2.5.4.3",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||
];
|
||||
for attr in assertion
|
||||
.descendants()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "Attribute")
|
||||
{
|
||||
let key = attr
|
||||
.attribute("Name")
|
||||
.or_else(|| attr.attribute("FriendlyName"))
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if WANTED.contains(&key.as_str()) {
|
||||
if let Some(val) = attr
|
||||
.descendants()
|
||||
.find(|n| n.is_element() && n.tag_name().name() == "AttributeValue")
|
||||
{
|
||||
let v = text_of(val);
|
||||
if !v.is_empty() {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn first_child<'a, 'i>(n: Node<'a, 'i>, local: &str) -> Option<Node<'a, 'i>> {
|
||||
n.children()
|
||||
.find(|c| c.is_element() && c.tag_name().name() == local)
|
||||
}
|
||||
|
||||
fn text_of(n: Node<'_, '_>) -> String {
|
||||
n.children()
|
||||
.filter(Node::is_text)
|
||||
.filter_map(|c| c.text())
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Parse an `xs:dateTime` (e.g. `2026-05-31T12:00:00.000Z`).
|
||||
fn parse_instant(s: &str) -> Result<OffsetDateTime, SamlError> {
|
||||
OffsetDateTime::parse(s.trim(), &Rfc3339).map_err(|e| SamlError::Timestamp(format!("{s}: {e}")))
|
||||
}
|
||||
|
||||
/// Compare two URLs for SAML endpoint-matching purposes: exact, or differing
|
||||
/// only by a single trailing slash.
|
||||
fn urls_equal(a: &str, b: &str) -> bool {
|
||||
a == b || a.trim_end_matches('/') == b.trim_end_matches('/')
|
||||
}
|
||||
Reference in New Issue
Block a user