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
+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.
|
||||
|
||||
Reference in New Issue
Block a user