# 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>`): - **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 `/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.