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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user