Single sign-on setup
Praxis authenticates single sign-on users with the OIDC Authorization Code
flow against a confidential client. Any provider that publishes a standard
discovery document (/.well-known/openid-configuration) works; this guide uses
Keycloak as the worked example and then gives a generic checklist for other
providers (Okta, Azure AD, Auth0, Google Workspace, …).
Operators register providers in the UI at Settings → Admin → OIDC Providers. This document covers the deployment-side prerequisites (URLs, environment variables, and the claims your IdP must emit) that the UI form can’t set for you.
How it works
- A user clicks the provider’s Sign in button, which sends the browser to
Praxis at
/api/backend/auth/oidc/login. - Praxis generates a
stateandnonce, stores them, and redirects the browser to your IdP’s authorization endpoint (response_type=code, your client ID, the exact redirect URI, scopeopenid email profile). - After the user authenticates, the IdP redirects back to Praxis at
/api/backend/auth/oidc/callbackwith an authorizationcode. - Praxis exchanges the code for tokens, validates the ID token, maps its
role claim to a Praxis role, provisions/updates the user, and redirects the
browser to
/loginwith short-lived Praxis tokens in the URL fragment (which the frontend swaps for httpOnly cookies). On failure it redirects to/login?oidc_error=<code>with a non-sensitive error code.
Praxis URLs and environment variables
Praxis sits behind a frontend proxy: the browser-facing path
/api/backend/auth/oidc/callback is proxied to the backend. The IdP always
talks to the public URL, so the redirect URI you register must be the
public one.
| Variable | Purpose | Default |
|---|---|---|
PUBLIC_BASE_URL | The browser-facing Praxis origin. Used to build the redirect URI and the post-login return URL. | https://localhost |
OIDC_REDIRECT_URI | Optional. Pin the exact redirect URI to register with the IdP. When unset, Praxis derives it from PUBLIC_BASE_URL. | (derived) |
-
Set
PUBLIC_BASE_URLto your real external origin in production, e.g.https://praxis.example.com. If it still points atlocalhost, the redirect URI and the post-login return URL will be wrong and SSO will fail. -
When
OIDC_REDIRECT_URIis unset, the redirect URI is:PUBLIC_BASE_URL+/api/backend/auth/oidc/callback. -
The redirect URI to register with your IdP for a normal production deployment is therefore:
https://<praxis-host>/api/backend/auth/oidc/callback -
Set
OIDC_REDIRECT_URIexplicitly when your external URL differs from what Praxis would derive (extra path prefix, a load balancer rewriting the host, etc.). Praxis re-uses the exact redirect URI it sent at authorize time when it exchanges the code, so the registered value,OIDC_REDIRECT_URI(if set), and the derived value must all agree.
The Praxis backend must be able to reach the IdP’s discovery and JWKS URLs directly (server-to-server), separate from the browser reaching the IdP. In segmented networks, allow backend egress to your IdP.
Keycloak walkthrough
- Realm — create or select the realm your users live in.
- Client — create a client:
- Client type: OpenID Connect.
- Client authentication: ON (this makes it a confidential client with a secret — required).
- Standard flow: ENABLED (this is the Authorization Code flow). Direct access grants / implicit are not needed.
- Valid redirect URIs — add your exact Praxis redirect URI:
https://<praxis-host>/api/backend/auth/oidc/callback. - Web origins — add your Praxis origin (
https://<praxis-host>) so the browser exchange is allowed. - Credentials — copy the client secret and paste it into the Praxis
provider form along with the client ID and the realm’s issuer URL
(
https://<keycloak-host>/realms/<realm>). - Identity claims — make sure
email,preferred_username, and profile claims are present in the token (the defaultemailandprofileclient scopes cover this). - Emit roles into the ID token — this is the step people miss. Add a
mapper that puts the user’s roles into the ID token:
- Realm roles: add a User Realm Role mapper. Roles land at
realm_access.roles. - Client roles: add a User Client Role mapper for this client. Roles
land at
resource_access.<client-id>.roles. - On the mapper, enable “Add to ID token.” Keycloak’s role mappers put roles in the access token by default; Praxis validates the ID token, so the roles must be added there too.
- Realm roles: add a User Realm Role mapper. Roles land at
- Role claim path in Praxis — set the provider’s role claim to the
dotted path you chose above (
realm_access.rolesorresource_access.<client-id>.roles), and map the emitted values to Praxis roles (see Role mapping).
Generic OIDC provider checklist
For any non-Keycloak provider, confirm:
- Discovery URL — the provider publishes
/.well-known/openid-configuration. Praxis reads theissuer, authorization endpoint, token endpoint, andjwks_urifrom it. - Confidential client using the Authorization Code flow
(
response_type=code) with a client secret. - Redirect URI registered with an exact string match to
https://<praxis-host>/api/backend/auth/oidc/callback(or yourOIDC_REDIRECT_URI). - Scopes —
openid email profileat minimum. Add whatever provider-specific scope is required to include roles/groups in the token. - Issuer and JWKS reachable from the Praxis backend.
- Role claim in the ID token — the claim you point Praxis at must be present in the ID token, not only in the access token or the userinfo response.
- Identity claims —
sub(stable subject), plusemail/preferred_usernamefor a usable account. - Clock sync — the IdP and Praxis hosts should agree on time (NTP); token
exp/iat/nbfandnoncechecks are time-sensitive.
Role mapping
Praxis reads the configured role claim from the ID token and turns its values into Praxis roles. The claim path is dotted, so a nested claim is addressed by its full path:
realm_access.roles— Keycloak realm roles.resource_access.<client-id>.roles— Keycloak client roles.- A top-level claim like
rolesalso works (a single unqualified segment). If no role claim is configured, Praxis looks for a top-levelrolesclaim.
Each value found at that path is resolved to a Praxis role only through the provider’s explicit role mapping allowlist:
- Configure a role mapping — a JSON map of IdP value → Praxis role, e.g.
{"praxis-admins": "admin", "praxis-ops": "maintainer"}. Only claim values present as keys in this map, resolving to a real Praxis role, grant that role. - There is no direct pass-through. An IdP claim value of
admin,maintainer, orauditorgrants nothing on its own — the value must be allowlisted in the mapping (map"admin": "admin"explicitly if your IdP really does emit Praxis role names and you trust them). This prevents a hostile or misconfigured IdP from handing itself Praxis admin via aroles: ["admin"]claim.
Roles that can be mapped: admin, maintainer, auditor, and
viewer. A user can receive more than one; the effective permission is the
union.
Default when no role maps: if the role claim is missing, or none of its values map to a Praxis role, the user is provisioned as
auditor(read-only). Configure your mapping so privileged users don’t silently land read-only — and so unknown users get the least privilege by default.
Account provisioning and linking
Praxis binds an SSO login to the stable (issuer, sub) identity from the ID
token — never to a matching username or email. This is deliberate and fails
closed to prevent account takeover:
- Returning users are matched only by
(issuer, sub). If that user is disabled in Praxis, login is refused (no tokens are issued). - New users are created only when the IdP asserts a verified email
(
email_verified: truein the ID token). A login without a verified email cannot create an account. - No auto-linking. If a new OIDC subject’s username or email collides with an existing Praxis account (e.g. a local admin), the login fails — Praxis never rewrites the existing account into an OIDC-linked one. To let an existing operator sign in via SSO, an admin must reconcile that account deliberately; a matching email claim alone is not accepted as proof of ownership.
Callback failures surface a generic sso_failed (or invalid_state) to the
browser; the specific reason is logged server-side.
Security and validation behavior
- State and nonce are generated per login attempt, stored server-side with a
10-minute TTL, and are single-use: the callback consumes the
staterow atomically, so a replayed or concurrent callback with the samestatefails. An expired state is rejected. - ID token validation checks, in one step:
- the issuer matches the discovery document’s
issuer, - the audience matches your client ID,
- the signature verifies against the matching JWKS key (
kid, RS256), - the
noncematches the one issued at login.
- the issuer matches the discovery document’s
at_hash— providers that include anat_hashclaim in the ID token (Keycloak does) are validated correctly, because Praxis passes the access token into ID-token validation so theat_hashbinding can be checked.
Troubleshooting
invalid_redirect_uri/ the IdP rejects the redirect — the redirect URI registered at the IdP doesn’t exactly match what Praxis sent. ConfirmPUBLIC_BASE_URL(orOIDC_REDIRECT_URI) resolves tohttps://<praxis-host>/api/backend/auth/oidc/callbackand that the IdP has that exact string, scheme and all.- SSO logs in but the user is only
auditor/ has no privileges — the role claim almost certainly isn’t in the ID token. Roles that appear only in the access token or the userinfo response are not seen by Praxis. In Keycloak, enable “Add to ID token” on the role mapper; on other providers, add the role/group claim to the ID token. Also confirm the Praxis role claim path matches where the roles actually land (realm_access.rolesvsresource_access.<client-id>.roles). Invalid issuer/ signature or key errors — the issuer in the token doesn’t match the discovery document, or the backend can’t fetch the current JWKS. Verify the issuer URL and that the backend can reach the JWKS endpoint.- Token expired /
nonce/iaterrors that come and go — clock skew. Ensure the IdP and Praxis hosts are NTP-synced. - Login fails only after an upgrade, with an
at_hasherror — very old builds validated the ID token without the access token and could reject Keycloak tokens carryingat_hash. Current builds pass the access token into validation; make sure you’re on a current build. - Backend can’t reach discovery/JWKS — the browser reaching the IdP is not enough; the Praxis backend must reach the IdP’s discovery and JWKS URLs. Open backend egress to the IdP.
- Everything 404s / redirect returns to the wrong place —
PUBLIC_BASE_URLdoesn’t match the origin the browser actually uses, or the proxy in front of Praxis isn’t forwarding/api/backend/...to the backend. Fix the public origin and proxy routing.
See also
- Production Hardening — deployment shapes and environment validation.
- In-app help: Settings → Admin documents the OIDC provider form and the role-claim mapping note.