Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SSO

The Phase-5 WebAuthn flow stays the default for single-user panel operators. Phase-9 adds SAML 2.0 + OIDC as opt-in per-organisation SSO providers behind a uniform AuthProvider trait.

Architecture

Three pieces:

  1. edssa-sso crate — trait + types + registry only. No samael / openidconnect compile dep; concrete providers live in sibling crates behind cargo features.
  2. edssa-panel SSO routes/sso/:provider_ref/start + /sso/:provider_ref/callback consume the registry; the nonce ceremony state lives in panel AppState.
  3. Concrete providers — cargo features on edssa-sso. The two OIDC ones are on in the panel build today (oidc-jwt-provider, oidc-code-exchange-provider, plus oidc-discovery and jwks-rotation), so an operator can register an OIDC IdP from the panel without a rebuild — see below. SAML is the one still pending: the trait and registry accept it, the concrete samael provider and its form are customer-driven follow-up.

AuthProvider trait

#![allow(unused)]
fn main() {
pub trait AuthProvider: Send + Sync {
    fn id(&self) -> &str;                  // matches Organization::sso_provider_ref
    fn protocol(&self) -> AuthProtocol;    // Saml / Oidc / WebAuthn
    fn organization_id(&self) -> &OrganizationId;

    fn begin_login(
        &self,
        request: BeginLoginRequest,         // { return_url, state_nonce }
    ) -> Result<BeginLoginResponse, AuthError>;

    fn complete_login(
        &self,
        request: CompleteLoginRequest,      // { callback_data, state_nonce, stash }
    ) -> Result<AuthenticatedPrincipal, AuthError>;
}
}

The trait is intentionally two-step. begin_login produces either a redirect URL (SAML / OIDC) or a challenge blob (WebAuthn-via-trait providers); complete_login validates the IdP callback / WebAuthn assertion and returns an AuthenticatedPrincipal { subject_id, tenant_id, organization_id, email, display_name, groups, claims }.

A provider may also hand back an opaque per-attempt secret in BeginLoginResponse::Redirect::stash — the OIDC code-exchange provider’s PKCE code_verifier rides here. The panel stores it server-side beside the CSRF nonce and returns the same value in CompleteLoginRequest::stash; it never reaches the user agent.

CSRF nonce contract

The trait’s state_nonce field is the CSRF guard. Provider impls MUST:

  1. Embed the panel’s state_nonce in the IdP’s state parameter (SAML RelayState, OIDC state).
  2. Surface the same value back through the IdP callback into CompleteLoginRequest::state_nonce.
  3. Return AuthError::InvalidNonce on mismatch.

The panel separately verifies the nonce against its in-memory SsoFlows pending-map (10-minute TTL, single-use). Double check: the panel rejects forged callbacks that never went through /start; the provider rejects IdP responses whose state echo doesn’t match. Each failure mode is distinct in logs.

Provider registry

AuthProviderRegistry is an RwLock<HashMap<String, Arc<dyn AuthProvider>>> keyed by sso_provider_ref. Read-mostly: many concurrent get_by_ref reads, occasional register writes when an operator adds an IdP via the panel.

#![allow(unused)]
fn main() {
let registry = Arc::new(AuthProviderRegistry::new());
registry.register(Arc::new(SamlProvider::new(...)))?;
}

Duplicates fail at registration with RegistryError::Duplicate(id) — runtime collisions indicate a configuration bug the operator should fix.

Panel routes

RouteBehaviour
GET /sso/:provider_ref/start?return_url=...404 if unknown provider; 307 redirect (SAML/OIDC) or JSON { kind: "challenge", state_nonce, challenge: <base64> } (WebAuthn-via-trait). return_url must be a panel-local path; anything else falls back to /dashboard (open-redirect guard).
GET /sso/:provider_ref/callback?code=...&state=...The browser-facing shape — what a real OIDC IdP redirects back with. On success: issues the tenant-scoped session cookie + 303 to the panel-local return_url. An IdP error response is logged and answered 401 without echoing the IdP-authored text.
POST /sso/:provider_ref/callbackJSON shape for XHR-style clients: { state_nonce, callback_data: <base64> }. On success: session cookie + JSON { ok: true, redirect: <return_url> }. On failure: 401 with empty body (IdP error fingerprinting prevention).

