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