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,188 @@
|
||||
//! AuthnRequest construction + HTTP-Redirect binding encoding.
|
||||
//!
|
||||
//! For SP-initiated login we build an `<AuthnRequest>`, then encode it for the
|
||||
//! HTTP-Redirect binding: raw DEFLATE (RFC 1951) → base64 → percent-encode,
|
||||
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
|
||||
//! unsigned in this release (the IdP must not require client signatures).
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
use base64::Engine;
|
||||
use flate2::write::DeflateEncoder;
|
||||
use flate2::Compression;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::{SamlError, SpParams};
|
||||
|
||||
const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
|
||||
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
|
||||
const NAMEID_EMAIL: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
||||
const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
|
||||
|
||||
/// A built AuthnRequest, ready to redirect the browser to the IdP.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthnRequest {
|
||||
/// The request `ID` — the caller records this so the matching response's
|
||||
/// `InResponseTo` can be correlated (replay/CSRF protection).
|
||||
pub id: String,
|
||||
/// The full IdP URL to 302 the browser to (includes `SAMLRequest` and,
|
||||
/// when supplied, `RelayState`).
|
||||
pub location: String,
|
||||
}
|
||||
|
||||
/// Build an AuthnRequest targeting `idp_sso_url` and encode it for the
|
||||
/// HTTP-Redirect binding. `relay_state`, if given, round-trips back to us via
|
||||
/// the response (we use it to send the operator to their intended page).
|
||||
pub fn build(
|
||||
sp: &SpParams,
|
||||
idp_sso_url: &str,
|
||||
relay_state: Option<&str>,
|
||||
) -> Result<AuthnRequest, SamlError> {
|
||||
let id = format!("_{}", uuid::Uuid::new_v4().simple());
|
||||
let issue_instant = OffsetDateTime::now_utc()
|
||||
.replace_nanosecond(0)
|
||||
.unwrap_or_else(|_| OffsetDateTime::now_utc())
|
||||
.format(&Rfc3339)
|
||||
.map_err(|e| SamlError::Timestamp(e.to_string()))?;
|
||||
|
||||
let xml = format!(
|
||||
r#"<samlp:AuthnRequest xmlns:samlp="{NS_PROTOCOL}" xmlns:saml="{NS_ASSERTION}" ID="{id}" Version="2.0" IssueInstant="{instant}" Destination="{dest}" ProtocolBinding="{BINDING_POST}" AssertionConsumerServiceURL="{acs}"><saml:Issuer>{issuer}</saml:Issuer><samlp:NameIDPolicy Format="{NAMEID_EMAIL}" AllowCreate="true"/></samlp:AuthnRequest>"#,
|
||||
instant = issue_instant,
|
||||
dest = xml_escape(idp_sso_url),
|
||||
acs = xml_escape(&sp.acs_url),
|
||||
issuer = xml_escape(&sp.entity_id),
|
||||
);
|
||||
|
||||
let encoded = deflate_base64(&xml)?;
|
||||
|
||||
let sep = if idp_sso_url.contains('?') { '&' } else { '?' };
|
||||
let mut location = format!("{idp_sso_url}{sep}SAMLRequest={}", pct_encode(&encoded));
|
||||
if let Some(rs) = relay_state {
|
||||
location.push_str("&RelayState=");
|
||||
location.push_str(&pct_encode(rs));
|
||||
}
|
||||
|
||||
Ok(AuthnRequest { id, location })
|
||||
}
|
||||
|
||||
/// Raw-DEFLATE then base64 — the HTTP-Redirect binding's `SAMLRequest` payload.
|
||||
fn deflate_base64(xml: &str) -> Result<String, SamlError> {
|
||||
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
|
||||
enc.write_all(xml.as_bytes())
|
||||
.and_then(|()| enc.try_finish())
|
||||
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
|
||||
let compressed = enc
|
||||
.finish()
|
||||
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
|
||||
}
|
||||
|
||||
/// Percent-encode a query-string component (RFC 3986 unreserved set passes
|
||||
/// through; everything else is `%XX`).
|
||||
fn pct_encode(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);
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(out, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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::*;
|
||||
use flate2::read::DeflateDecoder;
|
||||
use std::io::Read;
|
||||
|
||||
fn sp() -> SpParams {
|
||||
SpParams {
|
||||
entity_id: "https://pxe.example.com".into(),
|
||||
acs_url: "https://pxe.example.com/api/sso/acs".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pct_decode(s: &str) -> Vec<u8> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hi = (bytes[i + 1] as char).to_digit(16).unwrap();
|
||||
let lo = (bytes[i + 2] as char).to_digit(16).unwrap();
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_is_ncname_and_location_has_request() {
|
||||
let req = build(&sp(), "https://idp.example.com/sso", Some("/dashboard")).unwrap();
|
||||
assert!(req.id.starts_with('_'));
|
||||
assert!(req
|
||||
.location
|
||||
.starts_with("https://idp.example.com/sso?SAMLRequest="));
|
||||
assert!(req.location.contains("&RelayState=%2Fdashboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_payload_round_trips_to_our_authn_request() {
|
||||
let req = build(&sp(), "https://idp.example.com/sso", None).unwrap();
|
||||
// Pull SAMLRequest value out of the query string.
|
||||
let q = req.location.split("SAMLRequest=").nth(1).unwrap();
|
||||
let val = q.split('&').next().unwrap();
|
||||
let compressed = base64::engine::general_purpose::STANDARD
|
||||
.decode(pct_decode(val))
|
||||
.unwrap();
|
||||
let mut inflate = DeflateDecoder::new(&compressed[..]);
|
||||
let mut xml = String::new();
|
||||
inflate.read_to_string(&mut xml).unwrap();
|
||||
|
||||
let doc = roxmltree::Document::parse(&xml).unwrap();
|
||||
let root = doc.root_element();
|
||||
assert_eq!(root.tag_name().name(), "AuthnRequest");
|
||||
assert_eq!(root.attribute("ID").unwrap(), req.id);
|
||||
assert_eq!(
|
||||
root.attribute("AssertionConsumerServiceURL").unwrap(),
|
||||
"https://pxe.example.com/api/sso/acs"
|
||||
);
|
||||
let issuer = root
|
||||
.descendants()
|
||||
.find(|n| n.tag_name().name() == "Issuer")
|
||||
.unwrap();
|
||||
assert_eq!(issuer.text().unwrap(), "https://pxe.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_query_uses_ampersand_separator() {
|
||||
let req = build(&sp(), "https://idp.example.com/sso?foo=bar", None).unwrap();
|
||||
assert!(req.location.contains("?foo=bar&SAMLRequest="));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user