Both callback shapes run the same completion path — nonce consumption, provider re-resolution, PKCE-stash round-trip, cookie issuance.

The callback handler:

  1. Reads state_nonce from the request body.
  2. Consumes the nonce from SsoFlows (single-use; replay protection).
  3. Verifies the callback’s :provider_ref matches what /start stashed alongside the nonce — defence in depth against shuttling a stolen nonce across providers.
  4. Re-resolves the provider (an operator could have removed the IdP between /start and /callback).
  5. Base64-decodes callback_data.
  6. Calls complete_login.
  7. On success: maps the principal’s groups to the RBAC role and issues auth.issue_tenant_scoped_session_cookie(subject, tenant, org, role).

Session payload formats

The SessionSigner dispatches on field count:

Legacy        (2 fields): <user_id>|<exp>[|g=<epoch>]
Tenant-scoped (4 fields): <subject>|<tenant_id>|<org_id>|<exp>[|r=<role>][|g=<epoch>]

The optional trailing tags are popped before the field-count dispatch: g= is the sign-out-everywhere epoch, r= the RBAC role mapped from IdP groups at login. Cookies issued before either tag existed keep verifying; a tenant-scoped cookie without r= reads as role-unknown and the role gate fails it closed to viewer.

SessionSigner::verify(token, now) -> Option<String> continues to return the subject for both shapes — pre-Phase-9 callers keep working unchanged. New callers (the RBAC middleware) use verify_payload(token, now) -> Option<SessionPayload> to get the typed enum.

WebAuthn callbacks issue legacy sessions; SSO callbacks issue tenant-scoped sessions. Mixed deployments (some passkey operators + some SSO users) compose cleanly.

AuthenticatedPrincipal

#![allow(unused)]
fn main() {
pub struct AuthenticatedPrincipal {
    pub subject_id: SubjectId,          // IdP NameID / sub / WebAuthn user handle
    pub tenant_id: TenantId,            // required; provider knows its tenant
    pub organization_id: OrganizationId,
    pub email: Option<String>,
    pub display_name: Option<String>,
    pub groups: Vec<String>,            // IdP group memberships → RBAC roles
    pub claims: BTreeMap<String, String>, // BTreeMap → deterministic JSON for audit
}
}

SubjectId accepts UUIDs, email addresses, opaque base64url user handles — the IdP’s contract. The validator only rejects empty strings and tokens longer than 256 chars.

claims uses BTreeMap so the serialised JSON has deterministic key order — audit-log reproducibility depends on this for byte-for-byte hash stability across runs.

Registering an OIDC provider from the panel

Providers are registered per organisation, under /tenants/:tenant/orgs/:org/sso.

The panel's SSO providers page for the aegis-platform organisation: a breadcrumb from Tenants through the tenant to the org, a link to download the tenant's provider config as JSON that excludes client_secret material, a persistence note about the sealed SQLite provider store, and a provider table with one row — oidc-code:aegis-platform with an oidc protocol pill and test sign-in, edit and Delete actions — above an 'Add a provider' card describing the OIDC JWT-bearer and OIDC code-exchange flavours plus the pending SAML support.
The organisation-scoped SSO admin with a registered code-exchange provider — here a self-hosted Zitadel, registered through the persistent provider store and reloaded at every panel boot. Users of this organisation authenticate at the IdP; organisations without a provider fall back to the WebAuthn ceremony at /login. The two OIDC flavours differ in who talks to the IdP: JWT-bearer trusts an upstream IdP-aware proxy (oauth2-proxy, Pomerium, IAP) that forwards a verified ID token, so the panel needs only the issuer and audience; code-exchange makes the panel the OIDC client itself and so needs a client_id, client_secret and redirect_uri. Note the persistence note: without EDSSA_PANEL_PROVIDER_STORE_PATH the panel keeps providers in memory and they do not survive a restart.

Both flavours run real OIDC Discovery against the IdP’s .well-known/openid-configuration at form-submit time and bootstrap JWKS from it, so a typo in the issuer URL fails at registration rather than at first login.

