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]>
242 lines
9.4 KiB
Rust
242 lines
9.4 KiB
Rust
//! 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();
|
|
}
|
|
}
|