Final cleanup before hardware testing. No behaviour changes; 248 tests green, clippy clean. #1 AppError newtype (http-api/src/error.rs) with one IntoResponse mapping (NotFound→404, Invalid→400, _→500) + From<core::Error>/From<io::Error>. Converted the clearly-safe handlers (sso_put, unattended_upload, branding_clear) to `?`; intentionally left handlers with bespoke status semantics (Invalid→404 on category, 409 on duplicate share / open upload) explicit so no asserted status changes. #2 figment-based Config::load (defaults → TOML → env). Keeps the historical flat OPENPXE_* names (Unraid/entrypoint compatible) AND adds the nested OPENPXE_SECTION__FIELD form; now covers every field (apply_env had silently skipped unattended_dir + bind addrs). 6 Jail tests prove backward-compat. Removed the hand-rolled apply_env. #3 thiserror 1→2; dropped unused mime/mime_guess/once_cell deps. #4 Re-evaluated: Duration::from_hours/from_mins are stable on the pinned 1.95 toolchain and clippy prefers them — kept the readable form (the "unstable" premise didn't hold; MSRV is intentionally 1.95). #5 insta snapshot of the rendered iPXE menu (version-filtered) + wiremock coverage of the SAML metadata-URL fetch (200 + non-2xx). #6 api_status → typed StatusResponse struct (was a 25-key json! blob) with a full_flow guard test asserting every UI key + the started_at string shape. Deferred the /api/docs typed conversion (lowest value, highest churn, zero functional benefit). #7 pct_encode/xml_escape de-duplicated into openpxe_core::encoding (were copied across app.rs + the SAML modules). No new crates. #8 UploadSessions registry → parking_lot::RwLock (sync, never held across .await); per-session lock stays tokio::Mutex. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
229 lines
9.1 KiB
Rust
229 lines
9.1 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};
|
|
use crate::encoding::xml_escape;
|
|
|
|
/// 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()
|
|
}
|
|
|
|
// `xml_escape` now lives in `openpxe_core::encoding` (v0.5.4) — imported above.
|
|
|
|
#[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();
|
|
}
|
|
}
|