How Versus delegates every interactive sign-in to an OIDC identity provider — a standard authorization-code-plus-PKCE relying party that discovers the IdP, verifies RS256 ID tokens against the published JWKS, maps org and role claims onto tenants and RBAC, and ends the IdP session on logout.
Versus delegates interactive sign-in to a dedicated OIDC identity provider rather than storing and checking passwords itself. The application core is a standard OIDC relying party (RP): it discovers the IdP through the well-known metadata endpoint, runs the authorization-code flow with PKCE, verifies the returned RS256 ID token against the IdP's published signing keys, and only then mints its own session tokens. Because the RP is standards-based, the same code works whether the IdP is bundled with your Versus deployment or is your own corporate identity provider — only the issuer URL changes.
This page covers what the OIDC login does under the hood, how organizations and roles flow from the IdP into Versus tenants and RBAC, and how an operator turns it on.
Centralizing identity in a single OIDC provider means one place — not the application — owns sessions, multi-factor policy, and account lifecycle. Versus simply trusts the tokens the IdP issues. That gives you:
The identity layer is described from the platform's perspective in Platform Architecture; this page is the operator- and integrator-level detail.
The RP exposes four routes under /api/auth/oidc/. They are inert until configured — with no issuer set, enabled reports false and the login/callback routes return 503, so wiring them has no effect on existing password or SAML login paths.
| Method & path | Purpose |
|---|---|
GET /api/auth/oidc/enabled | Public probe so the login page can decide whether to show the SSO button. Returns `{ "enabled": true |
GET /api/auth/oidc/login | Builds the authorize URL (auth-code + PKCE), sets a short-lived signed transaction cookie, and 302-redirects the browser to the IdP. |
GET /api/auth/oidc/callback | Validates state, exchanges the code for tokens over the back channel, verifies the ID token, provisions the user, and hands session tokens to the SPA. |
GET /api/auth/oidc/logout | RP-initiated single logout — ends the IdP session, then returns the browser to the app. |
These sit alongside the platform's other authentication routes (/api/auth/token/ for direct JWT login and /api/auth/totp/* for TOTP) rather than replacing them; which one a deployment uses is a configuration choice.
/api/auth/oidc/loginThe RP fetches the IdP's discovery document ({issuer}/.well-known/openid-configuration, cached per process) to find the authorization_endpoint, then constructs a standard authorization-code request:
state, nonce, and PKCE code_verifier are generated. The verifier is hashed with SHA-256 into a code_challenge sent with code_challenge_method=S256 — so the code can only be redeemed by the party that started the flow.openid profile email plus a provider scope that surfaces the user's organization, so Versus can map the login onto a tenant.state, nonce, the verifier, and the host the user started on are sealed into a signed, HttpOnly oidc_txn cookie (10-minute lifetime) — never exposed to the browser's scripts.The browser is then redirected to the IdP to authenticate and satisfy MFA.
/api/auth/oidc/callbackWhen the IdP redirects back with a code and state, the RP:
oidc_txn cookie and rejects the request if it is missing/expired or if state doesn't match — closing CSRF and stale-session gaps.token_endpoint over a back-channel (server-to-server) POST, sending the PKCE code_verifier — and a client secret if the app is registered as a confidential client.kid, selects the matching key from the discovered JWKS, and validates the RS256 signature, issuer, and audience. If the kid isn't in the cached key set (a key rotation), the JWKS is refreshed once and re-checked. Finally it confirms the token's nonce matches the one from the transaction cookie.sub becomes the stable Django username; email, given_name, and family_name are captured so the app shows a real name rather than the raw subject. A disabled account is refused./sso-callback route.At no point does the application see the user's password — only a signed assertion from the IdP that authentication (and MFA) succeeded.
/api/auth/oidc/logoutClearing the app's own tokens is not enough: the IdP session would survive and silently sign the user back in on the next SSO redirect. So logout is RP-initiated: the RP hits the IdP's end_session_endpoint, passing the stored ID token as a logout hint, then returns to the app. Because the app is SSO-only, the user lands on the IdP login form — genuinely signed out.
The callback doesn't just authenticate — it reconciles the token's claims with the Versus authorization model.
Versus is multi-tenant, and each tenant is 1:1 with an organization in the IdP. The Tenant record carries an idp_mode (versus_idp, client_oidc, or client_saml) and links to its IdP organization. On login, the RP extracts an org code from the token — preferring an explicit org claim, otherwise deriving it from the organization's primary domain — and ensures a TenantMembership exists for the user in the matching tenant. The user's default membership becomes a tenant_id claim embedded in the minted JWT, which the API reads back per request to scope every tenant-aware query. Unknown organizations are ignored (self-service provisioning is out of scope for the RP), and single-tenant/on-prem deployments carry no org claim at all — the whole mechanism is a no-op there.
If the token asserts project roles, the RP maps them onto the platform's built-in RBAC roles and picks the highest one:
| IdP role key | Versus RBAC role |
|---|---|
viewer | Viewer |
operator | Operator |
tenant_admin | Network Admin (plus the tenant-admin membership flag) |
Role sync is idempotent and fail-open: a token that carries no recognized role never downgrades a user, and any error in role mapping is swallowed so it can't block sign-in. The top platform role (Super Admin) is deliberately never granted from a tenant token — it is only assigned to a short, explicitly configured admin email allowlist, so a tenant can never mint itself platform-wide power.
In multi-tenant hosted deployments, each organization reaches the platform at its own subdomain (<org-code>.<base-domain>), and the Host header is the tenant selector:
auth, api, app, admin, login, and others) can never resolve to an organization.This entire layer is inert unless a platform base domain is configured, so localhost and single-tenant on-prem installs behave exactly as before. Data is always token-scoped regardless — the subdomain check is URL integrity, not the security boundary itself.
Multi-factor authentication is enforced at the IdP, not the application. Because Versus trusts only a signed ID token that the IdP issues after its own login and MFA challenge, MFA policy — TOTP, passkeys, and step-up rules — lives in one place and applies uniformly to every Versus workspace. The application never has to reimplement it. (For automation and service accounts that call the API directly with a JWT, the platform also supports a TOTP challenge as part of obtaining a token — see Running Workflows via the API.)
The RP is driven entirely by environment configuration; there is nothing to migrate. Set the issuer and client, and the SSO button appears on the login page.
# Required — enables OIDC login
OIDC_ISSUER="https://idp.example.com/org-issuer"
OIDC_CLIENT_ID="your-versus-app-client-id"
# Confidential clients only
OIDC_CLIENT_SECRET="••••••••"
# Optional — override defaults
OIDC_SCOPES="openid profile email" # extra provider scopes appended automatically
OIDC_REDIRECT_URI="https://app.example.com/api/auth/oidc/callback"
OIDC_POST_LOGOUT_REDIRECT_URI="https://app.example.com/"
OIDC_ORG_CODE_CLAIM="org_code" # claim to read the tenant org code from
OIDC_ROLES_CLAIM="…project:roles" # claim to read project roles from
OIDC_ADMIN_EMAILS="[email protected]" # allowlist granted Super Admin on login
# Multi-tenant hosted deployments only
PLATFORM_BASE_DOMAINS="example.com" # enables subdomain-as-tenant routing
On the IdP side, register Versus as an OIDC application (public with PKCE, or confidential with a secret), and add both the callback (OIDC_REDIRECT_URI) and the post-logout URL to the app's registered redirect URIs.
# Confirm the RP is live
GET /api/auth/oidc/enabled
200 OK
{ "enabled": true }
Because discovery, key retrieval, and issuer/audience validation are all standards-based, any conformant OIDC provider works — Versus does not hard-code a vendor.
Customer-built applications on the Versus App Builder layer can carry their own SSO configuration (AppSSOConfig) independent of the platform login — selecting OIDC/OAuth2 or SAML 2.0, with the discovery URL, client credentials, scopes, and attribute mappings for email, name, and groups stored per app. This lets a bespoke app federate to a different IdP than the platform itself when the business case calls for it.
| Property | How it's achieved |
|---|---|
| No password store in the app | Authentication is delegated entirely to the OIDC IdP. |
| Authorization-code interception resistance | PKCE (S256) — the code is useless without the verifier held server-side. |
| CSRF / replay protection | Signed, HttpOnly transaction cookie binding state + nonce; both are re-checked at callback. |
| Token authenticity | RS256 signature verified against the IdP's discovered JWKS, with issuer and audience enforced and automatic key-rotation refresh. |
| Least privilege by default | Roles mapped from token claims; Super Admin only via an explicit email allowlist, never from a tenant token. |
| Tenant isolation | Org claim → TenantMembership → tenant_id JWT claim, cross-checked against the request subdomain. |
| Clean sign-out | RP-initiated single logout ends the IdP session, not just the local one. |