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 providers — cargo features on
edssa-sso. The two OIDC ones are on in the panel build today (oidc-jwt-provider,oidc-code-exchange-provider, plusoidc-discoveryandjwks-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 concretesamaelprovider 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:
- 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). 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/callback | JSON 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:
- 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: 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.
/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_basicby 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_postselectable 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
groupsarray; pointing it at an IdP-specific claim also handles object-shaped values by taking their keys. For Zitadel, seturn: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:
- 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
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 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.