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 SAML / OIDC providerssamael-provider / openidconnect-provider cargo features land behind customer-driven follow-up batches. The substrate ships today; the protocol implementations land per-customer.

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 }
    ) -> 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 }.

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)
POST /sso/:provider_ref/callbackBody: { state_nonce, callback_data: <base64> }. On success: issues tenant-scoped session cookie + JSON { ok: true, redirect: <return_url> }. On failure: 401 with empty body (IdP error fingerprinting prevention).

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: issues auth.issue_tenant_scoped_session_cookie(subject, tenant, org).

Session payload formats

The SessionSigner dispatches on field count:

Legacy        (2 fields): <user_id>|<exp>
Tenant-scoped (4 fields): <subject>|<tenant_id>|<org_id>|<exp>

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.

Operator workflow: registering a SAML IdP

  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

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.