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
+84
-12
@@ -1,16 +1,17 @@
|
||||
//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5.
|
||||
//! SAML SSO configuration — FleetDM-shaped.
|
||||
//!
|
||||
//! The operator pastes their IdP's metadata XML (or its URL) and a
|
||||
//! human-readable label; v0.4.5 just persists it. The actual SAML
|
||||
//! response-validation / JIT-provisioning flow lands in a later release
|
||||
//! — for now we cover the "configurable" half so an operator can teach
|
||||
//! OpenPXE about their IdP today and flip the switch on next upgrade.
|
||||
//! human-readable label. As of v0.5.1 the SAML login flow is wired
|
||||
//! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS
|
||||
//! endpoint, pure-Rust signature verification, and operator-session
|
||||
//! minting. This module owns only the persisted *configuration*.
|
||||
//!
|
||||
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
|
||||
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you
|
||||
//! have access or you don't). Entity ID is omitted from the operator
|
||||
//! UI per the v0.4.5 brief — it defaults to the advertised public base
|
||||
//! URL when SAML wiring lands, which is what most IdPs expect anyway.
|
||||
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any
|
||||
//! IdP-authenticated user the SP cryptographically verifies gets an
|
||||
//! operator session; there is no per-user role table). Entity ID is
|
||||
//! exposed (FleetDM-style) but defaults to the advertised public base
|
||||
//! URL when blank, which is what most IdPs expect anyway.
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -47,6 +48,18 @@ pub struct SsoConfig {
|
||||
/// future SAML flow; not validated here beyond a basic length cap.
|
||||
#[serde(default)]
|
||||
pub metadata_url: String,
|
||||
/// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID".
|
||||
/// Must exactly match the SP/Relying-Party entry configured on the IdP.
|
||||
/// Empty falls back to the advertised public base URL at runtime, which
|
||||
/// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`].
|
||||
#[serde(default)]
|
||||
pub entity_id: String,
|
||||
/// Allow IdP-initiated login — an unsolicited `<Response>` POSTed to the
|
||||
/// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login
|
||||
/// initiated by identity provider". Default off; SP-initiated (the
|
||||
/// "Sign in with X" button) is always allowed regardless.
|
||||
#[serde(default)]
|
||||
pub allow_idp_initiated: bool,
|
||||
}
|
||||
|
||||
impl SsoConfig {
|
||||
@@ -56,8 +69,7 @@ impl SsoConfig {
|
||||
/// surface a yellow "configured but not live yet" hint.
|
||||
#[must_use]
|
||||
pub fn is_usable(&self) -> bool {
|
||||
self.enabled
|
||||
&& (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
|
||||
self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +120,12 @@ impl SsoStore {
|
||||
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
|
||||
cfg.metadata = cfg.metadata.trim().to_string();
|
||||
cfg.metadata_url = cfg.metadata_url.trim().to_string();
|
||||
cfg.entity_id = cfg.entity_id.trim().to_string();
|
||||
if cfg.entity_id.len() > MAX_URL_LEN {
|
||||
return Err(Error::Invalid(format!(
|
||||
"entity_id exceeds {MAX_URL_LEN}-char cap"
|
||||
)));
|
||||
}
|
||||
if cfg.metadata.len() > MAX_METADATA_BYTES {
|
||||
return Err(Error::Invalid(format!(
|
||||
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
|
||||
@@ -217,6 +235,8 @@ mod tests {
|
||||
metadata: String::new(),
|
||||
metadata_url: "https://idp.example.com/metadata".into(),
|
||||
idp_logo_url: String::new(),
|
||||
entity_id: String::new(),
|
||||
allow_idp_initiated: false,
|
||||
})
|
||||
.unwrap();
|
||||
drop(s);
|
||||
@@ -239,6 +259,8 @@ mod tests {
|
||||
metadata: xml.into(),
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: String::new(),
|
||||
entity_id: String::new(),
|
||||
allow_idp_initiated: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.snapshot().is_usable());
|
||||
@@ -254,6 +276,8 @@ mod tests {
|
||||
metadata: String::new(),
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: String::new(),
|
||||
entity_id: String::new(),
|
||||
allow_idp_initiated: false,
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
// …and a disabled blank config is fine.
|
||||
@@ -270,6 +294,8 @@ mod tests {
|
||||
metadata: String::new(),
|
||||
metadata_url: "ftp://idp.example.com/metadata".into(),
|
||||
idp_logo_url: String::new(),
|
||||
entity_id: String::new(),
|
||||
allow_idp_initiated: false,
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
}
|
||||
@@ -287,6 +313,8 @@ mod tests {
|
||||
metadata: String::new(),
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: "data:image/png;base64,...".into(),
|
||||
entity_id: String::new(),
|
||||
allow_idp_initiated: false,
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
// Real HTTPS URL is fine.
|
||||
@@ -296,9 +324,51 @@ mod tests {
|
||||
metadata: String::new(),
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: "https://idp.example.com/logo.png".into(),
|
||||
entity_id: String::new(),
|
||||
allow_idp_initiated: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png");
|
||||
assert_eq!(
|
||||
s.snapshot().idp_logo_url,
|
||||
"https://idp.example.com/logo.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_id_and_idp_initiated_round_trip() {
|
||||
// v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload.
|
||||
let dir = tempdir().unwrap();
|
||||
let s = SsoStore::load_or_default(dir.path());
|
||||
s.replace(SsoConfig {
|
||||
enabled: true,
|
||||
idp_name: "Keycloak".into(),
|
||||
metadata: String::new(),
|
||||
metadata_url: "https://idp.example.com/metadata".into(),
|
||||
idp_logo_url: String::new(),
|
||||
entity_id: "https://pxe.example.com".into(),
|
||||
allow_idp_initiated: true,
|
||||
})
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let cfg = SsoStore::load_or_default(dir.path()).snapshot();
|
||||
assert_eq!(cfg.entity_id, "https://pxe.example.com");
|
||||
assert!(cfg.allow_idp_initiated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_id_cap_enforced() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = SsoStore::load_or_default(dir.path());
|
||||
let r = s.replace(SsoConfig {
|
||||
enabled: false,
|
||||
idp_name: String::new(),
|
||||
metadata: String::new(),
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: String::new(),
|
||||
entity_id: "x".repeat(MAX_URL_LEN + 1),
|
||||
allow_idp_initiated: false,
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -312,6 +382,8 @@ mod tests {
|
||||
metadata: oversize,
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: String::new(),
|
||||
entity_id: String::new(),
|
||||
allow_idp_initiated: false,
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user