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:
Miles Ward
2026-05-31 00:50:28 -04:00
co-authored by Claude Opus 4.8
parent 252b557b9c
commit cbcd63bb14
21 changed files with 3748 additions and 298 deletions
+42 -47
View File
@@ -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()
}