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
+42
-47
@@ -116,14 +116,16 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/login", post(auth_api::api_login))
|
||||
.route("/api/logout", post(auth_api::api_logout))
|
||||
.route("/api/me", get(auth_api::api_me))
|
||||
.route(
|
||||
"/api/me/credentials",
|
||||
put(auth_api::api_update_credentials),
|
||||
)
|
||||
// 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/me/credentials", put(auth_api::api_update_credentials))
|
||||
// SAML SSO configuration (FleetDM-shaped). Gated behind auth — the
|
||||
// operator pastes their IdP metadata, Entity ID, and toggles here.
|
||||
.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/status", get(api_status))
|
||||
.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
|
||||
// load a host kernel module. `smbclient` speaks SMB over a
|
||||
// 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/scan", post(api_smb_shares_scan))
|
||||
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
|
||||
// Ships alongside SMB. Routes are parallel so the UI can
|
||||
// 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/scan", post(api_nfs_shares_scan))
|
||||
// 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 {
|
||||
match state.sso.replace(body) {
|
||||
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
|
||||
Err(Error::Invalid(msg)) => {
|
||||
(StatusCode::BAD_REQUEST, msg).into_response()
|
||||
}
|
||||
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).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
|
||||
/// upper bound on caching across an upgrade is "until the operator
|
||||
/// reloads".
|
||||
const ASSET_CACHE_CONTROL: HeaderValue =
|
||||
HeaderValue::from_static("no-cache, must-revalidate");
|
||||
const ASSET_CACHE_CONTROL: HeaderValue = HeaderValue::from_static("no-cache, must-revalidate");
|
||||
|
||||
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
|
||||
// file) yields `None`, which composes the default background.
|
||||
let raster: Option<Vec<u8>> = match (state.branding.logo_path(), state.branding.logo_mime()) {
|
||||
(Some(path), Some(mime)) if mime != "image/svg+xml" => {
|
||||
tokio::fs::read(&path).await.ok()
|
||||
}
|
||||
(Some(path), Some(mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -693,9 +696,7 @@ async fn iso_raw(
|
||||
};
|
||||
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
IsoSource::Smb {
|
||||
@@ -710,7 +711,10 @@ async fn iso_raw(
|
||||
if headers.get(header::RANGE).is_some() {
|
||||
return Response::builder()
|
||||
.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())
|
||||
.unwrap();
|
||||
}
|
||||
@@ -1048,10 +1052,7 @@ async fn api_storage_disk(State(state): State<AppState>) -> Json<serde_json::Val
|
||||
|
||||
// ─── Branding (custom logo) ───────────────────────────────────────────────
|
||||
|
||||
async fn api_branding_upload(
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Response {
|
||||
async fn api_branding_upload(State(state): State<AppState>, mut multipart: Multipart) -> Response {
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
if name != "file" && name != "logo" {
|
||||
@@ -1072,9 +1073,7 @@ async fn api_branding_upload(
|
||||
// hitting disk. Logos are tiny by definition.
|
||||
let bytes = match field.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response()
|
||||
}
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(),
|
||||
};
|
||||
if bytes.len() > MAX_LOGO_BYTES {
|
||||
return (
|
||||
@@ -1102,9 +1101,7 @@ async fn api_branding_upload(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
||||
}
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).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
|
||||
/// wake MACs that are actually bound — keeps this from being an open
|
||||
/// "spray packets at any MAC" endpoint.
|
||||
async fn api_hosts_wol(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(mac): AxumPath<String>,
|
||||
) -> Response {
|
||||
async fn api_hosts_wol(State(state): State<AppState>, AxumPath(mac): AxumPath<String>) -> Response {
|
||||
if state.hosts.lookup(&mac).is_none() {
|
||||
return (
|
||||
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
|
||||
// executor.
|
||||
let mac_owned = mac.clone();
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
|
||||
let result = tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
|
||||
match result {
|
||||
Ok(Ok(n)) => {
|
||||
// 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()
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
async fn api_notify_put(
|
||||
State(state): State<AppState>,
|
||||
Json(cfg): Json<NotifyConfig>,
|
||||
) -> Response {
|
||||
async fn api_notify_put(State(state): State<AppState>, Json(cfg): Json<NotifyConfig>) -> Response {
|
||||
match state.notify.replace(cfg) {
|
||||
Ok(saved) => (StatusCode::OK, Json(saved.redacted())).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())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let update_available = version_is_newer(
|
||||
latest_tag.trim_start_matches('v'),
|
||||
current,
|
||||
);
|
||||
let update_available = version_is_newer(latest_tag.trim_start_matches('v'), current);
|
||||
Json(json!({
|
||||
"current": current,
|
||||
"latest": latest_tag,
|
||||
@@ -2241,7 +2232,11 @@ fn gitea_releases_api_url() -> Option<String> {
|
||||
fn version_is_newer(latest: &str, current: &str) -> bool {
|
||||
fn parts(v: &str) -> Vec<u64> {
|
||||
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))
|
||||
.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
|
||||
// us to be explicit. `cast_signed` is the documented form.
|
||||
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
|
||||
format!(
|
||||
"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}"
|
||||
)
|
||||
format!("{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> {
|
||||
@@ -169,9 +176,18 @@ fn is_public_path(path: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
// 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!(
|
||||
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/poll/")
|
||||
}
|
||||
@@ -222,10 +238,7 @@ pub struct SetupBody {
|
||||
/// guards against a leaked WebUI being re-bootstrapped by an attacker
|
||||
/// who's seen the deployment URL. After bootstrap, the new session
|
||||
/// cookie is set so the operator goes straight to the dashboard.
|
||||
pub async fn api_setup(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<SetupBody>,
|
||||
) -> Response {
|
||||
pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
|
||||
if state.admin.is_configured() {
|
||||
return (
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn api_logout(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
||||
if let Some(t) = parse_cookie(&headers) {
|
||||
state.sessions.revoke(&t);
|
||||
}
|
||||
@@ -449,8 +459,14 @@ mod tests {
|
||||
fn public_path_allowlist() {
|
||||
// PXE + chrome paths bypass auth.
|
||||
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",
|
||||
// v0.4.6: iPXE fetches this for `console --picture` before
|
||||
// it can possibly have a session cookie.
|
||||
@@ -462,6 +478,10 @@ mod tests {
|
||||
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
|
||||
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).
|
||||
assert!(is_public_path("/api/queue/join"));
|
||||
assert!(is_public_path("/api/queue/poll/abc"));
|
||||
@@ -483,8 +503,7 @@ mod tests {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux"))
|
||||
.unwrap(),
|
||||
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(),
|
||||
);
|
||||
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
|
||||
// Different name → None.
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod ipxe_script;
|
||||
pub mod iso_fs;
|
||||
pub mod log_stream;
|
||||
pub mod notify;
|
||||
pub mod saml_routes;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
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::saml_routes::SamlRuntime;
|
||||
use crate::uploads::UploadSessions;
|
||||
use openpxe_core::{
|
||||
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
||||
Metrics, NotifyStore, SettingsStore, SsoStore,
|
||||
@@ -34,9 +35,13 @@ pub struct AppState {
|
||||
/// process restart (sessions are tied to UI state, not persisted —
|
||||
/// matches Sonarr/Radarr behaviour).
|
||||
pub sessions: SessionStore,
|
||||
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
|
||||
/// flow ships in a later release.
|
||||
/// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles).
|
||||
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/
|
||||
/// SMTP). Drives the fire-and-forget pings on boot events and powers
|
||||
/// the Advanced tab's config + "Send test" button.
|
||||
|
||||
Reference in New Issue
Block a user