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
@@ -116,6 +116,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
admin,
|
||||
sessions,
|
||||
sso,
|
||||
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
|
||||
notify,
|
||||
metrics,
|
||||
smb: None,
|
||||
@@ -559,7 +560,10 @@ async fn notify_config_round_trips_and_redacts_smtp_password() {
|
||||
assert_eq!(v["kind"], "smtp");
|
||||
let pw = v["smtp_password"].as_str().unwrap_or("");
|
||||
assert_ne!(pw, "s3cret", "raw password must never be returned");
|
||||
assert!(!pw.is_empty(), "a set password should surface as a sentinel");
|
||||
assert!(
|
||||
!pw.is_empty(),
|
||||
"a set password should surface as a sentinel"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1459,7 +1463,7 @@ async fn storage_disk_endpoint_reports_volume_stats() {
|
||||
let avail = v["available_bytes"].as_u64().unwrap();
|
||||
let used = v["used_bytes"].as_u64().unwrap();
|
||||
assert!(total >= avail, "{v}");
|
||||
assert!(total >= used, "{v}");
|
||||
assert!(total >= used, "{v}");
|
||||
assert!(v["path"].as_str().unwrap().contains("isos"), "got {v}");
|
||||
}
|
||||
|
||||
@@ -1551,7 +1555,11 @@ async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode,
|
||||
|
||||
// ─── v0.4.5: Forms auth + SSO ─────────────────────────────────────────────
|
||||
|
||||
async fn post_collect(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>, Vec<axum::http::HeaderValue>) {
|
||||
async fn post_collect(
|
||||
router: &axum::Router,
|
||||
path: &str,
|
||||
body: &str,
|
||||
) -> (StatusCode, Vec<u8>, Vec<axum::http::HeaderValue>) {
|
||||
let res = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -1962,7 +1970,10 @@ async fn pxe_background_falls_back_to_default_for_svg_upload() {
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(body.starts_with(b"\x89PNG"), "should serve default PNG for SVG");
|
||||
assert!(
|
||||
body.starts_with(b"\x89PNG"),
|
||||
"should serve default PNG for SVG"
|
||||
);
|
||||
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
|
||||
assert_eq!(width, 1024);
|
||||
}
|
||||
@@ -1974,10 +1985,7 @@ async fn pxe_logo_composes_to_1024x768_png() {
|
||||
// the iPXE menu always paints at consistent dimensions.
|
||||
let (state, _dir) = build_state().await;
|
||||
let png = tiny_png();
|
||||
state
|
||||
.branding
|
||||
.set_logo("image/png", "png", &png)
|
||||
.unwrap();
|
||||
state.branding.set_logo("image/png", "png", &png).unwrap();
|
||||
let app = build_router(state);
|
||||
let res = app
|
||||
.clone()
|
||||
@@ -2017,10 +2025,7 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
|
||||
// auth allowlist gates `/api/*` only.
|
||||
let (state, _dir) = build_state().await;
|
||||
let png = tiny_png();
|
||||
state
|
||||
.branding
|
||||
.set_logo("image/png", "png", &png)
|
||||
.unwrap();
|
||||
state.branding.set_logo("image/png", "png", &png).unwrap();
|
||||
let app = build_router(state);
|
||||
// Configure an admin so the middleware kicks in.
|
||||
let (s, _, _) = post_collect(
|
||||
@@ -2034,3 +2039,305 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
|
||||
let (s, _) = get(&app, "/branding/pxe-logo").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
}
|
||||
|
||||
// ─── v0.5.1: SAML SSO flow ──────────────────────────────────────────────────
|
||||
//
|
||||
// The core crate exhaustively tests signature verification + semantic
|
||||
// validation (crates/core/src/saml/tests.rs). These integration tests cover
|
||||
// the HTTP wiring the core can't: routing, base64 decode, session minting,
|
||||
// the InResponseTo / IdP-initiated gating, and assertion-replay rejection.
|
||||
|
||||
use base64::Engine as _;
|
||||
use openpxe_core::SsoConfig;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||
|
||||
const SP_BASE: &str = "http://127.0.0.1"; // build_state's public_base_url
|
||||
const SP_ACS: &str = "http://127.0.0.1/api/sso/acs";
|
||||
const IDP_ENTITY: &str = "https://idp.test/realms/fleet";
|
||||
const IDP_SSO: &str = "https://idp.test/realms/fleet/protocol/saml";
|
||||
|
||||
struct TestIdp {
|
||||
cert_b64: String,
|
||||
key_pem: String,
|
||||
}
|
||||
|
||||
fn make_idp() -> TestIdp {
|
||||
let ck = rcgen::generate_simple_self_signed(vec!["idp.test".to_string()]).unwrap();
|
||||
let der = ck.cert.der().as_ref().to_vec();
|
||||
TestIdp {
|
||||
cert_b64: base64::engine::general_purpose::STANDARD.encode(der),
|
||||
key_pem: ck.key_pair.serialize_pem(),
|
||||
}
|
||||
}
|
||||
|
||||
fn idp_metadata_xml(cert_b64: &str) -> String {
|
||||
format!(
|
||||
r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{IDP_ENTITY}">
|
||||
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:KeyDescriptor use="signing"><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert_b64}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{IDP_SSO}"/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Build + sign a SAMLResponse with the test IdP key. `in_response_to: None`
|
||||
/// makes it an unsolicited (IdP-initiated) response.
|
||||
fn signed_response(idp: &TestIdp, in_response_to: Option<&str>) -> String {
|
||||
let now = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap();
|
||||
let fmt = |t: OffsetDateTime| t.format(&Rfc3339).unwrap();
|
||||
let irt = in_response_to
|
||||
.map(|v| format!(r#" InResponseTo="{v}""#))
|
||||
.unwrap_or_default();
|
||||
let template = format!(
|
||||
r##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp1" Version="2.0" IssueInstant="{now}" Destination="{SP_ACS}"{irt}>
|
||||
<saml:Issuer>{IDP_ENTITY}</saml:Issuer>
|
||||
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
|
||||
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
|
||||
<saml:Issuer>{IDP_ENTITY}</saml:Issuer>
|
||||
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:SignedInfo>
|
||||
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
|
||||
<ds:Reference URI="#_assertion1">
|
||||
<ds:Transforms>
|
||||
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
|
||||
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||
</ds:Transforms>
|
||||
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
|
||||
<ds:DigestValue></ds:DigestValue>
|
||||
</ds:Reference>
|
||||
</ds:SignedInfo>
|
||||
<ds:SignatureValue></ds:SignatureValue>
|
||||
</ds:Signature>
|
||||
<saml:Subject>
|
||||
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">[email protected]</saml:NameID>
|
||||
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||
<saml:SubjectConfirmationData Recipient="{SP_ACS}" NotOnOrAfter="{noa}"{irt}/>
|
||||
</saml:SubjectConfirmation>
|
||||
</saml:Subject>
|
||||
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
|
||||
<saml:AudienceRestriction><saml:Audience>{SP_BASE}</saml:Audience></saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-1">
|
||||
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
</saml:Assertion>
|
||||
</samlp:Response>"##,
|
||||
now = fmt(now),
|
||||
nb = fmt(now - TimeDuration::minutes(5)),
|
||||
noa = fmt(now + TimeDuration::hours(1)),
|
||||
);
|
||||
let key = bergshamra::keys::loader::load_pem_auto(idp.key_pem.as_bytes(), None).unwrap();
|
||||
let mut km = bergshamra::keys::KeysManager::new();
|
||||
km.add_key(key);
|
||||
let ctx = bergshamra::DsigContext::new(km);
|
||||
bergshamra::sign(&ctx, &template).unwrap()
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() * 3);
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
out.push('%');
|
||||
out.push(char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase());
|
||||
out.push(char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn configure_sso(state: &AppState, metadata: String, allow_idp_initiated: bool) {
|
||||
state
|
||||
.sso
|
||||
.replace(SsoConfig {
|
||||
enabled: true,
|
||||
idp_name: "Test IdP".into(),
|
||||
idp_logo_url: String::new(),
|
||||
metadata,
|
||||
metadata_url: String::new(),
|
||||
entity_id: String::new(),
|
||||
allow_idp_initiated,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn post_acs(router: &axum::Router, signed_xml: &str) -> axum::response::Response {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(signed_xml.as_bytes());
|
||||
let body = format!("SAMLResponse={}", urlencode(&b64));
|
||||
router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/sso/acs")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn has_session_cookie(resp: &axum::response::Response) -> bool {
|
||||
resp.headers().get_all(header::SET_COOKIE).iter().any(|v| {
|
||||
let s = v.to_str().unwrap_or("");
|
||||
s.starts_with("openpxe_session=")
|
||||
&& !s.contains("openpxe_session=;")
|
||||
&& !s.contains("Max-Age=0")
|
||||
})
|
||||
}
|
||||
|
||||
fn location(resp: &axum::response::Response) -> String {
|
||||
resp.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sso_login_redirects_to_idp() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let idp = make_idp();
|
||||
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false);
|
||||
let app = build_router(state);
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/sso/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
let loc = location(&resp);
|
||||
assert!(loc.starts_with(IDP_SSO), "redirect to IdP, got {loc}");
|
||||
assert!(
|
||||
loc.contains("SAMLRequest="),
|
||||
"carries SAMLRequest, got {loc}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sso_login_unavailable_when_disabled() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state); // SSO never configured
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/sso/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert!(location(&resp).contains("sso_error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sso_metadata_is_served() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (status, body) = get(&app, "/api/sso/metadata").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let xml = String::from_utf8(body).unwrap();
|
||||
assert!(xml.contains("SPSSODescriptor"));
|
||||
assert!(xml.contains(SP_ACS));
|
||||
assert!(xml.contains(SP_BASE));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acs_idp_initiated_mints_session() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let idp = make_idp();
|
||||
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
|
||||
let app = build_router(state);
|
||||
let signed = signed_response(&idp, None);
|
||||
let resp = post_acs(&app, &signed).await;
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert_eq!(location(&resp), "/");
|
||||
assert!(
|
||||
has_session_cookie(&resp),
|
||||
"ACS must set an operator session cookie"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acs_idp_initiated_blocked_when_disabled() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let idp = make_idp();
|
||||
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false); // gate OFF
|
||||
let app = build_router(state);
|
||||
let signed = signed_response(&idp, None);
|
||||
let resp = post_acs(&app, &signed).await;
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert!(location(&resp).contains("sso_error"));
|
||||
assert!(
|
||||
!has_session_cookie(&resp),
|
||||
"no session when IdP-initiated is disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acs_sp_initiated_without_known_request_is_rejected() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let idp = make_idp();
|
||||
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
|
||||
let app = build_router(state);
|
||||
// A valid signature but an InResponseTo we never issued => reject.
|
||||
let signed = signed_response(&idp, Some("_never-issued"));
|
||||
let resp = post_acs(&app, &signed).await;
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert!(location(&resp).contains("sso_error"));
|
||||
assert!(!has_session_cookie(&resp));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acs_replayed_assertion_is_rejected() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let idp = make_idp();
|
||||
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
|
||||
let app = build_router(state);
|
||||
let signed = signed_response(&idp, None);
|
||||
// First use succeeds…
|
||||
let first = post_acs(&app, &signed).await;
|
||||
assert!(has_session_cookie(&first));
|
||||
// …replaying the identical assertion is rejected.
|
||||
let second = post_acs(&app, &signed).await;
|
||||
assert_eq!(second.status(), StatusCode::FOUND);
|
||||
assert!(location(&second).contains("sso_error"));
|
||||
assert!(!has_session_cookie(&second));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acs_garbage_is_rejected_without_500() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let idp = make_idp();
|
||||
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
|
||||
let app = build_router(state);
|
||||
let body = "SAMLResponse=not%20valid%20base64%21%21";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/sso/acs")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert!(location(&resp).contains("sso_error"));
|
||||
assert!(!has_session_cookie(&resp));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user