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
Generated
+1557
-22
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -71,6 +71,20 @@ nfs3_types = "0.5"
|
|||||||
# to match reqwest and stay musl-static-friendly — no OpenSSL.
|
# to match reqwest and stay musl-static-friendly — no OpenSSL.
|
||||||
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
|
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
|
||||||
|
|
||||||
|
# v0.5.1: pure-Rust SAML 2.0 Service Provider. bergshamra does XML-DSig
|
||||||
|
# verification + exclusive c14n with RustCrypto (no OpenSSL/xmlsec/libxml2
|
||||||
|
# C deps), so the static musl binary stays OpenSSL-free — samael was
|
||||||
|
# rejected precisely because it hard-requires OpenSSL. We build the thin
|
||||||
|
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
|
||||||
|
bergshamra = "0.4"
|
||||||
|
roxmltree = "0.21"
|
||||||
|
quick-xml = "0.40"
|
||||||
|
x509-parser = "0.18"
|
||||||
|
# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the
|
||||||
|
# zlib/zlib-ng C backends, which would break the musl-static build.
|
||||||
|
flate2 = "1.1"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
openpxe-core = { path = "crates/core" }
|
openpxe-core = { path = "crates/core" }
|
||||||
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
|
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
|
||||||
openpxe-tftp = { path = "crates/tftp" }
|
openpxe-tftp = { path = "crates/tftp" }
|
||||||
|
|||||||
@@ -25,5 +25,19 @@ tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
|
|||||||
# for per-ISO boot passwords; just re-exported here.
|
# for per-ISO boot passwords; just re-exported here.
|
||||||
bcrypt.workspace = true
|
bcrypt.workspace = true
|
||||||
|
|
||||||
|
# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive
|
||||||
|
# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML;
|
||||||
|
# x509-parser pulls the IdP signing cert out of metadata; flate2+base64
|
||||||
|
# encode the HTTP-Redirect binding's SAMLRequest.
|
||||||
|
bergshamra.workspace = true
|
||||||
|
roxmltree.workspace = true
|
||||||
|
quick-xml.workspace = true
|
||||||
|
x509-parser.workspace = true
|
||||||
|
flate2.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.12"
|
tempfile = "3.12"
|
||||||
|
# v0.5.1: generate a throwaway self-signed signing cert/key so SAML
|
||||||
|
# verification tests can produce genuinely signed SAMLResponses.
|
||||||
|
rcgen = "0.13"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod log_bus;
|
|||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod notify;
|
pub mod notify;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
|
pub mod saml;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod sso;
|
pub mod sso;
|
||||||
pub mod wol;
|
pub mod wol;
|
||||||
@@ -23,7 +24,6 @@ pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
|||||||
pub use boot_log::{BootEvent, BootLog};
|
pub use boot_log::{BootEvent, BootLog};
|
||||||
pub use branding::{ext_for_mime, BrandingStore, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
|
pub use branding::{ext_for_mime, BrandingStore, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
|
||||||
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
|
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
|
||||||
pub use sso::{SsoConfig, SsoStore};
|
|
||||||
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
|
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
|
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
|
||||||
@@ -31,4 +31,6 @@ pub use log_bus::{LogBus, LogBusLayer, LogLine};
|
|||||||
pub use metrics::{HttpRoute, Metrics};
|
pub use metrics::{HttpRoute, Metrics};
|
||||||
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
|
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
|
||||||
pub use queue::{DeploymentQueue, QueueEntry};
|
pub use queue::{DeploymentQueue, QueueEntry};
|
||||||
|
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
|
||||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||||
|
pub use sso::{SsoConfig, SsoStore};
|
||||||
|
|||||||
@@ -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="));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! Pure-Rust SAML 2.0 Service Provider (v0.5.1).
|
||||||
|
//!
|
||||||
|
//! This module implements the SP half of a SAML Web-Browser-SSO profile:
|
||||||
|
//!
|
||||||
|
//! * [`metadata`] — parse the IdP's `EntityDescriptor` (SSO URLs + signing
|
||||||
|
//! certificates) and build *our* SP metadata for the IdP admin to import.
|
||||||
|
//! * [`authn_request`] — build an `AuthnRequest` and encode it for the
|
||||||
|
//! HTTP-Redirect binding.
|
||||||
|
//! * [`response`] — decode a `SAMLResponse`, **verify its XML signature**
|
||||||
|
//! against the IdP's pinned certificate (via the pure-Rust `bergshamra`
|
||||||
|
//! crate — no OpenSSL/libxml2/xmlsec, so the static musl binary stays
|
||||||
|
//! C-free), then enforce the SP-side semantic checks (Status, Destination,
|
||||||
|
//! Audience, time bounds) that are where SAML SPs actually get attacked.
|
||||||
|
//!
|
||||||
|
//! Stateful checks (replay of assertion IDs, correlating `InResponseTo`
|
||||||
|
//! against requests *we* issued, gating IdP-initiated login) live in the
|
||||||
|
//! HTTP layer — [`response::consume`] is deliberately stateless and returns
|
||||||
|
//! the IDs the caller needs to perform them.
|
||||||
|
//!
|
||||||
|
//! Access model: any assertion the IdP authenticates and we cryptographically
|
||||||
|
//! verify yields an operator [`VerifiedPrincipal`]. OpenPXE is single-tier —
|
||||||
|
//! there is no per-user role table — and the local admin account remains a
|
||||||
|
//! guaranteed fallback owner regardless of SSO state.
|
||||||
|
|
||||||
|
pub mod authn_request;
|
||||||
|
pub mod metadata;
|
||||||
|
pub mod response;
|
||||||
|
|
||||||
|
pub use authn_request::AuthnRequest;
|
||||||
|
pub use metadata::IdpMetadata;
|
||||||
|
pub use response::{VerifiedPrincipal, VerifiedResponse};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Default clock-skew tolerance applied to assertion time bounds. SAML IdPs
|
||||||
|
/// and SPs rarely have perfectly synced clocks; 60s matches common practice
|
||||||
|
/// (Shibboleth/FleetDM defaults are in this ballpark).
|
||||||
|
pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 60;
|
||||||
|
|
||||||
|
/// Runtime SP parameters, derived from [`crate::SsoConfig`] + the advertised
|
||||||
|
/// public base URL by the HTTP layer.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SpParams {
|
||||||
|
/// Our SP Entity ID (the `<Issuer>` we send and the `Audience` we require
|
||||||
|
/// in responses). Defaults to the public base URL when the operator left
|
||||||
|
/// the Entity ID field blank.
|
||||||
|
pub entity_id: String,
|
||||||
|
/// The Assertion Consumer Service URL the IdP POSTs the response to —
|
||||||
|
/// `<public_base_url>/api/sso/acs`.
|
||||||
|
pub acs_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything that can go wrong consuming a SAML response. Kept coarse on
|
||||||
|
/// purpose: the HTTP layer logs the detail and shows the operator a generic
|
||||||
|
/// "SSO sign-in failed" — we never leak which specific check tripped to the
|
||||||
|
/// browser, since that aids an attacker probing the SP.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SamlError {
|
||||||
|
#[error("SAML XML parse error: {0}")]
|
||||||
|
Xml(String),
|
||||||
|
#[error("IdP metadata is missing a required element: {0}")]
|
||||||
|
Metadata(String),
|
||||||
|
#[error("no usable IdP signing certificate in metadata")]
|
||||||
|
NoSigningCert,
|
||||||
|
#[error("signature verification failed: {0}")]
|
||||||
|
Signature(String),
|
||||||
|
#[error("the signature does not cover the assertion we read")]
|
||||||
|
SignatureScope,
|
||||||
|
#[error("SAML response status was not Success: {0}")]
|
||||||
|
Status(String),
|
||||||
|
#[error("response is missing a required element: {0}")]
|
||||||
|
MissingElement(String),
|
||||||
|
#[error("encrypted assertions are not supported in this release")]
|
||||||
|
EncryptedAssertionUnsupported,
|
||||||
|
#[error("expected exactly one assertion, found {0}")]
|
||||||
|
AssertionCount(usize),
|
||||||
|
#[error("issuer mismatch: response was not issued by the configured IdP")]
|
||||||
|
IssuerMismatch,
|
||||||
|
#[error("audience mismatch: assertion is not addressed to this service provider")]
|
||||||
|
AudienceMismatch,
|
||||||
|
#[error("response destination does not match our ACS URL")]
|
||||||
|
DestinationMismatch,
|
||||||
|
#[error("assertion is expired or not yet valid")]
|
||||||
|
TimeBounds,
|
||||||
|
#[error("invalid SAML timestamp: {0}")]
|
||||||
|
Timestamp(String),
|
||||||
|
#[error("base64 decode failed: {0}")]
|
||||||
|
Base64(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//! SAMLResponse consumption: signature verification + SP-side validation.
|
||||||
|
//!
|
||||||
|
//! [`consume`] is intentionally **stateless** — it verifies the XML signature
|
||||||
|
//! against the IdP's pinned certificate(s) and enforces every check that can
|
||||||
|
//! be made from the response alone (Status, Destination, Issuer, Audience,
|
||||||
|
//! time bounds, signature scope). It then returns the `assertion_id` and
|
||||||
|
//! `in_response_to` so the HTTP layer can perform the *stateful* checks it
|
||||||
|
//! owns: replay rejection, correlating the request we issued, and gating
|
||||||
|
//! IdP-initiated login.
|
||||||
|
|
||||||
|
use roxmltree::{Document, Node};
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
|
use super::metadata::IdpMetadata;
|
||||||
|
use super::{SamlError, SpParams};
|
||||||
|
|
||||||
|
const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
|
||||||
|
|
||||||
|
/// The verified subject of a SAML assertion. OpenPXE is single-tier, so this
|
||||||
|
/// is all an operator session needs.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VerifiedPrincipal {
|
||||||
|
/// The `<NameID>` value (an email, per our requested NameID format).
|
||||||
|
pub name_id: String,
|
||||||
|
/// Email used as the session identity. Equals `name_id` for the
|
||||||
|
/// emailAddress NameID format.
|
||||||
|
pub email: String,
|
||||||
|
/// Human-readable display name, if the IdP sent one as an attribute.
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a successful [`consume`]. The IDs/expiry feed the HTTP layer's
|
||||||
|
/// stateful checks.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VerifiedResponse {
|
||||||
|
pub principal: VerifiedPrincipal,
|
||||||
|
/// `InResponseTo` from the response, if present. `None` = unsolicited
|
||||||
|
/// (IdP-initiated) — the HTTP layer only accepts that when the operator
|
||||||
|
/// enabled it.
|
||||||
|
pub in_response_to: Option<String>,
|
||||||
|
/// The assertion's `ID` — used by the caller as the replay-guard key.
|
||||||
|
pub assertion_id: String,
|
||||||
|
/// The assertion's expiry (`Conditions/@NotOnOrAfter`) — the replay
|
||||||
|
/// guard can drop the consumed ID after this instant.
|
||||||
|
pub assertion_expiry: OffsetDateTime,
|
||||||
|
/// `AuthnStatement/@SessionIndex`, if present (useful for future SLO).
|
||||||
|
pub session_index: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify and validate a decoded `SAMLResponse` XML document.
|
||||||
|
pub fn consume(
|
||||||
|
xml: &str,
|
||||||
|
sp: &SpParams,
|
||||||
|
idp: &IdpMetadata,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
clock_skew: Duration,
|
||||||
|
) -> Result<VerifiedResponse, SamlError> {
|
||||||
|
// 1. Cryptographically verify the signature against the pinned IdP cert(s).
|
||||||
|
// `trusted_keys_only` ignores any cert embedded in the document's
|
||||||
|
// KeyInfo, so an attacker can't substitute their own key.
|
||||||
|
let verified_uris = verify_signature(xml, &idp.signing_certs_der)?;
|
||||||
|
|
||||||
|
// 2. Parse for semantic validation.
|
||||||
|
let doc = Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
|
||||||
|
let root = doc.root_element();
|
||||||
|
if root.tag_name().name() != "Response" {
|
||||||
|
return Err(SamlError::MissingElement("Response".into()));
|
||||||
|
}
|
||||||
|
let response_id = root.attribute("ID").map(str::to_owned);
|
||||||
|
let in_response_to = root.attribute("InResponseTo").map(str::to_owned);
|
||||||
|
|
||||||
|
// 3. Status must be Success.
|
||||||
|
let status_value = root
|
||||||
|
.descendants()
|
||||||
|
.find(|n| n.is_element() && n.tag_name().name() == "StatusCode")
|
||||||
|
.and_then(|sc| sc.attribute("Value"))
|
||||||
|
.unwrap_or("");
|
||||||
|
if status_value != STATUS_SUCCESS && !status_value.ends_with(":Success") {
|
||||||
|
return Err(SamlError::Status(status_value.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Destination (if the IdP set one) must be our ACS.
|
||||||
|
if let Some(dest) = root.attribute("Destination") {
|
||||||
|
if !urls_equal(dest, &sp.acs_url) {
|
||||||
|
return Err(SamlError::DestinationMismatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Exactly one (unencrypted) Assertion.
|
||||||
|
if root
|
||||||
|
.descendants()
|
||||||
|
.any(|n| n.is_element() && n.tag_name().name() == "EncryptedAssertion")
|
||||||
|
{
|
||||||
|
return Err(SamlError::EncryptedAssertionUnsupported);
|
||||||
|
}
|
||||||
|
let assertions: Vec<Node<'_, '_>> = root
|
||||||
|
.children()
|
||||||
|
.filter(|c| c.is_element() && c.tag_name().name() == "Assertion")
|
||||||
|
.collect();
|
||||||
|
if assertions.len() != 1 {
|
||||||
|
return Err(SamlError::AssertionCount(assertions.len()));
|
||||||
|
}
|
||||||
|
let assertion = assertions[0];
|
||||||
|
let assertion_id = assertion
|
||||||
|
.attribute("ID")
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(|| SamlError::MissingElement("Assertion/@ID".into()))?;
|
||||||
|
|
||||||
|
// 6. The signature must actually cover the assertion we're about to trust:
|
||||||
|
// either the assertion itself, the enclosing response, or the whole
|
||||||
|
// document. (bergshamra's strict_verification already constrains where
|
||||||
|
// the signed element may sit; this ties it to *our* assertion.)
|
||||||
|
let covers_assertion = verified_uris.iter().any(|u| {
|
||||||
|
u.is_empty()
|
||||||
|
|| u == &format!("#{assertion_id}")
|
||||||
|
|| response_id
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|rid| u == &format!("#{rid}"))
|
||||||
|
});
|
||||||
|
if !covers_assertion {
|
||||||
|
return Err(SamlError::SignatureScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Issuer must be the configured IdP.
|
||||||
|
let issuer = first_child(assertion, "Issuer")
|
||||||
|
.map(text_of)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !idp.entity_id.is_empty() && issuer != idp.entity_id {
|
||||||
|
return Err(SamlError::IssuerMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Subject → NameID + SubjectConfirmationData time/recipient checks.
|
||||||
|
let subject = first_child(assertion, "Subject")
|
||||||
|
.ok_or_else(|| SamlError::MissingElement("Subject".into()))?;
|
||||||
|
let name_id = first_child(subject, "NameID")
|
||||||
|
.map(text_of)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| SamlError::MissingElement("NameID".into()))?;
|
||||||
|
if let Some(scd) = subject
|
||||||
|
.descendants()
|
||||||
|
.find(|n| n.is_element() && n.tag_name().name() == "SubjectConfirmationData")
|
||||||
|
{
|
||||||
|
if let Some(recipient) = scd.attribute("Recipient") {
|
||||||
|
if !urls_equal(recipient, &sp.acs_url) {
|
||||||
|
return Err(SamlError::DestinationMismatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(noa) = scd.attribute("NotOnOrAfter") {
|
||||||
|
let noa = parse_instant(noa)?;
|
||||||
|
if now >= noa + clock_skew {
|
||||||
|
return Err(SamlError::TimeBounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Conditions: time window + audience.
|
||||||
|
let conditions = first_child(assertion, "Conditions");
|
||||||
|
if let Some(cond) = conditions {
|
||||||
|
if let Some(nb) = cond.attribute("NotBefore") {
|
||||||
|
let nb = parse_instant(nb)?;
|
||||||
|
if now < nb - clock_skew {
|
||||||
|
return Err(SamlError::TimeBounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let assertion_expiry = conditions
|
||||||
|
.and_then(|c| c.attribute("NotOnOrAfter"))
|
||||||
|
.map(parse_instant)
|
||||||
|
.transpose()?
|
||||||
|
.ok_or_else(|| SamlError::MissingElement("Conditions/@NotOnOrAfter".into()))?;
|
||||||
|
if now >= assertion_expiry + clock_skew {
|
||||||
|
return Err(SamlError::TimeBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
let audience_ok = conditions.is_some_and(|c| {
|
||||||
|
c.descendants()
|
||||||
|
.filter(|n| n.is_element() && n.tag_name().name() == "Audience")
|
||||||
|
.any(|a| text_of(a) == sp.entity_id)
|
||||||
|
});
|
||||||
|
if !audience_ok {
|
||||||
|
return Err(SamlError::AudienceMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Optional: SessionIndex + display-name attribute.
|
||||||
|
let session_index = assertion
|
||||||
|
.descendants()
|
||||||
|
.find(|n| n.is_element() && n.tag_name().name() == "AuthnStatement")
|
||||||
|
.and_then(|a| a.attribute("SessionIndex"))
|
||||||
|
.map(str::to_owned);
|
||||||
|
|
||||||
|
let display_name = extract_display_name(assertion);
|
||||||
|
|
||||||
|
Ok(VerifiedResponse {
|
||||||
|
principal: VerifiedPrincipal {
|
||||||
|
email: name_id.clone(),
|
||||||
|
name_id,
|
||||||
|
display_name,
|
||||||
|
},
|
||||||
|
in_response_to,
|
||||||
|
assertion_id,
|
||||||
|
assertion_expiry,
|
||||||
|
session_index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the document's XML-DSig against each pinned IdP cert in turn
|
||||||
|
/// (handles key rotation), returning the verified `<Reference>` URIs.
|
||||||
|
fn verify_signature(xml: &str, certs_der: &[Vec<u8>]) -> Result<Vec<String>, SamlError> {
|
||||||
|
let mut last_err = String::from("no signing certificate matched");
|
||||||
|
for der in certs_der {
|
||||||
|
let key = match bergshamra::keys::loader::load_x509_cert_der(der) {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(e) => {
|
||||||
|
last_err = e.to_string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut km = bergshamra::keys::KeysManager::new();
|
||||||
|
km.add_key(key);
|
||||||
|
// trusted_keys_only: only ever trust the pinned IdP key, never an
|
||||||
|
// inline KeyInfo cert. strict_verification: XSW positional defense.
|
||||||
|
let ctx = bergshamra::DsigContext::new(km)
|
||||||
|
.with_trusted_keys_only(true)
|
||||||
|
.with_strict_verification(true);
|
||||||
|
match bergshamra::verify(&ctx, xml) {
|
||||||
|
Ok(bergshamra::VerifyResult::Valid { references, .. }) => {
|
||||||
|
return Ok(references.into_iter().map(|r| r.uri).collect());
|
||||||
|
}
|
||||||
|
Ok(bergshamra::VerifyResult::Invalid { reason }) => last_err = reason,
|
||||||
|
Err(e) => last_err = e.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(SamlError::Signature(last_err))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull a display name from the assertion's attribute statement, trying the
|
||||||
|
/// common attribute names IdPs use (FleetDM checks the same set).
|
||||||
|
fn extract_display_name(assertion: Node<'_, '_>) -> Option<String> {
|
||||||
|
const WANTED: &[&str] = &[
|
||||||
|
"name",
|
||||||
|
"displayname",
|
||||||
|
"cn",
|
||||||
|
"urn:oid:2.5.4.3",
|
||||||
|
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||||
|
];
|
||||||
|
for attr in assertion
|
||||||
|
.descendants()
|
||||||
|
.filter(|n| n.is_element() && n.tag_name().name() == "Attribute")
|
||||||
|
{
|
||||||
|
let key = attr
|
||||||
|
.attribute("Name")
|
||||||
|
.or_else(|| attr.attribute("FriendlyName"))
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if WANTED.contains(&key.as_str()) {
|
||||||
|
if let Some(val) = attr
|
||||||
|
.descendants()
|
||||||
|
.find(|n| n.is_element() && n.tag_name().name() == "AttributeValue")
|
||||||
|
{
|
||||||
|
let v = text_of(val);
|
||||||
|
if !v.is_empty() {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_child<'a, 'i>(n: Node<'a, 'i>, local: &str) -> Option<Node<'a, 'i>> {
|
||||||
|
n.children()
|
||||||
|
.find(|c| c.is_element() && c.tag_name().name() == local)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_of(n: Node<'_, '_>) -> String {
|
||||||
|
n.children()
|
||||||
|
.filter(Node::is_text)
|
||||||
|
.filter_map(|c| c.text())
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an `xs:dateTime` (e.g. `2026-05-31T12:00:00.000Z`).
|
||||||
|
fn parse_instant(s: &str) -> Result<OffsetDateTime, SamlError> {
|
||||||
|
OffsetDateTime::parse(s.trim(), &Rfc3339).map_err(|e| SamlError::Timestamp(format!("{s}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two URLs for SAML endpoint-matching purposes: exact, or differing
|
||||||
|
/// only by a single trailing slash.
|
||||||
|
fn urls_equal(a: &str, b: &str) -> bool {
|
||||||
|
a == b || a.trim_end_matches('/') == b.trim_end_matches('/')
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
//! End-to-end SAML SP tests.
|
||||||
|
//!
|
||||||
|
//! We mint a throwaway self-signed cert/key with `rcgen`, sign a SAML Response
|
||||||
|
//! template with `bergshamra::sign` (the same engine that verifies it), and
|
||||||
|
//! drive [`response::consume`] through the accept path and every reject path.
|
||||||
|
//! This proves both the signature wiring and the SP-semantic checks.
|
||||||
|
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
|
use super::metadata::IdpMetadata;
|
||||||
|
use super::{response, SamlError, SpParams};
|
||||||
|
|
||||||
|
const SP_ENTITY: &str = "https://pxe.example.com";
|
||||||
|
const ACS: &str = "https://pxe.example.com/api/sso/acs";
|
||||||
|
const IDP_ENTITY: &str = "https://idp.example.com/realms/fleet";
|
||||||
|
const EMAIL: &str = "[email protected]";
|
||||||
|
|
||||||
|
struct TestIdp {
|
||||||
|
cert_der: Vec<u8>,
|
||||||
|
key_pem: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_idp() -> TestIdp {
|
||||||
|
let ck = rcgen::generate_simple_self_signed(vec!["idp.example.com".to_string()]).unwrap();
|
||||||
|
TestIdp {
|
||||||
|
cert_der: ck.cert.der().as_ref().to_vec(),
|
||||||
|
key_pem: ck.key_pair.serialize_pem(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt(t: OffsetDateTime) -> String {
|
||||||
|
t.replace_nanosecond(0).unwrap().format(&Rfc3339).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Knobs for building a response template — defaults are a valid response.
|
||||||
|
struct Resp {
|
||||||
|
issuer: String,
|
||||||
|
audience: String,
|
||||||
|
status: String,
|
||||||
|
not_before: OffsetDateTime,
|
||||||
|
not_on_or_after: OffsetDateTime,
|
||||||
|
in_response_to: Option<String>,
|
||||||
|
recipient: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Resp {
|
||||||
|
fn default() -> Self {
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
Self {
|
||||||
|
issuer: IDP_ENTITY.into(),
|
||||||
|
audience: SP_ENTITY.into(),
|
||||||
|
status: "urn:oasis:names:tc:SAML:2.0:status:Success".into(),
|
||||||
|
not_before: now - Duration::minutes(5),
|
||||||
|
not_on_or_after: now + Duration::hours(1),
|
||||||
|
in_response_to: Some("_req-abc".into()),
|
||||||
|
recipient: ACS.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resp {
|
||||||
|
/// The unsigned template (a `<ds:Signature>` with empty values).
|
||||||
|
fn template(&self) -> String {
|
||||||
|
let now = fmt(OffsetDateTime::now_utc());
|
||||||
|
let irt = self
|
||||||
|
.in_response_to
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| format!(r#" InResponseTo="{v}""#))
|
||||||
|
.unwrap_or_default();
|
||||||
|
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="{ACS}"{irt}>
|
||||||
|
<saml:Issuer>{issuer}</saml:Issuer>
|
||||||
|
<samlp:Status><samlp:StatusCode Value="{status}"/></samlp:Status>
|
||||||
|
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
|
||||||
|
<saml:Issuer>{issuer}</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}</saml:NameID>
|
||||||
|
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||||
|
<saml:SubjectConfirmationData Recipient="{recipient}" NotOnOrAfter="{noa}"{irt}/>
|
||||||
|
</saml:SubjectConfirmation>
|
||||||
|
</saml:Subject>
|
||||||
|
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
|
||||||
|
<saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
|
||||||
|
</saml:Conditions>
|
||||||
|
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-123">
|
||||||
|
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
|
||||||
|
</saml:AuthnStatement>
|
||||||
|
<saml:AttributeStatement>
|
||||||
|
<saml:Attribute Name="displayName"><saml:AttributeValue>Miles Ward</saml:AttributeValue></saml:Attribute>
|
||||||
|
</saml:AttributeStatement>
|
||||||
|
</saml:Assertion>
|
||||||
|
</samlp:Response>"##,
|
||||||
|
issuer = self.issuer,
|
||||||
|
status = self.status,
|
||||||
|
audience = self.audience,
|
||||||
|
recipient = self.recipient,
|
||||||
|
nb = fmt(self.not_before),
|
||||||
|
noa = fmt(self.not_on_or_after),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(template: &str, key_pem: &str) -> String {
|
||||||
|
let key = bergshamra::keys::loader::load_pem_auto(key_pem.as_bytes(), None)
|
||||||
|
.expect("load test signing key");
|
||||||
|
let mut km = bergshamra::keys::KeysManager::new();
|
||||||
|
km.add_key(key);
|
||||||
|
let ctx = bergshamra::DsigContext::new(km);
|
||||||
|
bergshamra::sign(&ctx, template).expect("sign test response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sp() -> SpParams {
|
||||||
|
SpParams {
|
||||||
|
entity_id: SP_ENTITY.into(),
|
||||||
|
acs_url: ACS.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn idp(cert_der: Vec<u8>) -> IdpMetadata {
|
||||||
|
IdpMetadata {
|
||||||
|
entity_id: IDP_ENTITY.into(),
|
||||||
|
sso_redirect_url: None,
|
||||||
|
sso_post_url: None,
|
||||||
|
signing_certs_der: vec![cert_der],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consume(xml: &str, cert_der: Vec<u8>) -> Result<response::VerifiedResponse, SamlError> {
|
||||||
|
response::consume(
|
||||||
|
xml,
|
||||||
|
&sp(),
|
||||||
|
&idp(cert_der),
|
||||||
|
OffsetDateTime::now_utc(),
|
||||||
|
Duration::seconds(60),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn good_response_yields_principal() {
|
||||||
|
let t = test_idp();
|
||||||
|
let signed = sign(&Resp::default().template(), &t.key_pem);
|
||||||
|
let out = consume(&signed, t.cert_der).expect("valid response should verify");
|
||||||
|
assert_eq!(out.principal.email, EMAIL);
|
||||||
|
assert_eq!(out.principal.name_id, EMAIL);
|
||||||
|
assert_eq!(out.principal.display_name.as_deref(), Some("Miles Ward"));
|
||||||
|
assert_eq!(out.in_response_to.as_deref(), Some("_req-abc"));
|
||||||
|
assert_eq!(out.assertion_id, "_assertion1");
|
||||||
|
assert_eq!(out.session_index.as_deref(), Some("sess-123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_assertion_is_rejected() {
|
||||||
|
let t = test_idp();
|
||||||
|
let signed = sign(&Resp::default().template(), &t.key_pem);
|
||||||
|
// Flip the subject email after signing — breaks the digest.
|
||||||
|
let tampered = signed.replace(EMAIL, "[email protected]");
|
||||||
|
assert_ne!(signed, tampered);
|
||||||
|
assert!(matches!(
|
||||||
|
consume(&tampered, t.cert_der),
|
||||||
|
Err(SamlError::Signature(_) | SamlError::SignatureScope)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsigned_response_is_rejected() {
|
||||||
|
let t = test_idp();
|
||||||
|
// Feed the *unsigned* template (empty SignatureValue) straight in.
|
||||||
|
let unsigned = Resp::default().template();
|
||||||
|
assert!(matches!(
|
||||||
|
consume(&unsigned, t.cert_der),
|
||||||
|
Err(SamlError::Signature(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_signing_key_is_rejected() {
|
||||||
|
let signer = test_idp();
|
||||||
|
let other = test_idp(); // different keypair pinned as the "IdP" cert
|
||||||
|
let signed = sign(&Resp::default().template(), &signer.key_pem);
|
||||||
|
assert!(matches!(
|
||||||
|
consume(&signed, other.cert_der),
|
||||||
|
Err(SamlError::Signature(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_audience_is_rejected() {
|
||||||
|
let t = test_idp();
|
||||||
|
let r = Resp {
|
||||||
|
audience: "https://someone-else.example".into(),
|
||||||
|
..Resp::default()
|
||||||
|
};
|
||||||
|
let signed = sign(&r.template(), &t.key_pem);
|
||||||
|
assert!(matches!(
|
||||||
|
consume(&signed, t.cert_der),
|
||||||
|
Err(SamlError::AudienceMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_assertion_is_rejected() {
|
||||||
|
let t = test_idp();
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let r = Resp {
|
||||||
|
not_before: now - Duration::hours(2),
|
||||||
|
not_on_or_after: now - Duration::hours(1),
|
||||||
|
..Resp::default()
|
||||||
|
};
|
||||||
|
let signed = sign(&r.template(), &t.key_pem);
|
||||||
|
assert!(matches!(
|
||||||
|
consume(&signed, t.cert_der),
|
||||||
|
Err(SamlError::TimeBounds)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn future_assertion_is_rejected() {
|
||||||
|
let t = test_idp();
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let r = Resp {
|
||||||
|
not_before: now + Duration::hours(1),
|
||||||
|
not_on_or_after: now + Duration::hours(2),
|
||||||
|
..Resp::default()
|
||||||
|
};
|
||||||
|
let signed = sign(&r.template(), &t.key_pem);
|
||||||
|
assert!(matches!(
|
||||||
|
consume(&signed, t.cert_der),
|
||||||
|
Err(SamlError::TimeBounds)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_issuer_is_rejected() {
|
||||||
|
let t = test_idp();
|
||||||
|
let r = Resp {
|
||||||
|
issuer: "https://evil-idp.example".into(),
|
||||||
|
..Resp::default()
|
||||||
|
};
|
||||||
|
let signed = sign(&r.template(), &t.key_pem);
|
||||||
|
assert!(matches!(
|
||||||
|
consume(&signed, t.cert_der),
|
||||||
|
Err(SamlError::IssuerMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_success_status_is_rejected() {
|
||||||
|
let t = test_idp();
|
||||||
|
let r = Resp {
|
||||||
|
status: "urn:oasis:names:tc:SAML:2.0:status:Requester".into(),
|
||||||
|
..Resp::default()
|
||||||
|
};
|
||||||
|
let signed = sign(&r.template(), &t.key_pem);
|
||||||
|
assert!(matches!(
|
||||||
|
consume(&signed, t.cert_der),
|
||||||
|
Err(SamlError::Status(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idp_initiated_has_no_in_response_to() {
|
||||||
|
// No InResponseTo => the HTTP layer must gate it behind allow_idp_initiated.
|
||||||
|
let t = test_idp();
|
||||||
|
let r = Resp {
|
||||||
|
in_response_to: None,
|
||||||
|
..Resp::default()
|
||||||
|
};
|
||||||
|
let signed = sign(&r.template(), &t.key_pem);
|
||||||
|
let out = consume(&signed, t.cert_der).expect("unsolicited but otherwise valid");
|
||||||
|
assert!(out.in_response_to.is_none());
|
||||||
|
}
|
||||||
+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
|
//! 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
|
//! human-readable label. As of v0.5.1 the SAML login flow is wired
|
||||||
//! response-validation / JIT-provisioning flow lands in a later release
|
//! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS
|
||||||
//! — for now we cover the "configurable" half so an operator can teach
|
//! endpoint, pure-Rust signature verification, and operator-session
|
||||||
//! OpenPXE about their IdP today and flip the switch on next upgrade.
|
//! minting. This module owns only the persisted *configuration*.
|
||||||
//!
|
//!
|
||||||
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
|
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
|
||||||
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you
|
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any
|
||||||
//! have access or you don't). Entity ID is omitted from the operator
|
//! IdP-authenticated user the SP cryptographically verifies gets an
|
||||||
//! UI per the v0.4.5 brief — it defaults to the advertised public base
|
//! operator session; there is no per-user role table). Entity ID is
|
||||||
//! URL when SAML wiring lands, which is what most IdPs expect anyway.
|
//! 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 parking_lot::RwLock;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -47,6 +48,18 @@ pub struct SsoConfig {
|
|||||||
/// future SAML flow; not validated here beyond a basic length cap.
|
/// future SAML flow; not validated here beyond a basic length cap.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metadata_url: String,
|
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 {
|
impl SsoConfig {
|
||||||
@@ -56,8 +69,7 @@ impl SsoConfig {
|
|||||||
/// surface a yellow "configured but not live yet" hint.
|
/// surface a yellow "configured but not live yet" hint.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_usable(&self) -> bool {
|
pub fn is_usable(&self) -> bool {
|
||||||
self.enabled
|
self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
|
||||||
&& (!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.idp_logo_url = cfg.idp_logo_url.trim().to_string();
|
||||||
cfg.metadata = cfg.metadata.trim().to_string();
|
cfg.metadata = cfg.metadata.trim().to_string();
|
||||||
cfg.metadata_url = cfg.metadata_url.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 {
|
if cfg.metadata.len() > MAX_METADATA_BYTES {
|
||||||
return Err(Error::Invalid(format!(
|
return Err(Error::Invalid(format!(
|
||||||
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
|
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
|
||||||
@@ -217,6 +235,8 @@ mod tests {
|
|||||||
metadata: String::new(),
|
metadata: String::new(),
|
||||||
metadata_url: "https://idp.example.com/metadata".into(),
|
metadata_url: "https://idp.example.com/metadata".into(),
|
||||||
idp_logo_url: String::new(),
|
idp_logo_url: String::new(),
|
||||||
|
entity_id: String::new(),
|
||||||
|
allow_idp_initiated: false,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(s);
|
drop(s);
|
||||||
@@ -239,6 +259,8 @@ mod tests {
|
|||||||
metadata: xml.into(),
|
metadata: xml.into(),
|
||||||
metadata_url: String::new(),
|
metadata_url: String::new(),
|
||||||
idp_logo_url: String::new(),
|
idp_logo_url: String::new(),
|
||||||
|
entity_id: String::new(),
|
||||||
|
allow_idp_initiated: false,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(s.snapshot().is_usable());
|
assert!(s.snapshot().is_usable());
|
||||||
@@ -254,6 +276,8 @@ mod tests {
|
|||||||
metadata: String::new(),
|
metadata: String::new(),
|
||||||
metadata_url: String::new(),
|
metadata_url: String::new(),
|
||||||
idp_logo_url: String::new(),
|
idp_logo_url: String::new(),
|
||||||
|
entity_id: String::new(),
|
||||||
|
allow_idp_initiated: false,
|
||||||
});
|
});
|
||||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||||
// …and a disabled blank config is fine.
|
// …and a disabled blank config is fine.
|
||||||
@@ -270,6 +294,8 @@ mod tests {
|
|||||||
metadata: String::new(),
|
metadata: String::new(),
|
||||||
metadata_url: "ftp://idp.example.com/metadata".into(),
|
metadata_url: "ftp://idp.example.com/metadata".into(),
|
||||||
idp_logo_url: String::new(),
|
idp_logo_url: String::new(),
|
||||||
|
entity_id: String::new(),
|
||||||
|
allow_idp_initiated: false,
|
||||||
});
|
});
|
||||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||||
}
|
}
|
||||||
@@ -287,6 +313,8 @@ mod tests {
|
|||||||
metadata: String::new(),
|
metadata: String::new(),
|
||||||
metadata_url: String::new(),
|
metadata_url: String::new(),
|
||||||
idp_logo_url: "data:image/png;base64,...".into(),
|
idp_logo_url: "data:image/png;base64,...".into(),
|
||||||
|
entity_id: String::new(),
|
||||||
|
allow_idp_initiated: false,
|
||||||
});
|
});
|
||||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||||
// Real HTTPS URL is fine.
|
// Real HTTPS URL is fine.
|
||||||
@@ -296,9 +324,51 @@ mod tests {
|
|||||||
metadata: String::new(),
|
metadata: String::new(),
|
||||||
metadata_url: String::new(),
|
metadata_url: String::new(),
|
||||||
idp_logo_url: "https://idp.example.com/logo.png".into(),
|
idp_logo_url: "https://idp.example.com/logo.png".into(),
|
||||||
|
entity_id: String::new(),
|
||||||
|
allow_idp_initiated: false,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
@@ -312,6 +382,8 @@ mod tests {
|
|||||||
metadata: oversize,
|
metadata: oversize,
|
||||||
metadata_url: String::new(),
|
metadata_url: String::new(),
|
||||||
idp_logo_url: String::new(),
|
idp_logo_url: String::new(),
|
||||||
|
entity_id: String::new(),
|
||||||
|
allow_idp_initiated: false,
|
||||||
});
|
});
|
||||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ parking_lot.workspace = true
|
|||||||
# OpenSSL-free.
|
# OpenSSL-free.
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
lettre.workspace = true
|
lettre.workspace = true
|
||||||
|
# v0.5.1: decode the base64 SAMLResponse at the ACS endpoint.
|
||||||
|
base64.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
|
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
|
||||||
@@ -54,3 +56,8 @@ time = { workspace = true }
|
|||||||
# v0.4.61: integration tests need to generate real PNG bytes for the
|
# v0.4.61: integration tests need to generate real PNG bytes for the
|
||||||
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
|
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
# v0.5.1: the SAML ACS integration tests mint a throwaway IdP keypair
|
||||||
|
# (rcgen) and sign a SAMLResponse with bergshamra so the happy-path,
|
||||||
|
# replay, and IdP-initiated-gating flows exercise real signatures.
|
||||||
|
rcgen = "0.13"
|
||||||
|
bergshamra = { workspace = true }
|
||||||
|
|||||||
+42
-47
@@ -116,14 +116,16 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/login", post(auth_api::api_login))
|
.route("/api/login", post(auth_api::api_login))
|
||||||
.route("/api/logout", post(auth_api::api_logout))
|
.route("/api/logout", post(auth_api::api_logout))
|
||||||
.route("/api/me", get(auth_api::api_me))
|
.route("/api/me", get(auth_api::api_me))
|
||||||
.route(
|
.route("/api/me/credentials", put(auth_api::api_update_credentials))
|
||||||
"/api/me/credentials",
|
// SAML SSO configuration (FleetDM-shaped). Gated behind auth — the
|
||||||
put(auth_api::api_update_credentials),
|
// operator pastes their IdP metadata, Entity ID, and toggles here.
|
||||||
)
|
|
||||||
// v0.4.5: SAML SSO configuration (FleetDM-shaped, storage-only).
|
|
||||||
// The actual sign-in flow lands in a later release; this just
|
|
||||||
// gives operators a place to paste their IdP metadata today.
|
|
||||||
.route("/api/sso", get(api_sso_get).put(api_sso_put))
|
.route("/api/sso", get(api_sso_get).put(api_sso_put))
|
||||||
|
// v0.5.1: SAML SP login flow (pre-auth — see the require_auth
|
||||||
|
// allowlist). /login redirects to the IdP, /acs consumes the signed
|
||||||
|
// response + mints a session, /metadata serves our SP descriptor.
|
||||||
|
.route("/api/sso/login", get(crate::saml_routes::sso_login))
|
||||||
|
.route("/api/sso/acs", post(crate::saml_routes::sso_acs))
|
||||||
|
.route("/api/sso/metadata", get(crate::saml_routes::sso_metadata))
|
||||||
.route("/api/clients", get(api_list_clients))
|
.route("/api/clients", get(api_list_clients))
|
||||||
.route("/api/status", get(api_status))
|
.route("/api/status", get(api_status))
|
||||||
.route("/api/settings", get(api_get_settings).put(api_put_settings))
|
.route("/api/settings", get(api_get_settings).put(api_put_settings))
|
||||||
@@ -138,13 +140,19 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
// modules (Unraid), and no container-side configuration could
|
// modules (Unraid), and no container-side configuration could
|
||||||
// load a host kernel module. `smbclient` speaks SMB over a
|
// load a host kernel module. `smbclient` speaks SMB over a
|
||||||
// plain TCP socket in userspace, works in every container.
|
// plain TCP socket in userspace, works in every container.
|
||||||
.route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add))
|
.route(
|
||||||
|
"/api/smb-shares",
|
||||||
|
get(api_smb_shares_list).post(api_smb_shares_add),
|
||||||
|
)
|
||||||
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
|
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
|
||||||
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
|
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
|
||||||
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
|
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
|
||||||
// Ships alongside SMB. Routes are parallel so the UI can
|
// Ships alongside SMB. Routes are parallel so the UI can
|
||||||
// reuse the same form/error/hint rendering for both.
|
// reuse the same form/error/hint rendering for both.
|
||||||
.route("/api/nfs-shares", get(api_nfs_shares_list).post(api_nfs_shares_add))
|
.route(
|
||||||
|
"/api/nfs-shares",
|
||||||
|
get(api_nfs_shares_list).post(api_nfs_shares_add),
|
||||||
|
)
|
||||||
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
|
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
|
||||||
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
|
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
|
||||||
// Phase 4: Network info (read-only) + DNS edit.
|
// Phase 4: Network info (read-only) + DNS edit.
|
||||||
@@ -204,9 +212,7 @@ async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
|
|||||||
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
|
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
|
||||||
match state.sso.replace(body) {
|
match state.sso.replace(body) {
|
||||||
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
|
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
|
||||||
Err(Error::Invalid(msg)) => {
|
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
|
||||||
(StatusCode::BAD_REQUEST, msg).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,8 +257,7 @@ async fn index(State(state): State<AppState>) -> Response {
|
|||||||
/// with the `?v=<version>` query string in index.html, the practical
|
/// with the `?v=<version>` query string in index.html, the practical
|
||||||
/// upper bound on caching across an upgrade is "until the operator
|
/// upper bound on caching across an upgrade is "until the operator
|
||||||
/// reloads".
|
/// reloads".
|
||||||
const ASSET_CACHE_CONTROL: HeaderValue =
|
const ASSET_CACHE_CONTROL: HeaderValue = HeaderValue::from_static("no-cache, must-revalidate");
|
||||||
HeaderValue::from_static("no-cache, must-revalidate");
|
|
||||||
|
|
||||||
async fn ui_js() -> Response {
|
async fn ui_js() -> Response {
|
||||||
(
|
(
|
||||||
@@ -347,9 +352,7 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
|||||||
// iPXE/our compositor can consume. SVG (or a missing/unreadable
|
// iPXE/our compositor can consume. SVG (or a missing/unreadable
|
||||||
// file) yields `None`, which composes the default background.
|
// file) yields `None`, which composes the default background.
|
||||||
let raster: Option<Vec<u8>> = match (state.branding.logo_path(), state.branding.logo_mime()) {
|
let raster: Option<Vec<u8>> = match (state.branding.logo_path(), state.branding.logo_mime()) {
|
||||||
(Some(path), Some(mime)) if mime != "image/svg+xml" => {
|
(Some(path), Some(mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(),
|
||||||
tokio::fs::read(&path).await.ok()
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -693,9 +696,7 @@ async fn iso_raw(
|
|||||||
};
|
};
|
||||||
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IsoSource::Smb {
|
IsoSource::Smb {
|
||||||
@@ -710,7 +711,10 @@ async fn iso_raw(
|
|||||||
if headers.get(header::RANGE).is_some() {
|
if headers.get(header::RANGE).is_some() {
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
.header(header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes))
|
.header(
|
||||||
|
header::CONTENT_RANGE,
|
||||||
|
format!("bytes */{}", meta.size_bytes),
|
||||||
|
)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -1048,10 +1052,7 @@ async fn api_storage_disk(State(state): State<AppState>) -> Json<serde_json::Val
|
|||||||
|
|
||||||
// ─── Branding (custom logo) ───────────────────────────────────────────────
|
// ─── Branding (custom logo) ───────────────────────────────────────────────
|
||||||
|
|
||||||
async fn api_branding_upload(
|
async fn api_branding_upload(State(state): State<AppState>, mut multipart: Multipart) -> Response {
|
||||||
State(state): State<AppState>,
|
|
||||||
mut multipart: Multipart,
|
|
||||||
) -> Response {
|
|
||||||
while let Ok(Some(field)) = multipart.next_field().await {
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
let name = field.name().unwrap_or("").to_string();
|
let name = field.name().unwrap_or("").to_string();
|
||||||
if name != "file" && name != "logo" {
|
if name != "file" && name != "logo" {
|
||||||
@@ -1072,9 +1073,7 @@ async fn api_branding_upload(
|
|||||||
// hitting disk. Logos are tiny by definition.
|
// hitting disk. Logos are tiny by definition.
|
||||||
let bytes = match field.bytes().await {
|
let bytes = match field.bytes().await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(),
|
||||||
return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if bytes.len() > MAX_LOGO_BYTES {
|
if bytes.len() > MAX_LOGO_BYTES {
|
||||||
return (
|
return (
|
||||||
@@ -1102,9 +1101,7 @@ async fn api_branding_upload(
|
|||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
|
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
|
||||||
@@ -2064,10 +2061,7 @@ async fn api_hosts_remove(
|
|||||||
/// common "same VLAN as OpenPXE" case with zero network config. We only
|
/// common "same VLAN as OpenPXE" case with zero network config. We only
|
||||||
/// wake MACs that are actually bound — keeps this from being an open
|
/// wake MACs that are actually bound — keeps this from being an open
|
||||||
/// "spray packets at any MAC" endpoint.
|
/// "spray packets at any MAC" endpoint.
|
||||||
async fn api_hosts_wol(
|
async fn api_hosts_wol(State(state): State<AppState>, AxumPath(mac): AxumPath<String>) -> Response {
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(mac): AxumPath<String>,
|
|
||||||
) -> Response {
|
|
||||||
if state.hosts.lookup(&mac).is_none() {
|
if state.hosts.lookup(&mac).is_none() {
|
||||||
return (
|
return (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
@@ -2097,8 +2091,7 @@ async fn api_hosts_wol(
|
|||||||
// The send is a blocking std UDP call; push it off the async
|
// The send is a blocking std UDP call; push it off the async
|
||||||
// executor.
|
// executor.
|
||||||
let mac_owned = mac.clone();
|
let mac_owned = mac.clone();
|
||||||
let result =
|
let result = tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
|
||||||
tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(n)) => {
|
Ok(Ok(n)) => {
|
||||||
// Fire-and-forget notification — nice "someone woke a box"
|
// Fire-and-forget notification — nice "someone woke a box"
|
||||||
@@ -2111,7 +2104,11 @@ async fn api_hosts_wol(
|
|||||||
Json(json!({ "ok": true, "broadcasts": n })).into_response()
|
Json(json!({ "ok": true, "broadcasts": n })).into_response()
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("wol task failed: {e}")).into_response(),
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("wol task failed: {e}"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2122,10 +2119,7 @@ async fn api_notify_get(State(state): State<AppState>) -> Json<NotifyConfig> {
|
|||||||
Json(state.notify.snapshot().redacted())
|
Json(state.notify.snapshot().redacted())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn api_notify_put(
|
async fn api_notify_put(State(state): State<AppState>, Json(cfg): Json<NotifyConfig>) -> Response {
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(cfg): Json<NotifyConfig>,
|
|
||||||
) -> Response {
|
|
||||||
match state.notify.replace(cfg) {
|
match state.notify.replace(cfg) {
|
||||||
Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(),
|
Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(),
|
||||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||||
@@ -2191,10 +2185,7 @@ async fn api_updates_check() -> Response {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let update_available = version_is_newer(
|
let update_available = version_is_newer(latest_tag.trim_start_matches('v'), current);
|
||||||
latest_tag.trim_start_matches('v'),
|
|
||||||
current,
|
|
||||||
);
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"current": current,
|
"current": current,
|
||||||
"latest": latest_tag,
|
"latest": latest_tag,
|
||||||
@@ -2241,7 +2232,11 @@ fn gitea_releases_api_url() -> Option<String> {
|
|||||||
fn version_is_newer(latest: &str, current: &str) -> bool {
|
fn version_is_newer(latest: &str, current: &str) -> bool {
|
||||||
fn parts(v: &str) -> Vec<u64> {
|
fn parts(v: &str) -> Vec<u64> {
|
||||||
v.split('.')
|
v.split('.')
|
||||||
.map(|p| p.chars().take_while(char::is_ascii_digit).collect::<String>())
|
.map(|p| {
|
||||||
|
p.chars()
|
||||||
|
.take_while(char::is_ascii_digit)
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
.map(|s| s.parse::<u64>().unwrap_or(0))
|
.map(|s| s.parse::<u64>().unwrap_or(0))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-16
@@ -140,9 +140,16 @@ fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
|
|||||||
// never overflow i64, but clippy's `cast_possible_wrap` lint wants
|
// never overflow i64, but clippy's `cast_possible_wrap` lint wants
|
||||||
// us to be explicit. `cast_signed` is the documented form.
|
// us to be explicit. `cast_signed` is the documented form.
|
||||||
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
|
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
|
||||||
format!(
|
format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}")
|
||||||
"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}"
|
}
|
||||||
)
|
|
||||||
|
/// Build the `Set-Cookie` header value that establishes a fresh operator
|
||||||
|
/// session with the default 24h TTL. Exposed so the SAML ACS handler can
|
||||||
|
/// attach an operator session to its post-login redirect, exactly as the
|
||||||
|
/// Forms-login path does via [`login_response`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn session_cookie(session: &str) -> String {
|
||||||
|
cookie_attrs(session, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
@@ -169,9 +176,18 @@ fn is_public_path(path: &str) -> bool {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Auth surface and iPXE long-poll endpoints (no cookie available).
|
// Auth surface and iPXE long-poll endpoints (no cookie available).
|
||||||
|
// The SAML SP endpoints are pre-auth by nature — the operator hasn't a
|
||||||
|
// session yet when they start (or arrive from) the IdP. `/api/sso`
|
||||||
|
// (the config GET/PUT, no trailing slash) stays gated.
|
||||||
matches!(
|
matches!(
|
||||||
path,
|
path,
|
||||||
"/api/setup" | "/api/login" | "/api/logout" | "/api/me"
|
"/api/setup"
|
||||||
|
| "/api/login"
|
||||||
|
| "/api/logout"
|
||||||
|
| "/api/me"
|
||||||
|
| "/api/sso/login"
|
||||||
|
| "/api/sso/acs"
|
||||||
|
| "/api/sso/metadata"
|
||||||
) || path.starts_with("/api/queue/join")
|
) || path.starts_with("/api/queue/join")
|
||||||
|| path.starts_with("/api/queue/poll/")
|
|| path.starts_with("/api/queue/poll/")
|
||||||
}
|
}
|
||||||
@@ -222,10 +238,7 @@ pub struct SetupBody {
|
|||||||
/// guards against a leaked WebUI being re-bootstrapped by an attacker
|
/// guards against a leaked WebUI being re-bootstrapped by an attacker
|
||||||
/// who's seen the deployment URL. After bootstrap, the new session
|
/// who's seen the deployment URL. After bootstrap, the new session
|
||||||
/// cookie is set so the operator goes straight to the dashboard.
|
/// cookie is set so the operator goes straight to the dashboard.
|
||||||
pub async fn api_setup(
|
pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<SetupBody>,
|
|
||||||
) -> Response {
|
|
||||||
if state.admin.is_configured() {
|
if state.admin.is_configured() {
|
||||||
return (
|
return (
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
@@ -280,10 +293,7 @@ pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody
|
|||||||
login_response(StatusCode::OK, &pub_, &session)
|
login_response(StatusCode::OK, &pub_, &session)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn api_logout(
|
pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
||||||
State(state): State<AppState>,
|
|
||||||
headers: axum::http::HeaderMap,
|
|
||||||
) -> Response {
|
|
||||||
if let Some(t) = parse_cookie(&headers) {
|
if let Some(t) = parse_cookie(&headers) {
|
||||||
state.sessions.revoke(&t);
|
state.sessions.revoke(&t);
|
||||||
}
|
}
|
||||||
@@ -449,8 +459,14 @@ mod tests {
|
|||||||
fn public_path_allowlist() {
|
fn public_path_allowlist() {
|
||||||
// PXE + chrome paths bypass auth.
|
// PXE + chrome paths bypass auth.
|
||||||
for p in [
|
for p in [
|
||||||
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
|
"/",
|
||||||
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
|
"/assets/app.js",
|
||||||
|
"/boot.ipxe",
|
||||||
|
"/boot/fake.ipxe",
|
||||||
|
"/iso/fake.iso",
|
||||||
|
"/ipxe/snponly.efi",
|
||||||
|
"/healthz",
|
||||||
|
"/readyz",
|
||||||
"/metrics",
|
"/metrics",
|
||||||
// v0.4.6: iPXE fetches this for `console --picture` before
|
// v0.4.6: iPXE fetches this for `console --picture` before
|
||||||
// it can possibly have a session cookie.
|
// it can possibly have a session cookie.
|
||||||
@@ -462,6 +478,10 @@ mod tests {
|
|||||||
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
|
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
|
||||||
assert!(is_public_path(p), "expected {p} to be public");
|
assert!(is_public_path(p), "expected {p} to be public");
|
||||||
}
|
}
|
||||||
|
// v0.5.1: SAML SP endpoints are pre-auth (no session yet).
|
||||||
|
for p in ["/api/sso/login", "/api/sso/acs", "/api/sso/metadata"] {
|
||||||
|
assert!(is_public_path(p), "expected {p} to be public");
|
||||||
|
}
|
||||||
// iPXE long-poll endpoints are public (no cookie available).
|
// iPXE long-poll endpoints are public (no cookie available).
|
||||||
assert!(is_public_path("/api/queue/join"));
|
assert!(is_public_path("/api/queue/join"));
|
||||||
assert!(is_public_path("/api/queue/poll/abc"));
|
assert!(is_public_path("/api/queue/poll/abc"));
|
||||||
@@ -483,8 +503,7 @@ mod tests {
|
|||||||
let mut h = axum::http::HeaderMap::new();
|
let mut h = axum::http::HeaderMap::new();
|
||||||
h.insert(
|
h.insert(
|
||||||
header::COOKIE,
|
header::COOKIE,
|
||||||
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux"))
|
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(),
|
||||||
.unwrap(),
|
|
||||||
);
|
);
|
||||||
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
|
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
|
||||||
// Different name → None.
|
// Different name → None.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub mod ipxe_script;
|
|||||||
pub mod iso_fs;
|
pub mod iso_fs;
|
||||||
pub mod log_stream;
|
pub mod log_stream;
|
||||||
pub mod notify;
|
pub mod notify;
|
||||||
|
pub mod saml_routes;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod uploads;
|
pub mod uploads;
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
//! SAML 2.0 Service Provider HTTP endpoints (v0.5.1).
|
||||||
|
//!
|
||||||
|
//! * `GET /api/sso/login` — SP-initiated: build an AuthnRequest, record its
|
||||||
|
//! ID, and 302 the browser to the IdP.
|
||||||
|
//! * `POST /api/sso/acs` — Assertion Consumer Service: verify + validate
|
||||||
|
//! the IdP's `SAMLResponse`, perform the stateful checks (InResponseTo
|
||||||
|
//! correlation, IdP-initiated gating, assertion replay), mint an operator
|
||||||
|
//! session, and 302 to the dashboard. (Mirrors FleetDM's `/sso/callback`.)
|
||||||
|
//! * `GET /api/sso/metadata` — serve our SP metadata XML for IdP import.
|
||||||
|
//!
|
||||||
|
//! Stateless crypto + semantic validation live in `openpxe_core::saml`; this
|
||||||
|
//! module owns only the HTTP glue and the in-memory state the SP needs.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration as StdDuration, Instant};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{Form, Query, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
|
use openpxe_core::saml::{self, metadata::IdpMetadata, SamlError, SpParams};
|
||||||
|
use openpxe_core::SsoConfig;
|
||||||
|
|
||||||
|
use crate::auth;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Outstanding AuthnRequest IDs live at most this long before a matching
|
||||||
|
/// response is considered stale (covers a slow human at the IdP login form).
|
||||||
|
const REQUEST_TTL: StdDuration = StdDuration::from_mins(10);
|
||||||
|
/// How long we fetch-cache IdP metadata loaded from a URL.
|
||||||
|
const METADATA_FETCH_TIMEOUT: StdDuration = StdDuration::from_secs(10);
|
||||||
|
|
||||||
|
/// In-memory SAML runtime state. Cheap to clone (Arc-shared).
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct SamlRuntime {
|
||||||
|
/// request_id → issued_at. Correlates a response's `InResponseTo` to a
|
||||||
|
/// request *we* actually sent (replay / CSRF defense for SP-initiated).
|
||||||
|
outstanding: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
|
/// assertion_id → expiry. A consumed assertion may not be replayed.
|
||||||
|
consumed: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
|
/// Cache of IdP metadata fetched from a URL: (url, parsed).
|
||||||
|
metadata_cache: Arc<Mutex<Option<(String, IdpMetadata)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SamlRuntime {
|
||||||
|
/// Record an AuthnRequest we just sent.
|
||||||
|
pub fn register_request(&self, id: &str) {
|
||||||
|
let mut g = self.outstanding.lock();
|
||||||
|
prune(&mut g);
|
||||||
|
g.insert(id.to_owned(), Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume an outstanding request ID, returning `true` if it was present
|
||||||
|
/// and still fresh. A miss means the response doesn't correlate to any
|
||||||
|
/// live request we issued.
|
||||||
|
pub fn take_request(&self, id: &str) -> bool {
|
||||||
|
let mut g = self.outstanding.lock();
|
||||||
|
prune(&mut g);
|
||||||
|
g.remove(id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a consumed assertion. Returns `false` if it was already
|
||||||
|
/// consumed (a replay) — in which case the caller must reject.
|
||||||
|
pub fn record_assertion(&self, id: &str, expiry: OffsetDateTime) -> bool {
|
||||||
|
let mut g = self.consumed.lock();
|
||||||
|
prune(&mut g);
|
||||||
|
if g.contains_key(id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let ttl = (expiry - OffsetDateTime::now_utc())
|
||||||
|
.max(Duration::ZERO)
|
||||||
|
.unsigned_abs();
|
||||||
|
g.insert(id.to_owned(), Instant::now() + ttl);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_metadata(&self, url: &str) -> Option<IdpMetadata> {
|
||||||
|
let g = self.metadata_cache.lock();
|
||||||
|
match &*g {
|
||||||
|
Some((cached_url, md)) if cached_url == url => Some(md.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_metadata(&self, url: String, md: IdpMetadata) {
|
||||||
|
*self.metadata_cache.lock() = Some((url, md));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop expired entries so neither map grows unbounded.
|
||||||
|
fn prune(map: &mut HashMap<String, Instant>) {
|
||||||
|
let now = Instant::now();
|
||||||
|
// For the request map this over-prunes (entries store issued_at, not
|
||||||
|
// expiry), so cap by REQUEST_TTL; the consumed map stores absolute
|
||||||
|
// expiry instants. Using saturating logic keeps both correct: request
|
||||||
|
// entries older than REQUEST_TTL go, consumed entries past expiry go.
|
||||||
|
map.retain(|_, &mut t| now.saturating_duration_since(t) < REQUEST_TTL || t > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/sso/login ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginQuery {
|
||||||
|
/// Optional local path to return to after login (becomes RelayState).
|
||||||
|
#[serde(default)]
|
||||||
|
pub next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sso_login(State(state): State<AppState>, Query(q): Query<LoginQuery>) -> Response {
|
||||||
|
let cfg = state.sso.snapshot();
|
||||||
|
if !cfg.is_usable() {
|
||||||
|
return redirect("/?sso_error=unavailable");
|
||||||
|
}
|
||||||
|
let idp = match resolve_idp_metadata(&state, &cfg).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(target: "openpxe::saml", "sso_login: metadata unavailable: {e}");
|
||||||
|
return redirect("/?sso_error=metadata");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(dest) = idp.sso_destination().map(str::to_owned) else {
|
||||||
|
tracing::warn!(target: "openpxe::saml", "sso_login: IdP metadata has no SSO endpoint");
|
||||||
|
return redirect("/?sso_error=metadata");
|
||||||
|
};
|
||||||
|
let sp = sp_params(&state, &cfg);
|
||||||
|
let relay = safe_local_path(q.next.as_deref());
|
||||||
|
match saml::authn_request::build(&sp, &dest, Some(&relay)) {
|
||||||
|
Ok(req) => {
|
||||||
|
state.saml.register_request(&req.id);
|
||||||
|
redirect(&req.location)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(target: "openpxe::saml", "sso_login: build AuthnRequest failed: {e}");
|
||||||
|
redirect("/?sso_error=request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/sso/acs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AcsForm {
|
||||||
|
#[serde(rename = "SAMLResponse")]
|
||||||
|
pub saml_response: String,
|
||||||
|
#[serde(rename = "RelayState", default)]
|
||||||
|
pub relay_state: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sso_acs(State(state): State<AppState>, Form(form): Form<AcsForm>) -> Response {
|
||||||
|
let cfg = state.sso.snapshot();
|
||||||
|
if !cfg.is_usable() {
|
||||||
|
return redirect("/?sso_error=unavailable");
|
||||||
|
}
|
||||||
|
let xml = match base64::engine::general_purpose::STANDARD.decode(form.saml_response.as_bytes())
|
||||||
|
{
|
||||||
|
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(target: "openpxe::saml", "acs: base64 decode failed: {e}");
|
||||||
|
return redirect("/?sso_error=1");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let idp = match resolve_idp_metadata(&state, &cfg).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(target: "openpxe::saml", "acs: metadata unavailable: {e}");
|
||||||
|
return redirect("/?sso_error=metadata");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let sp = sp_params(&state, &cfg);
|
||||||
|
|
||||||
|
// Signature verification + semantic checks are CPU-bound — keep them off
|
||||||
|
// the async executor.
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let skew = Duration::seconds(saml::DEFAULT_CLOCK_SKEW_SECS);
|
||||||
|
let verify = {
|
||||||
|
let xml = xml.clone();
|
||||||
|
let sp = sp.clone();
|
||||||
|
tokio::task::spawn_blocking(move || saml::response::consume(&xml, &sp, &idp, now, skew))
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
let verified = match verify {
|
||||||
|
Ok(Ok(v)) => v,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
// Never leak which specific check failed to the browser.
|
||||||
|
tracing::warn!(target: "openpxe::saml", "acs: response rejected: {e}");
|
||||||
|
return redirect("/?sso_error=1");
|
||||||
|
}
|
||||||
|
Err(join) => {
|
||||||
|
tracing::error!(target: "openpxe::saml", "acs: verify task panicked: {join}");
|
||||||
|
return redirect("/?sso_error=1");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stateful checks the core deliberately left to us.
|
||||||
|
match &verified.in_response_to {
|
||||||
|
Some(id) => {
|
||||||
|
if !state.saml.take_request(id) {
|
||||||
|
tracing::warn!(target: "openpxe::saml", "acs: InResponseTo matches no live request");
|
||||||
|
return redirect("/?sso_error=1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if !cfg.allow_idp_initiated {
|
||||||
|
tracing::warn!(target: "openpxe::saml", "acs: IdP-initiated login is disabled");
|
||||||
|
return redirect("/?sso_error=idp_initiated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !state
|
||||||
|
.saml
|
||||||
|
.record_assertion(&verified.assertion_id, verified.assertion_expiry)
|
||||||
|
{
|
||||||
|
tracing::warn!(target: "openpxe::saml", "acs: assertion replay rejected");
|
||||||
|
return redirect("/?sso_error=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success → mint an operator session keyed to the verified email.
|
||||||
|
let session = state.sessions.create(&verified.principal.email);
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::saml",
|
||||||
|
email = %verified.principal.email,
|
||||||
|
idp_initiated = verified.in_response_to.is_none(),
|
||||||
|
"SAML SSO sign-in"
|
||||||
|
);
|
||||||
|
// safe_local_path already maps None / unsafe values to "/".
|
||||||
|
let relay = safe_local_path(form.relay_state.as_deref());
|
||||||
|
redirect_with_session(&relay, &session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/sso/metadata ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn sso_metadata(State(state): State<AppState>) -> Response {
|
||||||
|
let cfg = state.sso.snapshot();
|
||||||
|
let sp = sp_params(&state, &cfg);
|
||||||
|
let xml = saml::metadata::build_sp_metadata(&sp);
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, "application/samlmetadata+xml")],
|
||||||
|
xml,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Derive runtime SP parameters from config + the advertised public base URL.
|
||||||
|
fn sp_params(state: &AppState, cfg: &SsoConfig) -> SpParams {
|
||||||
|
let base = state.public_base_url.trim_end_matches('/');
|
||||||
|
let entity_id = if cfg.entity_id.trim().is_empty() {
|
||||||
|
base.to_owned()
|
||||||
|
} else {
|
||||||
|
cfg.entity_id.trim().to_owned()
|
||||||
|
};
|
||||||
|
SpParams {
|
||||||
|
entity_id,
|
||||||
|
acs_url: format!("{base}/api/sso/acs"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the IdP metadata: prefer the metadata URL (fetched + cached) per
|
||||||
|
/// the "URL wins" rule, else parse the pasted XML.
|
||||||
|
async fn resolve_idp_metadata(state: &AppState, cfg: &SsoConfig) -> Result<IdpMetadata, SamlError> {
|
||||||
|
let url = cfg.metadata_url.trim();
|
||||||
|
if !url.is_empty() {
|
||||||
|
if let Some(md) = state.saml.cached_metadata(url) {
|
||||||
|
return Ok(md);
|
||||||
|
}
|
||||||
|
let body = fetch_metadata(url).await?;
|
||||||
|
let md = IdpMetadata::parse(&body)?;
|
||||||
|
state.saml.cache_metadata(url.to_owned(), md.clone());
|
||||||
|
return Ok(md);
|
||||||
|
}
|
||||||
|
if !cfg.metadata.trim().is_empty() {
|
||||||
|
return IdpMetadata::parse(&cfg.metadata);
|
||||||
|
}
|
||||||
|
Err(SamlError::Metadata("no metadata source configured".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_metadata(url: &str) -> Result<String, SamlError> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(METADATA_FETCH_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| SamlError::Metadata(format!("http client: {e}")))?;
|
||||||
|
let resp = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SamlError::Metadata(format!("fetch {url}: {e}")))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SamlError::Metadata(format!(
|
||||||
|
"fetch {url}: HTTP {}",
|
||||||
|
resp.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
resp.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SamlError::Metadata(format!("read {url}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only permit a same-site path (single leading slash) as a redirect target —
|
||||||
|
/// blocks open-redirect / protocol-relative (`//evil.com`) abuse of RelayState.
|
||||||
|
fn safe_local_path(p: Option<&str>) -> String {
|
||||||
|
match p {
|
||||||
|
Some(p) if p.starts_with('/') && !p.starts_with("//") => p.to_owned(),
|
||||||
|
_ => "/".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redirect(location: &str) -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::FOUND)
|
||||||
|
.header(header::LOCATION, location)
|
||||||
|
.body(Body::empty())
|
||||||
|
.map_or_else(
|
||||||
|
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
IntoResponse::into_response,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redirect_with_session(location: &str, session: &str) -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::FOUND)
|
||||||
|
.header(header::LOCATION, location)
|
||||||
|
.header(header::SET_COOKIE, auth::session_cookie(session))
|
||||||
|
.body(Body::empty())
|
||||||
|
.map_or_else(
|
||||||
|
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
IntoResponse::into_response,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::uploads::UploadSessions;
|
|
||||||
use crate::auth::SessionStore;
|
use crate::auth::SessionStore;
|
||||||
|
use crate::saml_routes::SamlRuntime;
|
||||||
|
use crate::uploads::UploadSessions;
|
||||||
use openpxe_core::{
|
use openpxe_core::{
|
||||||
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
||||||
Metrics, NotifyStore, SettingsStore, SsoStore,
|
Metrics, NotifyStore, SettingsStore, SsoStore,
|
||||||
@@ -34,9 +35,13 @@ pub struct AppState {
|
|||||||
/// process restart (sessions are tied to UI state, not persisted —
|
/// process restart (sessions are tied to UI state, not persisted —
|
||||||
/// matches Sonarr/Radarr behaviour).
|
/// matches Sonarr/Radarr behaviour).
|
||||||
pub sessions: SessionStore,
|
pub sessions: SessionStore,
|
||||||
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
|
/// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles).
|
||||||
/// flow ships in a later release.
|
|
||||||
pub sso: SsoStore,
|
pub sso: SsoStore,
|
||||||
|
/// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs
|
||||||
|
/// (for InResponseTo correlation), consumed-assertion replay guard, and
|
||||||
|
/// a cache of fetched IdP metadata. Tied to process lifetime, like
|
||||||
|
/// `sessions`; a restart simply invalidates any in-flight SSO login.
|
||||||
|
pub saml: SamlRuntime,
|
||||||
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
|
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
|
||||||
/// SMTP). Drives the fire-and-forget pings on boot events and powers
|
/// SMTP). Drives the fire-and-forget pings on boot events and powers
|
||||||
/// the Advanced tab's config + "Send test" button.
|
/// the Advanced tab's config + "Send test" button.
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
admin,
|
admin,
|
||||||
sessions,
|
sessions,
|
||||||
sso,
|
sso,
|
||||||
|
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
|
||||||
notify,
|
notify,
|
||||||
metrics,
|
metrics,
|
||||||
smb: None,
|
smb: None,
|
||||||
@@ -559,7 +560,10 @@ async fn notify_config_round_trips_and_redacts_smtp_password() {
|
|||||||
assert_eq!(v["kind"], "smtp");
|
assert_eq!(v["kind"], "smtp");
|
||||||
let pw = v["smtp_password"].as_str().unwrap_or("");
|
let pw = v["smtp_password"].as_str().unwrap_or("");
|
||||||
assert_ne!(pw, "s3cret", "raw password must never be returned");
|
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]
|
#[tokio::test]
|
||||||
@@ -1551,7 +1555,11 @@ async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode,
|
|||||||
|
|
||||||
// ─── v0.4.5: Forms auth + SSO ─────────────────────────────────────────────
|
// ─── 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
|
let res = router
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.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)
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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]]);
|
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
|
||||||
assert_eq!(width, 1024);
|
assert_eq!(width, 1024);
|
||||||
}
|
}
|
||||||
@@ -1974,10 +1985,7 @@ async fn pxe_logo_composes_to_1024x768_png() {
|
|||||||
// the iPXE menu always paints at consistent dimensions.
|
// the iPXE menu always paints at consistent dimensions.
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
let png = tiny_png();
|
let png = tiny_png();
|
||||||
state
|
state.branding.set_logo("image/png", "png", &png).unwrap();
|
||||||
.branding
|
|
||||||
.set_logo("image/png", "png", &png)
|
|
||||||
.unwrap();
|
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
let res = app
|
let res = app
|
||||||
.clone()
|
.clone()
|
||||||
@@ -2017,10 +2025,7 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
|
|||||||
// auth allowlist gates `/api/*` only.
|
// auth allowlist gates `/api/*` only.
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
let png = tiny_png();
|
let png = tiny_png();
|
||||||
state
|
state.branding.set_logo("image/png", "png", &png).unwrap();
|
||||||
.branding
|
|
||||||
.set_logo("image/png", "png", &png)
|
|
||||||
.unwrap();
|
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
// Configure an admin so the middleware kicks in.
|
// Configure an admin so the middleware kicks in.
|
||||||
let (s, _, _) = post_collect(
|
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;
|
let (s, _) = get(&app, "/branding/pxe-logo").await;
|
||||||
assert_eq!(s, StatusCode::OK);
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
admin: admin.clone(),
|
admin: admin.clone(),
|
||||||
sessions: sessions.clone(),
|
sessions: sessions.clone(),
|
||||||
sso: sso.clone(),
|
sso: sso.clone(),
|
||||||
|
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
|
||||||
notify: notify.clone(),
|
notify: notify.clone(),
|
||||||
metrics: metrics.clone(),
|
metrics: metrics.clone(),
|
||||||
smb: Some(smb.clone()),
|
smb: Some(smb.clone()),
|
||||||
|
|||||||
@@ -797,3 +797,46 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
|||||||
.logo-preview .info { flex: 1; min-width: 0; }
|
.logo-preview .info { flex: 1; min-width: 0; }
|
||||||
.logo-preview .info .name { color: var(--fg); font-weight: 600; }
|
.logo-preview .info .name { color: var(--fg); font-weight: 600; }
|
||||||
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
|
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* v0.5.1: collapsible "Advanced" disclosure at the bottom of Settings
|
||||||
|
(the former Advanced sidebar tab). A quiet, full-width toggle that
|
||||||
|
expands to reveal the notification + API-reference cards. */
|
||||||
|
.advanced-disclosure { width: 100%; }
|
||||||
|
.advanced-summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.advanced-summary:hover { color: var(--fg); }
|
||||||
|
.advanced-summary::-webkit-details-marker { display: none; }
|
||||||
|
.advanced-summary::before {
|
||||||
|
content: "▸";
|
||||||
|
font-size: 11px;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.advanced-disclosure[open] .advanced-summary::before { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
/* v0.5.1: protocol tag on a unified Remote-shares row (SMB / NFS). */
|
||||||
|
.proto-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 1px 6px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: var(--bg-panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|||||||
+174
-178
@@ -684,57 +684,17 @@
|
|||||||
])
|
])
|
||||||
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
|
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
|
||||||
|
|
||||||
// ── SMB shares section (v0.4.65) ──
|
// ── Remote shares section (v0.5.1) ──
|
||||||
// Replaces the kernel-mount NFS card. SMB shares are consumed
|
// SMB + NFS unified into one "Remote shares" card with a protocol
|
||||||
// in userspace via Samba's `smbclient` CLI — no kernel modules,
|
// dropdown. The two protocols keep their own backend endpoints
|
||||||
// no CAP_SYS_ADMIN, works in any container. This is the same
|
// (/api/smb-shares, /api/nfs-shares) and the same add/scan/remove
|
||||||
// approach Bootimus uses.
|
// UX; the form just swaps the relevant fields. This declutters the
|
||||||
const smbMsg = el('div', {class:'msg'});
|
// Storage tab and leaves room for a future "Config files" card.
|
||||||
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
|
const shareMsg = el('div', {class:'msg'});
|
||||||
const smbShare = el('input', {type:'text', placeholder:'isos'});
|
|
||||||
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
|
|
||||||
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
|
|
||||||
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
|
|
||||||
// Toggle username/password fields based on the Guest checkbox so
|
|
||||||
// operators don't get confused about which fields matter.
|
|
||||||
const syncAuthDisabled = () => {
|
|
||||||
smbUser.disabled = smbGuest.checked;
|
|
||||||
smbPass.disabled = smbGuest.checked;
|
|
||||||
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
|
|
||||||
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
|
|
||||||
};
|
|
||||||
smbGuest.addEventListener('change', syncAuthDisabled);
|
|
||||||
syncAuthDisabled();
|
|
||||||
|
|
||||||
const addSmb = el('button', {onclick: async () => {
|
// Shared structured-error renderer ({error, stderr, hint}) for both
|
||||||
if (!smbServer.value || !smbShare.value) {
|
// protocols' add calls.
|
||||||
smbMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
|
const showShareError = async (r) => {
|
||||||
smbMsg.className='msg err'; return;
|
|
||||||
}
|
|
||||||
if (!smbGuest.checked && !smbUser.value) {
|
|
||||||
smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
|
|
||||||
smbMsg.className='msg err'; return;
|
|
||||||
}
|
|
||||||
smbMsg.replaceChildren(document.createTextNode('Connecting…'));
|
|
||||||
smbMsg.className = 'msg';
|
|
||||||
const body = {
|
|
||||||
server: smbServer.value,
|
|
||||||
share: smbShare.value,
|
|
||||||
guest: smbGuest.checked,
|
|
||||||
};
|
|
||||||
if (!smbGuest.checked) {
|
|
||||||
body.username = smbUser.value;
|
|
||||||
body.password = smbPass.value;
|
|
||||||
}
|
|
||||||
const r = await postJSON('/api/smb-shares', body);
|
|
||||||
if (r.ok) {
|
|
||||||
smbMsg.replaceChildren(document.createTextNode('Connected.'));
|
|
||||||
smbMsg.className = 'msg ok';
|
|
||||||
render('storage');
|
|
||||||
} else {
|
|
||||||
// The API returns a structured {error, stderr, hint} JSON
|
|
||||||
// body on failure so the raw smbclient error and the
|
|
||||||
// actionable hint render as two distinct lines.
|
|
||||||
let bodyJson = null;
|
let bodyJson = null;
|
||||||
let raw = null;
|
let raw = null;
|
||||||
try { bodyJson = await r.clone().json(); }
|
try { bodyJson = await r.clone().json(); }
|
||||||
@@ -748,15 +708,47 @@
|
|||||||
if (hint) {
|
if (hint) {
|
||||||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
||||||
}
|
}
|
||||||
smbMsg.replaceChildren(...parts);
|
shareMsg.replaceChildren(...parts);
|
||||||
smbMsg.className = 'msg err';
|
shareMsg.className = 'msg err';
|
||||||
}
|
};
|
||||||
}}, 'Add share');
|
|
||||||
|
|
||||||
const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
// Protocol picker — swaps which field block is visible.
|
||||||
|
const protoSelect = el('select', {}, [
|
||||||
|
el('option', {value:'smb'}, 'SMB / CIFS'),
|
||||||
|
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// SMB inputs.
|
||||||
|
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
|
||||||
|
const smbShare = el('input', {type:'text', placeholder:'isos'});
|
||||||
|
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
|
||||||
|
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
|
||||||
|
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
|
||||||
|
const syncAuthDisabled = () => {
|
||||||
|
smbUser.disabled = smbGuest.checked;
|
||||||
|
smbPass.disabled = smbGuest.checked;
|
||||||
|
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
|
||||||
|
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
|
||||||
|
};
|
||||||
|
smbGuest.addEventListener('change', syncAuthDisabled);
|
||||||
|
syncAuthDisabled();
|
||||||
|
const smbFields = el('div', {}, [
|
||||||
|
el('div', {class:'form-row cols-2'}, [
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMB server'), smbServer]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Share name'), smbShare]),
|
||||||
|
]),
|
||||||
|
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
|
||||||
|
el('label', {class:'check'}, [smbGuest, el('span', {}, 'Guest (anonymous read)')]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), smbUser]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), smbPass]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const smbRowEls = shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||||||
el('div', {}, [
|
el('div', {}, [
|
||||||
el('div', {class:'id'}, '//' + m.server + '/' + m.share),
|
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SMB'),
|
||||||
|
document.createTextNode('//' + m.server + '/' + m.share)]),
|
||||||
el('div', {class:'meta'},
|
el('div', {class:'meta'},
|
||||||
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
|
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
|
||||||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||||
@@ -773,59 +765,75 @@
|
|||||||
render('storage');
|
render('storage');
|
||||||
}}, 'Remove'),
|
}}, 'Remove'),
|
||||||
el('span'),
|
el('span'),
|
||||||
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
|
]));
|
||||||
|
|
||||||
// ── NFS shares section (v0.4.67) ──
|
// NFS inputs. The NFSv3 client is in-process (nfs3_client crate) so
|
||||||
// Parallel to SMB shares above. The NFSv3 client is in-process
|
// NFS-sourced ISOs support HTTP Range — SMB-sourced ones can't seek
|
||||||
// (nfs3_client crate) so NFS-sourced ISOs support HTTP Range
|
// mid-stream. No auth fields: NFSv3 access is gated by client IP on
|
||||||
// requests — SMB-sourced ones don't (smbclient CLI can't seek
|
// the server's export list, not client-supplied credentials.
|
||||||
// mid-stream). Otherwise the UX is identical: server + export,
|
|
||||||
// submit, scan, remove.
|
|
||||||
const nfsMsg = el('div', {class:'msg'});
|
|
||||||
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||||||
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
||||||
const addNfs = el('button', {style:'margin-top:14px', onclick: async () => {
|
const nfsFields = el('div', {}, [
|
||||||
if (!nfsServerIn.value || !nfsExportIn.value) {
|
el('div', {class:'form-row cols-2'}, [
|
||||||
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'NFS server'), nfsServerIn]),
|
||||||
nfsMsg.className = 'msg err'; return;
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), nfsExportIn]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Swap the visible field block + clear any stale message.
|
||||||
|
const syncProto = () => {
|
||||||
|
const nfs = protoSelect.value === 'nfs';
|
||||||
|
smbFields.style.display = nfs ? 'none' : '';
|
||||||
|
nfsFields.style.display = nfs ? '' : 'none';
|
||||||
|
shareMsg.replaceChildren();
|
||||||
|
shareMsg.className = 'msg';
|
||||||
|
};
|
||||||
|
protoSelect.addEventListener('change', syncProto);
|
||||||
|
|
||||||
|
// One add button; dispatches to the selected protocol's endpoint.
|
||||||
|
const addShare = el('button', {style:'margin-top:14px', onclick: async () => {
|
||||||
|
if (protoSelect.value === 'smb') {
|
||||||
|
if (!smbServer.value || !smbShare.value) {
|
||||||
|
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
|
||||||
|
shareMsg.className = 'msg err'; return;
|
||||||
}
|
}
|
||||||
nfsMsg.replaceChildren(document.createTextNode('Connecting…'));
|
if (!smbGuest.checked && !smbUser.value) {
|
||||||
nfsMsg.className = 'msg';
|
shareMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
|
||||||
const r = await postJSON('/api/nfs-shares', {
|
shareMsg.className = 'msg err'; return;
|
||||||
server: nfsServerIn.value,
|
}
|
||||||
export: nfsExportIn.value,
|
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||||
});
|
shareMsg.className = 'msg';
|
||||||
|
const body = { server: smbServer.value, share: smbShare.value, guest: smbGuest.checked };
|
||||||
|
if (!smbGuest.checked) { body.username = smbUser.value; body.password = smbPass.value; }
|
||||||
|
const r = await postJSON('/api/smb-shares', body);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
nfsMsg.replaceChildren(document.createTextNode('Connected.'));
|
shareMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||||
nfsMsg.className = 'msg ok';
|
shareMsg.className = 'msg ok';
|
||||||
render('storage');
|
render('storage');
|
||||||
|
} else { await showShareError(r); }
|
||||||
} else {
|
} else {
|
||||||
// Structured {error, stderr, hint} same as SMB.
|
if (!nfsServerIn.value || !nfsExportIn.value) {
|
||||||
let bodyJson = null;
|
shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||||
let raw = null;
|
shareMsg.className = 'msg err'; return;
|
||||||
try { bodyJson = await r.clone().json(); }
|
|
||||||
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
|
|
||||||
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
|
|
||||||
const hint = bodyJson && bodyJson.hint;
|
|
||||||
const parts = [el('div', {}, [
|
|
||||||
el('strong', {}, 'Connect failed: '),
|
|
||||||
document.createTextNode(msg),
|
|
||||||
])];
|
|
||||||
if (hint) {
|
|
||||||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
|
||||||
}
|
}
|
||||||
nfsMsg.replaceChildren(...parts);
|
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||||
nfsMsg.className = 'msg err';
|
shareMsg.className = 'msg';
|
||||||
|
const r = await postJSON('/api/nfs-shares', { server: nfsServerIn.value, export: nfsExportIn.value });
|
||||||
|
if (r.ok) {
|
||||||
|
shareMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||||
|
shareMsg.className = 'msg ok';
|
||||||
|
render('storage');
|
||||||
|
} else { await showShareError(r); }
|
||||||
}
|
}
|
||||||
}}, 'Add share');
|
}}, 'Add share');
|
||||||
|
|
||||||
const nfsRows = nfsShares.length ? nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
const nfsRowEls = nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||||||
el('div', {}, [
|
el('div', {}, [
|
||||||
el('div', {class:'id'}, m.server + ':' + m.export),
|
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'NFS'),
|
||||||
|
document.createTextNode(m.server + ':' + m.export)]),
|
||||||
el('div', {class:'meta'},
|
el('div', {class:'meta'},
|
||||||
'NFSv3 · ' +
|
'NFSv3 · ' + (m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
|
||||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||||||
]),
|
]),
|
||||||
@@ -839,7 +847,13 @@
|
|||||||
render('storage');
|
render('storage');
|
||||||
}}, 'Remove'),
|
}}, 'Remove'),
|
||||||
el('span'),
|
el('span'),
|
||||||
])) : [el('div', {class:'empty'}, 'No NFS shares configured.')];
|
]));
|
||||||
|
|
||||||
|
const totalShares = shares.length + nfsShares.length;
|
||||||
|
const remoteRows = totalShares
|
||||||
|
? [...smbRowEls, ...nfsRowEls]
|
||||||
|
: [el('div', {class:'empty'}, 'No remote shares configured.')];
|
||||||
|
syncProto();
|
||||||
|
|
||||||
const diskCard = diskSpaceCard(disk);
|
const diskCard = diskSpaceCard(disk);
|
||||||
|
|
||||||
@@ -849,77 +863,36 @@
|
|||||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||||
el('div', {class:'body'}, [drop, file, prog, upMsg]),
|
el('div', {class:'body'}, [drop, file, prog, upMsg]),
|
||||||
]),
|
]),
|
||||||
|
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||||||
|
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||||||
|
// pure UI consolidation that declutters the Storage tab.
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
el('h2', {}, 'SMB shares'),
|
el('h2', {}, 'Remote shares'),
|
||||||
el('span', {class:'sub'}, shares.length + ' configured'),
|
el('span', {class:'sub'}, totalShares + ' configured'),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
el('div', {class:'form-row cols-2'}, [
|
el('div', {class:'form-row cols-2'}, [
|
||||||
el('label', {class:'field'}, [
|
el('label', {class:'field'}, [
|
||||||
el('span', {class:'name'}, 'SMB server'),
|
el('span', {class:'name'}, 'Protocol'),
|
||||||
smbServer,
|
protoSelect,
|
||||||
]),
|
]),
|
||||||
el('label', {class:'field'}, [
|
el('span'),
|
||||||
el('span', {class:'name'}, 'Share name'),
|
|
||||||
smbShare,
|
|
||||||
]),
|
]),
|
||||||
]),
|
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]),
|
||||||
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
|
addShare, shareMsg,
|
||||||
el('label', {class:'check'}, [
|
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
|
||||||
smbGuest, el('span', {}, 'Guest (anonymous read)'),
|
|
||||||
]),
|
|
||||||
el('label', {class:'field'}, [
|
|
||||||
el('span', {class:'name'}, 'Username'),
|
|
||||||
smbUser,
|
|
||||||
]),
|
|
||||||
el('label', {class:'field'}, [
|
|
||||||
el('span', {class:'name'}, 'Password'),
|
|
||||||
smbPass,
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
addSmb, smbMsg,
|
|
||||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows),
|
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'SMB shares are read in userspace via Samba’s smbclient — ' +
|
'Remote ISO libraries are read on demand — no local cache, no ' +
|
||||||
'no kernel modules, no CAP_SYS_ADMIN, works in any container ' +
|
'double disk usage. SMB/CIFS is read in userspace via Samba’s ' +
|
||||||
'(Unraid, OpenShift restricted SCC, plain Docker, etc.). Most NAS ' +
|
'smbclient; NFSv3 via a pure-Rust in-process client. Both work in ' +
|
||||||
'appliances expose ISO libraries as guest-readable; check the box ' +
|
'any container (Unraid, OpenShift restricted SCC, plain Docker) with ' +
|
||||||
'above when that’s the case. ISOs are streamed on demand at PXE ' +
|
'no kernel modules and no CAP_SYS_ADMIN. SMB supports guest or ' +
|
||||||
'boot time — no local cache, no double disk usage.'),
|
'user/password; most NAS appliances expose ISO libraries as ' +
|
||||||
]),
|
'guest-readable. NFSv3 auth is AUTH_SYS only — gate access by ' +
|
||||||
]),
|
'allowing this OpenPXE host’s IP in the server’s export list. ' +
|
||||||
// v0.4.67: NFS shares card sits right below SMB so operators
|
'NFS-sourced ISOs also support HTTP Range (seek into a 5 GB ISO ' +
|
||||||
// can see both protocols at a glance. The form is simpler
|
'without reading what precedes the offset); SMB streams sequentially.'),
|
||||||
// (no auth) because NFSv3 access control is by client IP on
|
|
||||||
// the server side, not by client-supplied credentials.
|
|
||||||
el('div', {class:'card'}, [
|
|
||||||
el('header', {}, [
|
|
||||||
el('h2', {}, 'NFS shares'),
|
|
||||||
el('span', {class:'sub'}, nfsShares.length + ' configured'),
|
|
||||||
]),
|
|
||||||
el('div', {class:'body'}, [
|
|
||||||
el('div', {class:'form-row cols-2'}, [
|
|
||||||
el('label', {class:'field'}, [
|
|
||||||
el('span', {class:'name'}, 'NFS server'),
|
|
||||||
nfsServerIn,
|
|
||||||
]),
|
|
||||||
el('label', {class:'field'}, [
|
|
||||||
el('span', {class:'name'}, 'Export path'),
|
|
||||||
nfsExportIn,
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
addNfs, nfsMsg,
|
|
||||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
|
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
|
||||||
'NFSv3 shares are read in-process via a pure-Rust client — ' +
|
|
||||||
'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' +
|
|
||||||
'every container the SMB path works in (Unraid included). ' +
|
|
||||||
'NFSv3 auth is AUTH_SYS only; gate access on the server side ' +
|
|
||||||
'by allowing this OpenPXE host’s IP in the export list. ' +
|
|
||||||
'ISOs are streamed on demand and HTTP Range requests work — ' +
|
|
||||||
'NFSv3 READ3 takes an explicit offset, so clients can seek ' +
|
|
||||||
'into a 5 GB ISO without reading what comes before.'),
|
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
@@ -1204,12 +1177,16 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
settings: async () => {
|
settings: async () => {
|
||||||
const [status, me, sso] = await Promise.all([
|
const [status, me, sso, notify, docs] = await Promise.all([
|
||||||
getJSON('/api/status'),
|
getJSON('/api/status'),
|
||||||
getJSON('/api/me').catch(() => ({})),
|
getJSON('/api/me').catch(() => ({})),
|
||||||
getJSON('/api/sso').catch(() => ({
|
getJSON('/api/sso').catch(() => ({
|
||||||
enabled:false, idp_name:'', metadata:'', metadata_url:'',
|
enabled:false, idp_name:'', metadata:'', metadata_url:'',
|
||||||
})),
|
})),
|
||||||
|
// v0.5.1: the former Advanced tab folds in here, so Settings
|
||||||
|
// fetches the notify config + API docs it needs too.
|
||||||
|
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||||||
|
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||||
]);
|
]);
|
||||||
const hasLogo = !!status.custom_logo;
|
const hasLogo = !!status.custom_logo;
|
||||||
|
|
||||||
@@ -1507,18 +1484,26 @@
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// v0.5.0: the API reference moved to the Advanced tab; Settings
|
// v0.5.1: the former "Advanced" sidebar tab now lives here, folded
|
||||||
// now holds just account / SSO / branding.
|
// into a collapsible disclosure beneath the core settings cards —
|
||||||
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]);
|
// webhook/email notifications + the API reference. Keeps Settings
|
||||||
|
// clean by default while leaving the knobs one click away.
|
||||||
|
const [notifyCard, apiCard] = views._advancedCards(notify, docs);
|
||||||
|
const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
|
||||||
|
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
||||||
|
el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
|
||||||
|
]);
|
||||||
|
return el('div', {}, [
|
||||||
|
el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]),
|
||||||
|
advanced,
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
// v0.5.0: Advanced settings — webhook/email notifications, and the
|
// v0.5.1: builds the two "Advanced" cards — webhook/email notifications
|
||||||
// API reference (relocated from the bottom of Settings).
|
// and the API reference. There is no longer an Advanced sidebar tab;
|
||||||
advanced: async () => {
|
// the Settings view folds these into a collapsible disclosure and
|
||||||
const [notify, docs] = await Promise.all([
|
// passes in the pre-fetched `notify` + `docs` payloads.
|
||||||
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
_advancedCards: (notify, docs) => {
|
||||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Notification config ──
|
// ── Notification config ──
|
||||||
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
||||||
@@ -1600,7 +1585,7 @@
|
|||||||
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
|
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
|
||||||
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
|
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
|
||||||
const r = await putJSON('/api/notify', collectNotify());
|
const r = await putJSON('/api/notify', collectNotify());
|
||||||
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('advanced'); }
|
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
|
||||||
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||||||
}}, 'Save notification settings');
|
}}, 'Save notification settings');
|
||||||
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
|
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
|
||||||
@@ -1656,7 +1641,7 @@
|
|||||||
'No API documentation returned by /api/docs.')),
|
'No API documentation returned by /api/docs.')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return el('div', {class:'grid'}, [notifyCard, apiCard]);
|
return [notifyCard, apiCard];
|
||||||
},
|
},
|
||||||
|
|
||||||
about: async () => {
|
about: async () => {
|
||||||
@@ -1770,7 +1755,6 @@
|
|||||||
hosts: 'Hosts',
|
hosts: 'Hosts',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
advanced: 'Advanced',
|
|
||||||
about: 'About',
|
about: 'About',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1910,16 +1894,28 @@
|
|||||||
const err = el('div', {class:'auth-err', style:'display:none'});
|
const err = el('div', {class:'auth-err', style:'display:none'});
|
||||||
const submit = el('button', {class:'submit', type:'submit'}, 'Sign in');
|
const submit = el('button', {class:'submit', type:'submit'}, 'Sign in');
|
||||||
|
|
||||||
|
// v0.5.1: surface a failed/blocked SSO round-trip. The ACS handler
|
||||||
|
// redirects back to "/?sso_error=..." on any failure; we show a
|
||||||
|
// generic, non-leaky message and scrub the query so a refresh is clean.
|
||||||
|
const ssoErr = new URLSearchParams(window.location.search).get('sso_error');
|
||||||
|
if (ssoErr) {
|
||||||
|
err.textContent = ssoErr === 'idp_initiated'
|
||||||
|
? 'IdP-initiated SSO is disabled. Use the “Sign in with …” button, or enable it under Settings → SSO.'
|
||||||
|
: (ssoErr === 'unavailable' || ssoErr === 'metadata')
|
||||||
|
? 'Single sign-on is unavailable right now. Sign in with the local admin, or check the SSO settings.'
|
||||||
|
: 'SSO sign-in failed. Please try again, or sign in with the local admin.';
|
||||||
|
err.style.display = '';
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata)
|
const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata)
|
||||||
? el('button', {type:'button', class:'sso-btn', onclick: () => {
|
? el('button', {type:'button', class:'sso-btn', onclick: () => {
|
||||||
// SSO login flow lands in a later release — for now we
|
// SP-initiated SAML login (v0.5.1): hand off to the IdP. The
|
||||||
// surface a friendly note so the operator knows the config
|
// /api/sso/acs endpoint verifies the response, mints the
|
||||||
// landed but the runtime hookup is pending.
|
// operator session, and redirects back to the dashboard.
|
||||||
err.textContent = 'SSO sign-in is configured but the runtime flow ships in a future release. Sign in with the local admin for now.';
|
window.location.assign('/api/sso/login');
|
||||||
err.style.display = '';
|
|
||||||
}}, [
|
}}, [
|
||||||
el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
|
el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
|
||||||
el('div', {class:'meta'}, 'configured · runtime flow pending'),
|
|
||||||
])
|
])
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@
|
|||||||
</a>
|
</a>
|
||||||
<a data-view="terminal">Terminal</a>
|
<a data-view="terminal">Terminal</a>
|
||||||
<a data-view="settings">Settings</a>
|
<a data-view="settings">Settings</a>
|
||||||
<a data-view="advanced">Advanced</a>
|
|
||||||
<a data-view="about">About</a>
|
<a data-view="about">About</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user