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:
edssa-ssocrate — trait + types + registry only. Nosamael/openidconnectcompile dep; concrete providers live in sibling crates behind cargo features.edssa-panelSSO routes —/sso/:provider_ref/start+/sso/:provider_ref/callbackconsume the registry; the nonce ceremony state lives in panelAppState.- Concrete SAML / OIDC providers —
samael-provider/openidconnect-providercargo 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:
- Embed the panel’s
state_noncein the IdP’s state parameter (SAMLRelayState, OIDCstate). - Surface the same value back through the IdP callback into
CompleteLoginRequest::state_nonce. - Return
AuthError::InvalidNonceon 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
| Route | Behaviour |
|---|---|
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/callback | Body: { 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:
- Reads
state_noncefrom the request body. - Consumes the nonce from
SsoFlows(single-use; replay protection). - Verifies the callback’s
:provider_refmatches what/startstashed alongside the nonce — defence in depth against shuttling a stolen nonce across providers. - Re-resolves the provider (an operator could have removed the
IdP between
/startand/callback). - Base64-decodes
callback_data. - Calls
complete_login. - 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
- Operator obtains the IdP’s metadata (Okta / Azure AD / ADFS admin UI).
- Operator configures the provider via the
samael-providercargo feature’s bootstrap config (when that ships in a future batch). - Panel registers the provider at process startup:
registry.register(Arc::new(SamlProvider::new(...)))?. - Operator updates the org’s
Organization::sso_provider_refin the tenant store seed JSON. - 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 variant | HTTP status | Body | Audit |
|---|---|---|---|
InvalidNonce | 401 | empty | yes |
InvalidCallback(reason) | 401 | empty | yes (reason in tracing log only — IdP-error-message-fingerprinting prevention) |
InvalidProtocol(reason) | 400 | empty | yes |
Network(reason) | 502 | empty | yes |
Internal(reason) | 500 | empty | yes (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.