Code-exchange specifics

  • Token-endpoint auth: client_secret_basic by default (the method RFC 6749 §2.3.1 obliges every authorization server to support, with the credentials form-urlencoded before the base64 step); client_secret_post selectable on the form for IdP apps registered in body-credentials mode.
  • PKCE (S256) is always on — a fresh verifier per login attempt, held server-side, never in the browser. There is no off switch: servers ignore unknown authorization parameters, so IdPs without PKCE simply skip it.
  • Groups claim is configurable. Empty means the conventional top-level groups array; pointing it at an IdP-specific claim also handles object-shaped values by taking their keys. For Zitadel, set urn:zitadel:iam:org:project:roles — and enable the app’s ID Token Role Assertion flag, without which the claim is absent from the ID token entirely. A string-valued claim is one group, never whitespace-split (directory group names legitimately contain spaces).

Groups → panel roles

The mapped groups decide the session’s RBAC role at login time: EDSSA_PANEL_SSO_OPERATOR_GROUPS names the IdP groups (comma- or semicolon-separated) that grant the operator role; every other authenticated SSO user is a viewer, whose mutating requests the role gate answers with 403. Unset means every SSO login is a viewer — the fail-closed default, so an unconfigured mapping can never silently mint operators. The role rides inside the HMAC-signed session cookie, so a role change at the IdP takes effect on the user’s next login, and editing the cookie’s role field is a signature break, not an escalation. WebAuthn sessions are untouched: their role still comes from the panel’s own per-passkey role store.

code/samples/zitadel-sso/ in the repository stands up a pinned self-hosted Zitadel, provisions an org / project / roles / app / two users for the panel (alice with panel-admin — operator under the sample mapping — and bob with panel-viewer only, the read-only half of the demo), and emits a ready-made import document for edssa-panel-import-sso — the fastest way to run the whole flow against a real IdP locally.

Operator workflow: registering a SAML IdP

This is the path that has not shipped yet — it is written down so the shape is agreed, and step 2 is the gate:

  1. Operator obtains the IdP’s metadata (Okta / Azure AD / ADFS admin UI).
  2. Operator configures the provider via the samael-provider cargo feature’s bootstrap config (when that ships in a future batch).
  3. Panel registers the provider at process startup: registry.register(Arc::new(SamlProvider::new(...)))?.
  4. Operator updates the org’s Organization::sso_provider_ref in the tenant store seed JSON.
  5. Users in that org get redirected through SAML on next /login; the callback issues a tenant-scoped session cookie keyed on (subject, tenant, org).

Why no samael / openidconnect in edssa-sso

This is about those two crates, not about the protocols — OIDC ships, as above. It is implemented directly on jsonwebtoken (+ reqwest for discovery and JWKS), both optional, rather than by taking the openidconnect crate.

samael pulls in OpenSSL bindings; openidconnect drags reqwest + a large futures stack. Keeping edssa-sso lean means:

  • Crates that only need the trait (panel session middleware, future audit-event SSO tagger, RBAC matcher) compile fast.
  • Concrete providers live in sibling crates that opt into the protocol-specific deps.
  • Customer-driven IdP integrations (most enterprise SAML wiring targets one specific IdP at a time) don’t have to drag the full SAML / OIDC dep graph into deployments that don’t need it.

WebAuthn fallback

For organisations without an external IdP, the Phase-5 WebAuthn flow continues to work at /login/register/start + /login/authenticate/finish. Operator-admin sessions (single- passkey, no tenant scope) coexist with SSO sessions: the panel’s enforce_tenant_rbac returns None for legacy sessions (operator-admin view) and gates by tenant for tenant-scoped sessions.

This preserves the upgrade path: existing single-operator deployments don’t break when SSO providers register. The WebAuthn ceremony’s CBOR shape doesn’t currently route through the AuthProvider trait (the Challenge { challenge: bytes } variant exists for future opt-in but adds CBOR-round-trip friction without unlocking new behaviour today).

Errors

AuthError variantHTTP statusBodyAudit
InvalidNonce401emptyyes
InvalidCallback(reason)401emptyyes (reason in tracing log only — IdP-error-message-fingerprinting prevention)
InvalidProtocol(reason)400emptyyes
Network(reason)502emptyyes
Internal(reason)500emptyyes (escalation)

401 bodies are intentionally empty (ADR-006) to avoid leaking “this account exists” / “your IdP rejected the assertion for reason X” oracle behaviour to attackers.