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
@@ -0,0 +1,92 @@
|
||||
//! Pure-Rust SAML 2.0 Service Provider (v0.5.1).
|
||||
//!
|
||||
//! This module implements the SP half of a SAML Web-Browser-SSO profile:
|
||||
//!
|
||||
//! * [`metadata`] — parse the IdP's `EntityDescriptor` (SSO URLs + signing
|
||||
//! certificates) and build *our* SP metadata for the IdP admin to import.
|
||||
//! * [`authn_request`] — build an `AuthnRequest` and encode it for the
|
||||
//! HTTP-Redirect binding.
|
||||
//! * [`response`] — decode a `SAMLResponse`, **verify its XML signature**
|
||||
//! against the IdP's pinned certificate (via the pure-Rust `bergshamra`
|
||||
//! crate — no OpenSSL/libxml2/xmlsec, so the static musl binary stays
|
||||
//! C-free), then enforce the SP-side semantic checks (Status, Destination,
|
||||
//! Audience, time bounds) that are where SAML SPs actually get attacked.
|
||||
//!
|
||||
//! Stateful checks (replay of assertion IDs, correlating `InResponseTo`
|
||||
//! against requests *we* issued, gating IdP-initiated login) live in the
|
||||
//! HTTP layer — [`response::consume`] is deliberately stateless and returns
|
||||
//! the IDs the caller needs to perform them.
|
||||
//!
|
||||
//! Access model: any assertion the IdP authenticates and we cryptographically
|
||||
//! verify yields an operator [`VerifiedPrincipal`]. OpenPXE is single-tier —
|
||||
//! there is no per-user role table — and the local admin account remains a
|
||||
//! guaranteed fallback owner regardless of SSO state.
|
||||
|
||||
pub mod authn_request;
|
||||
pub mod metadata;
|
||||
pub mod response;
|
||||
|
||||
pub use authn_request::AuthnRequest;
|
||||
pub use metadata::IdpMetadata;
|
||||
pub use response::{VerifiedPrincipal, VerifiedResponse};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Default clock-skew tolerance applied to assertion time bounds. SAML IdPs
|
||||
/// and SPs rarely have perfectly synced clocks; 60s matches common practice
|
||||
/// (Shibboleth/FleetDM defaults are in this ballpark).
|
||||
pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 60;
|
||||
|
||||
/// Runtime SP parameters, derived from [`crate::SsoConfig`] + the advertised
|
||||
/// public base URL by the HTTP layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpParams {
|
||||
/// Our SP Entity ID (the `<Issuer>` we send and the `Audience` we require
|
||||
/// in responses). Defaults to the public base URL when the operator left
|
||||
/// the Entity ID field blank.
|
||||
pub entity_id: String,
|
||||
/// The Assertion Consumer Service URL the IdP POSTs the response to —
|
||||
/// `<public_base_url>/api/sso/acs`.
|
||||
pub acs_url: String,
|
||||
}
|
||||
|
||||
/// Everything that can go wrong consuming a SAML response. Kept coarse on
|
||||
/// purpose: the HTTP layer logs the detail and shows the operator a generic
|
||||
/// "SSO sign-in failed" — we never leak which specific check tripped to the
|
||||
/// browser, since that aids an attacker probing the SP.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SamlError {
|
||||
#[error("SAML XML parse error: {0}")]
|
||||
Xml(String),
|
||||
#[error("IdP metadata is missing a required element: {0}")]
|
||||
Metadata(String),
|
||||
#[error("no usable IdP signing certificate in metadata")]
|
||||
NoSigningCert,
|
||||
#[error("signature verification failed: {0}")]
|
||||
Signature(String),
|
||||
#[error("the signature does not cover the assertion we read")]
|
||||
SignatureScope,
|
||||
#[error("SAML response status was not Success: {0}")]
|
||||
Status(String),
|
||||
#[error("response is missing a required element: {0}")]
|
||||
MissingElement(String),
|
||||
#[error("encrypted assertions are not supported in this release")]
|
||||
EncryptedAssertionUnsupported,
|
||||
#[error("expected exactly one assertion, found {0}")]
|
||||
AssertionCount(usize),
|
||||
#[error("issuer mismatch: response was not issued by the configured IdP")]
|
||||
IssuerMismatch,
|
||||
#[error("audience mismatch: assertion is not addressed to this service provider")]
|
||||
AudienceMismatch,
|
||||
#[error("response destination does not match our ACS URL")]
|
||||
DestinationMismatch,
|
||||
#[error("assertion is expired or not yet valid")]
|
||||
TimeBounds,
|
||||
#[error("invalid SAML timestamp: {0}")]
|
||||
Timestamp(String),
|
||||
#[error("base64 decode failed: {0}")]
|
||||
Base64(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user