# 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 <IdP>" 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 <IdP>" 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.
