diff --git a/docs/superpowers/specs/2026-05-31-saml-sso-and-ui-design.md b/docs/superpowers/specs/2026-05-31-saml-sso-and-ui-design.md new file mode 100644 index 0000000..e371014 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-saml-sso-and-ui-design.md @@ -0,0 +1,199 @@ +# 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.