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,241 @@
|
||||
//! IdP metadata parsing + SP metadata generation.
|
||||
//!
|
||||
//! We parse only what the SP flow needs: the IdP Entity ID, its
|
||||
//! `SingleSignOnService` endpoints (HTTP-Redirect / HTTP-POST), and the
|
||||
//! X.509 signing certificate(s). Everything else in the document is ignored.
|
||||
|
||||
use base64::Engine;
|
||||
|
||||
use super::{SamlError, SpParams};
|
||||
|
||||
/// SAML 2.0 binding URIs.
|
||||
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
|
||||
pub const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
|
||||
|
||||
/// The subset of an IdP's `EntityDescriptor` the SP flow consumes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdpMetadata {
|
||||
/// The IdP's Entity ID — we require incoming assertions to be issued by it.
|
||||
pub entity_id: String,
|
||||
/// SSO endpoint for the HTTP-Redirect binding (where we send AuthnRequests).
|
||||
pub sso_redirect_url: Option<String>,
|
||||
/// SSO endpoint for the HTTP-POST binding (fallback target).
|
||||
pub sso_post_url: Option<String>,
|
||||
/// DER-encoded X.509 signing certificate(s). More than one appears during
|
||||
/// key rotation; verification tries each.
|
||||
pub signing_certs_der: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl IdpMetadata {
|
||||
/// Parse an IdP `EntityDescriptor` document.
|
||||
///
|
||||
/// Robust to namespace-prefix variation (matches on local element names),
|
||||
/// since IdPs disagree on prefixes (`md:`, `ns0:`, default, …).
|
||||
pub fn parse(xml: &str) -> Result<Self, SamlError> {
|
||||
let doc = roxmltree::Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
|
||||
let root = doc.root_element();
|
||||
|
||||
// The signing IDP descriptor. Some metadata wraps multiple
|
||||
// descriptors (AA, SP) in one document; we want IDPSSODescriptor.
|
||||
let idp_desc = root
|
||||
.descendants()
|
||||
.find(|n| n.is_element() && n.tag_name().name() == "IDPSSODescriptor")
|
||||
.ok_or_else(|| SamlError::Metadata("IDPSSODescriptor".into()))?;
|
||||
|
||||
// Entity ID lives on the EntityDescriptor (root, or an ancestor of the
|
||||
// IDPSSODescriptor when several are nested).
|
||||
let entity_id = idp_desc
|
||||
.ancestors()
|
||||
.find_map(|n| {
|
||||
if n.tag_name().name() == "EntityDescriptor" {
|
||||
n.attribute("entityID")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| root.attribute("entityID"))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| SamlError::Metadata("entityID".into()))?;
|
||||
|
||||
let mut sso_redirect_url = None;
|
||||
let mut sso_post_url = None;
|
||||
for sso in idp_desc
|
||||
.children()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "SingleSignOnService")
|
||||
{
|
||||
let binding = sso.attribute("Binding").unwrap_or("");
|
||||
let location = sso.attribute("Location").map(str::to_owned);
|
||||
match binding {
|
||||
BINDING_REDIRECT if sso_redirect_url.is_none() => sso_redirect_url = location,
|
||||
BINDING_POST if sso_post_url.is_none() => sso_post_url = location,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Signing certs: KeyDescriptor with use="signing" or no use attribute
|
||||
// (a bare KeyDescriptor is valid for both signing and encryption).
|
||||
let mut signing_certs_der = Vec::new();
|
||||
for kd in idp_desc
|
||||
.children()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "KeyDescriptor")
|
||||
{
|
||||
match kd.attribute("use") {
|
||||
Some("signing") | None => {}
|
||||
Some(_) => continue, // encryption-only key — skip
|
||||
}
|
||||
for cert_node in kd
|
||||
.descendants()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "X509Certificate")
|
||||
{
|
||||
let b64: String = node_text(&cert_node)
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect();
|
||||
if b64.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let der = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64.as_bytes())
|
||||
.map_err(|e| SamlError::Base64(e.to_string()))?;
|
||||
signing_certs_der.push(der);
|
||||
}
|
||||
}
|
||||
|
||||
if signing_certs_der.is_empty() {
|
||||
return Err(SamlError::NoSigningCert);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
entity_id,
|
||||
sso_redirect_url,
|
||||
sso_post_url,
|
||||
signing_certs_der,
|
||||
})
|
||||
}
|
||||
|
||||
/// Preferred SSO destination for an outbound AuthnRequest: HTTP-Redirect
|
||||
/// if advertised, otherwise HTTP-POST.
|
||||
pub fn sso_destination(&self) -> Option<&str> {
|
||||
self.sso_redirect_url
|
||||
.as_deref()
|
||||
.or(self.sso_post_url.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build our SP `EntityDescriptor` XML so an IdP admin can import OpenPXE as a
|
||||
/// relying party. Advertises the ACS URL (HTTP-POST binding) and an emailAddress
|
||||
/// NameID format — matching what the response path expects.
|
||||
pub fn build_sp_metadata(sp: &SpParams) -> String {
|
||||
let entity = xml_escape(&sp.entity_id);
|
||||
let acs = xml_escape(&sp.acs_url);
|
||||
format!(
|
||||
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" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
|
||||
<AssertionConsumerService Binding="{BINDING_POST}" Location="{acs}" index="0" isDefault="true"/>
|
||||
</SPSSODescriptor>
|
||||
</EntityDescriptor>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Collect the concatenated text of an element's direct text children.
|
||||
fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
|
||||
n.children()
|
||||
.filter(roxmltree::Node::is_text)
|
||||
.filter_map(|c| c.text())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Minimal XML attribute/text escaping for the values we interpolate.
|
||||
fn xml_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A trimmed-down Keycloak-style IdP descriptor (cert body is a stand-in;
|
||||
// signing tests build real certs in the parent module's tests).
|
||||
const SAMPLE: &str = r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
|
||||
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
|
||||
entityID="https://idp.example.com/realms/fleet">
|
||||
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:KeyDescriptor use="signing">
|
||||
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>
|
||||
QUJDREVG
|
||||
</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:KeyDescriptor use="encryption">
|
||||
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>WlpaWg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>"#;
|
||||
|
||||
#[test]
|
||||
fn parses_entity_sso_and_signing_cert() {
|
||||
let m = IdpMetadata::parse(SAMPLE).unwrap();
|
||||
assert_eq!(m.entity_id, "https://idp.example.com/realms/fleet");
|
||||
assert_eq!(
|
||||
m.sso_redirect_url.as_deref(),
|
||||
Some("https://idp.example.com/realms/fleet/protocol/saml")
|
||||
);
|
||||
assert!(m.sso_post_url.is_some());
|
||||
// Only the signing KeyDescriptor's cert is collected (ABCDEF), not the
|
||||
// encryption one (ZZZZ).
|
||||
assert_eq!(m.signing_certs_der.len(), 1);
|
||||
assert_eq!(m.signing_certs_der[0], b"ABCDEF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_signing_cert_is_rejected() {
|
||||
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x">
|
||||
<IDPSSODescriptor>
|
||||
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://x/sso"/>
|
||||
</IDPSSODescriptor></EntityDescriptor>"#;
|
||||
assert!(matches!(
|
||||
IdpMetadata::parse(xml),
|
||||
Err(SamlError::NoSigningCert)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_idp_descriptor_is_rejected() {
|
||||
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"></EntityDescriptor>"#;
|
||||
assert!(matches!(
|
||||
IdpMetadata::parse(xml),
|
||||
Err(SamlError::Metadata(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sp_metadata_contains_entity_and_acs() {
|
||||
let sp = SpParams {
|
||||
entity_id: "https://pxe.example.com".into(),
|
||||
acs_url: "https://pxe.example.com/api/sso/acs".into(),
|
||||
};
|
||||
let xml = build_sp_metadata(&sp);
|
||||
assert!(xml.contains(r#"entityID="https://pxe.example.com""#));
|
||||
assert!(xml.contains("https://pxe.example.com/api/sso/acs"));
|
||||
assert!(xml.contains(BINDING_POST));
|
||||
// Must be well-formed.
|
||||
roxmltree::Document::parse(&xml).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user