Pure-Rust SAML SP (bergshamra), Advanced tab folded into Settings, SMB+NFS merged into a Remote shares card with a protocol dropdown. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
9.3 KiB
OpenPXE v0.5.1 — SAML SSO wiring + Settings/Storage UI consolidation
Date: 2026-05-31 Author: Miles Ward (with Claude) Status: Approved design → implementation
Summary
Three workstreams for v0.5.1:
- Wire SAML 2.0 SSO end-to-end (currently config is persisted but no runtime sign-in exists). Pure-Rust implementation that preserves the static-musl / no-OpenSSL architecture, mirroring how FleetDM exposes and handles SAML.
- Fold the Advanced sidebar tab into Settings as a collapsible section.
- Merge the Storage tab's SMB and NFS cards into one "Remote shares" card with a protocol dropdown.
Then bump 0.5.0 → 0.5.1, build the static musl image, push :0.5.1 + :latest
to Gitea, create the release, and scrub registry credentials.
Decisions (locked with the user)
- Crypto: pure-Rust via
bergshamra(XML-DSig + exclusive c14n, RustCrypto-based,#![forbid(unsafe_code)], ~99% xmlsec interop).samaelis rejected — it hard-requires OpenSSL/xmlsec/libxml2C deps, which would break the static musl binary and the project's pure-Rust / no-OpenSSL architecture. - Access model: any SAML assertion the IdP successfully authenticates and that we cryptographically verify mints a full operator session. No user table, no roles, no domain allowlist. The local admin account remains a guaranteed fallback owner regardless of SSO state.
- Flows: SP-initiated (the "Sign in with " button) is always on.
IdP-initiated is supported but gated behind an
allow_idp_initiatedtoggle (default off), mirroring FleetDM's "Allow SSO login initiated by identity provider."
Scope boundaries (v0.5.1)
In scope: SP-initiated + (gated) IdP-initiated login, signature verification on the SAML Response/Assertion, full SP-side semantic validation, SP metadata endpoint, login-page button wiring.
Out of scope (note for later releases): EncryptedAssertion (assertions must be unencrypted), signed AuthnRequests (sent unsigned; Keycloak "client signature required" must be off), Single Logout (SLO), multi-user accounts / RBAC / JIT role mapping.
Workstream 1 — SAML SP wiring (pure-Rust)
New dependencies (workspace)
bergshamra— XML-DSig verification + exclusive c14n (pure Rust).roxmltree(read/navigate) and/orquick-xml(build/serialize) — parse IdP metadata + SAMLResponse, build AuthnRequest and SP metadata.x509-parser— extract the IdP signing certificate / public key from metadata.flate2— raw DEFLATE for the HTTP-Redirect binding.base64— encode/decode SAMLRequest/SAMLResponse.
All pure-Rust → the x86_64-unknown-linux-musl static build stays OpenSSL-free.
Exact bergshamra function signatures (verify, DsigContext, KeysManager,
Key, VerifiedReference, VerifyResult) will be pinned against the installed
crate source during implementation.
Module boundaries
Pure protocol logic lives in openpxe-core (no axum dependency, unit-testable);
HTTP wiring lives in openpxe-http-api.
crates/core/src/saml/mod.rs— public surface + shared types (VerifiedPrincipal { email, display_name, name_id, session_index },SamlError).crates/core/src/saml/metadata.rs— parse IdPEntityDescriptor: IdP EntityID,SingleSignOnServicelocations + bindings, and one or more X.509 signing certificates. Also build our SP metadata XML.crates/core/src/saml/authn_request.rs— build an AuthnRequest, return both the request ID (to track) and the encoded HTTP-Redirect query value (deflate → base64 → URL-encode).crates/core/src/saml/response.rs— decodeSAMLResponse(base64 → XML), verify the signature via bergshamra against the IdP cert, then enforce SP semantics, returningVerifiedPrincipalor a typedSamlError.
SP-side validation (response.rs)
After a cryptographically valid signature over the Response and/or the Assertion:
StatusisSuccess.Destination(if present) equals our ACS URL.Conditions/AudienceRestriction/Audienceequals our SP EntityID.NotBefore/NotOnOrAfterwithin bounds (allow small clock skew, e.g. ±60s).InResponseTomatches an outstanding request we issued (SP-initiated). Absent for IdP-initiated, which is only accepted whenallow_idp_initiatedis true.- Assertion-ID replay guard: reject a previously consumed assertion ID.
- NameID is the email (
nameid-format:emailAddress). Display name read from common attributes (name,displayname,cn,urn:oid:2.5.4.3).
XML Signature Wrapping (XSW) defenses come from bergshamra (duplicate-ID rejection,
strict positional verification); enable its strict verification options. We
additionally confirm the verified Reference covers the element we read claims from.
State (in openpxe-http-api)
Two small TTL-pruned in-memory stores (parking_lot Mutex<HashMap<...>>):
- Outstanding requests:
request_id → issued_at, TTL ≈ 5 min, forInResponseTo. - Consumed assertions:
assertion_id → expires_at, TTL = assertion validity, for replay protection.
(In-memory is acceptable: a single-container app; a restart simply invalidates in-flight logins.)
Routes (all pre-auth; added to the public allowlist in the auth middleware)
GET /api/sso/login→ build AuthnRequest, record its ID, 302 to the IdP SSO URL (HTTP-Redirect binding) withSAMLRequest+RelayState.POST /api/sso/acs→ consumeSAMLResponse(form-encoded). Verify + validate. On success:SessionStore::create(email), set theopenpxe_sessioncookie (same attributes as forms login), 302 to the dashboard. On failure: 302 back to the login page with an error indicator. (Mirrors FleetDM's/sso/callback.)GET /api/sso/metadata→ serve our SPEntityDescriptorXML for IdP import.
Config changes (crates/core/src/sso.rs)
Add to SsoConfig (preserve existing fields + validation):
entity_id: String— SP Entity ID (mirrors FleetDM's "Entity ID"); defaults to the configured public base URL. The ACS URL is derived as<public_base_url>/api/sso/acs.allow_idp_initiated: bool— defaultfalse.
GET /api/sso returns the new fields; PUT /api/sso validates and persists them.
Login page (crates/webui/src/app.js)
Replace the "configured · runtime pending" message: the existing
"Sign in with " button navigates to GET /api/sso/login. Render the IdP logo
(if idp_logo_url set) and use idp_name as the label. Keep the existing
FleetDM-style login layout.
Testing
core/samlunit tests using a self-signed test keypair we control:- Parse representative Keycloak IdP metadata → correct SSO URL + cert.
- Build an AuthnRequest → well-formed, deflate/base64 round-trips, ID recorded.
- A correctly signed Response →
VerifiedPrincipal { email, .. }. - Reject: tampered signature, expired (
NotOnOrAfter), wrong audience, replayed assertion ID, unsigned response,Status != Success.
http-apiintegration test:GET /api/sso/loginreturns a 302 with aSAMLRequestquery param; a crafted signedSAMLResponsePOSTed to/api/sso/acs(signed with the test key) sets anopenpxe_sessioncookie.
Workstream 2 — Advanced tab → Settings
- Remove the
Advancedsidebar entry (crates/webui/src/index.html) and itsadvancedview route inapp.js. - In the Settings view, append a collapsible "Advanced" disclosure (default-collapsed) at the bottom containing the existing Webhook Notifications card and the API reference block (moved out of the removed Advanced view).
- No backend changes;
/api/notify*and/api/docsendpoints are unchanged.
Workstream 3 — Storage: merge SMB + NFS → "Remote shares"
- Replace the separate "SMB shares" and "NFS shares" cards with a single
"Remote shares" card:
- One add-form with a protocol dropdown (SMB / NFS). Selecting the protocol swaps the fields: SMB → server, share, guest checkbox, username, password; NFS → server, export path.
- One unified table with a leading Protocol column (SMB/NFS badge), then server/share-or-export, auth, ISO count, reachability, and Re-scan / Remove actions.
- No backend changes. The form dispatches to the existing
POST /api/smb-sharesorPOST /api/nfs-shares; the table mergesGET /api/smb-shares+GET /api/nfs-shares, tagging each row with its protocol. Re-scan/Remove call the existing per-protocol endpoints. - Leaves the card pattern open for a future "Config files" card.
Release
- Bump workspace version
0.5.0 → 0.5.1(Cargo.toml). cargo fmt,cargo clippy,cargo test(all crates) green.- Build the static musl binary + Docker image; verify SAML deps compile clean under musl (no OpenSSL/C linkage).
- Push
openpxe:0.5.1+openpxe:latestto Gitea via the established temp-DOCKER_CONFIG pipeline; scrub credentials (logout + verify no token traces). - Create the Gitea release
v0.5.1with notes.
Risks
bergshamrais pre-1.0 and unaudited. Mitigation: pin the version, enable strict verification, keep the local-admin fallback, and own the SP-semantic checks carefully (audience/Conditions/replay/InResponseTo — where SP vulns usually live).- SAML is security-sensitive; negative tests (tamper/expiry/audience/replay/unsigned) are part of the definition of done, not optional.