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]>
93 lines
3.8 KiB
Rust
93 lines
3.8 KiB
Rust
//! 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;
|