Files
OpenPXE/docs/superpowers/specs/2026-05-31-saml-sso-and-ui-design.md
Miles WardandClaude Opus 4.8 252b557b9c docs: v0.5.1 design spec — SAML SSO wiring + Settings/Storage UI consolidation
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]>
2026-05-31 00:10:40 -04:00

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:

  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.
  2. Fold the Advanced sidebar tab into Settings as a collapsible section.
  3. 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). samael is rejected — it hard-requires OpenSSL/xmlsec/libxml2 C 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_initiated toggle (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/or quick-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 IdP EntityDescriptor: IdP EntityID, SingleSignOnService locations + 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 — decode SAMLResponse (base64 → XML), verify the signature via bergshamra against the IdP cert, then enforce SP semantics, returning VerifiedPrincipal or a typed SamlError.

SP-side validation (response.rs)

After a cryptographically valid signature over the Response and/or the Assertion:

  1. Status is Success.
  2. Destination (if present) equals our ACS URL.
  3. Conditions/AudienceRestriction/Audience equals our SP EntityID.
  4. NotBefore / NotOnOrAfter within bounds (allow small clock skew, e.g. ±60s).
  5. InResponseTo matches an outstanding request we issued (SP-initiated). Absent for IdP-initiated, which is only accepted when allow_idp_initiated is true.
  6. Assertion-ID replay guard: reject a previously consumed assertion ID.
  7. 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, for InResponseTo.
  • 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) with SAMLRequest + RelayState.
  • POST /api/sso/acs → consume SAMLResponse (form-encoded). Verify + validate. On success: SessionStore::create(email), set the openpxe_session cookie (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 SP EntityDescriptor XML 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 — default false.

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/saml unit 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-api integration test: GET /api/sso/login returns a 302 with a SAMLRequest query param; a crafted signed SAMLResponse POSTed to /api/sso/acs (signed with the test key) sets an openpxe_session cookie.

Workstream 2 — Advanced tab → Settings

  • Remove the Advanced sidebar entry (crates/webui/src/index.html) and its advanced view route in app.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/docs endpoints 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-shares or POST /api/nfs-shares; the table merges GET /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

  1. Bump workspace version 0.5.0 → 0.5.1 (Cargo.toml).
  2. cargo fmt, cargo clippy, cargo test (all crates) green.
  3. Build the static musl binary + Docker image; verify SAML deps compile clean under musl (no OpenSSL/C linkage).
  4. Push openpxe:0.5.1 + openpxe:latest to Gitea via the established temp-DOCKER_CONFIG pipeline; scrub credentials (logout + verify no token traces).
  5. Create the Gitea release v0.5.1 with notes.

Risks

  • bergshamra is 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.