Skip to content

OIDC / SSO Setup Guide

How to connect a corporate identity provider (Keycloak, Okta, Azure AD / Entra ID, Google Workspace, GitLab, or any standards-compliant OIDC provider) to the platform, so users can sign in without a platform-specific password.

This is additive: password + TOTP-MFA login keeps working for every org unless you explicitly turn on "obligatory OIDC" (see §6). See ACCESS_CONTROL.md for the security design behind this feature (account-linking, JIT provisioning, MFA composition, CSRF/PKCE) — this document is the operational how-to.

0. Org-scoped or platform-wide — pick one first

There are two independent ways to configure OIDC, and most of this guide applies to both — they differ only in who owns the config and what happens to a JIT-provisioned user:

Org-scoped Platform-wide
Configured via Organizations → (expand org) → SSO / OIDC tab, or /api/orgs/{org_id}/oidc-providers System → SSO tab, or /api/system/oidc-providers
Who can configure it Org owner/admin platform_admin only
Plan gate Needs the org's sso plan feature None
JIT-provisioned user lands In that org, with a role from role mapping/default_role In no organization — global role user only; a manager assigns org/team membership afterward
"Require SSO" (disable passwords) Supported, per org Not exposed — the blast radius of "every user on the platform" needs deliberate design, not a reuse of the per-org toggle
Intended shape Several independent client organizations sharing one instance, each with its own IdP and isolation boundary (MSSP/SaaS) One company, several internal teams under one org — SSO should just work platform-wide, without registering a separate OAuth client per team

Use platform-wide if this deployment is one company (the common case) — set it up once, and every new hire signs in with the corporate IdP from day one; someone with org-admin rights sorts them into the right org/team afterward from Organizations. Use org-scoped only if you are actually running this instance for multiple separate client organizations that must stay isolated from each other (an MSSP reselling the platform, for example) — each client configures their own provider, and JIT-provisioned users can never end up in the wrong org's roster.

They can coexist: a user typing an @company.com email on the login screen sees a button for every enabled provider — org-scoped and platform-wide alike — that matches that domain. Providers with no email_domain set are catch-alls — "our whole company, any address" — and behave accordingly: platform-wide catch-alls show on the login screen immediately, org-scoped ones match any typed email or login. Typing a login without @ works too: the backend maps it to the user's stored email and matches by its domain, so operators can sign in the way they think.

The rest of this guide describes the org-scoped flow; every field and step is identical for the platform-wide path except: no default_role/role_mapping/enforced (§5, §8 don't apply), no plan-feature check, the endpoint base is /api/system/oidc-providers (not /api/orgs/{org_id}/oidc-providers), and only a platform_admin can call it.


0b. Prerequisites

  • Org-scoped: you must be owner or admin of the organization (org role, not global role — see ACCESS_CONTROL.md's org-role section), and the organization's plan must include the sso feature (team/pro tiers don't by default; enterprise does — an admin can also grant sso directly via PATCH /api/marketplace/plans/{org_id}). Without it, provider creation returns 403.
  • Platform-wide: you must be a platform_admin. No plan check.
  • Decide PUBLIC_BASE_URL (env var on the backend) before you register the redirect URI at your IdP — see §1. On a multi-node HA deployment, set the identical value on every app node — see HA_DEPLOYMENT.md. Otherwise the redirect_uri the platform sends at login time can differ from the one an IdP's callback lands on, depending on which node answers, and OAuth's exact-match requirement on redirect_uri breaks the round trip.
  • If your IdP is self-hosted on a private/internal network (not reachable from the public internet), see the allow_private_issuer note in §3 — the platform's SSRF guard blocks it by default.

1. Set PUBLIC_BASE_URL first

The platform builds the OIDC redirect_uri itself — it is never accepted from the browser or from provider config, by design (closes the open-redirect angle entirely). It is:

{PUBLIC_BASE_URL}/api/auth/oidc/{provider_id}/callback

If PUBLIC_BASE_URL is unset, the backend falls back to the incoming request's own host, which works for local testing but is not safe behind a proxy that doesn't strictly control the Host header. Set it explicitly in production:

# .env
PUBLIC_BASE_URL=https://umbryx.example.com

Restart the backend after changing it. The create dialog pre-generates the provider id (§3), so the exact callback URL is visible and copyable while you fill in the form — register it at the IdP first, then Create. Every provider row also carries its redirect URI with a copy button, so a re-registration never means hunting for ids.


2. Register the platform as a client at your IdP

Generic steps — see the vendor-specific notes below for exact menu names.

  1. Create a new OIDC / OAuth2 confidential client (not "public"/SPA — the platform holds a client_secret).
  2. Grant it the Authorization Code grant type. PKCE is always used by the platform in addition to the client secret — if your IdP has a "PKCE required" toggle, turning it on is fine and recommended.
  3. Scopes: openid, email, profile at minimum. Add a scope that exposes group/role membership (e.g. Keycloak's roles, Azure AD's groups) if you plan to use role mapping (§5).
  4. You won't have the final redirect URI yet (you need the provider's platform-side id first) — register a placeholder or come back to this step after §3. The URI shape is always: {PUBLIC_BASE_URL}/api/auth/oidc/{provider_id}/callback.
  5. Note the issuer URL, client ID, and client secret — you'll need all three for §3. The issuer URL is the base that {issuer}/.well-known/openid-configuration resolves against; for most IdPs this is printed directly on the client/application's overview page.

3. Create the provider on the platform

Via the UI: Organizations → (expand your org) → SSO / OIDC tab → + Add Provider.

Org-scoped SSO providers

The dialog shows the Callback URL (with a copy button) under the Issuer field — it is final before you even save, and each provider row later carries the same URL plus a Test sign-in button that opens the real sign-in flow in a new tab (the row's last verified sign-in confirms a working round trip).

Fill in:

Field Notes
Display name Shown on the login screen as "Sign in with {name}"
Issuer URL e.g. https://your-org.okta.com — authorization/token/userinfo/JWKS endpoints are discovered automatically from {issuer}/.well-known/openid-configuration; you never enter them by hand
Client ID / Client secret From §2. The secret is encrypted at rest and is never shown again after creation
Email domain Optional — lets the login screen suggest this provider when someone types an @your-org.com email, before they've authenticated
Default role Org role (viewer, analyst, operator, …) assigned to a user with no matching group mapping (§5)
JIT provisioning See §4
Require platform MFA See §6
Allow private issuer Off by default — turn on only for a self-hosted IdP on an internal network (see below). Platform-admin only, even on the org-scoped form.

Or via API:

curl -X POST https://umbryx.example.com/api/orgs/$ORG_ID/oidc-providers \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{
    "name": "Corp Okta",
    "issuer_url": "https://your-org.okta.com",
    "client_id": "0oa1b2c3d4e5",
    "client_secret": "...",
    "email_domain": "your-org.com",
    "default_role": "viewer",
    "jit_provisioning_enabled": true,
    "require_platform_mfa": false
  }'

Platform-wide equivalent (drop default_role/role_mapping, no $ORG_ID, needs a platform_admin session):

curl -X POST https://umbryx.example.com/api/system/oidc-providers \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{
    "name": "Corp GitLab",
    "issuer_url": "https://gitlab.your-company.com",
    "client_id": "...",
    "client_secret": "...",
    "email_domain": "your-company.com",
    "jit_provisioning_enabled": true,
    "require_platform_mfa": false
  }'

If discovery fails (400 OIDC discovery failed: ...), double-check the issuer URL — it must be the exact base your IdP's .well-known/openid-configuration document lives under, with no trailing path segments. The issuer (and everything discovery finds — token/JWKS/userinfo endpoints) is checked against the platform's SSRF guard; an issuer that resolves to a private/internal address is rejected by default.

Self-hosted IdP on a private network: if your GitLab/Keycloak/etc. is only reachable via internal DNS and resolves to an RFC1918 address (this is normal for an on-prem IdP behind a VPN or office firewall), discovery fails with Target hostname resolves to private/internal address. Set "allow_private_issuer": true in the create/update payload (or the checkbox in the UI form) to opt this specific provider out of the SSRF guard. This is deliberately a narrow, per-provider override rather than a global setting: an org admin choosing where the platform's outbound requests go is exactly the trust boundary the guard exists for on a multi-tenant instance, so setting it always requires a platform_admin session even when creating/updating an org-scoped provider through /api/orgs/{org_id}/oidc-providers — an org owner/admin alone gets a 403 if they try. Also check your DNS resolution path if discovery fails in an unexpected way even after setting this: a container's outbound DNS resolver matters — if it's using public DNS while your IdP's hostname is only resolvable (or resolves differently, e.g. via split-horizon DNS) on your internal resolver, discovery can fail or silently hit the wrong host. docker-compose.yml's backend.dns service takes DNS_SERVER_1/DNS_SERVER_2 env vars for exactly this — point them at your internal resolver if requests need to reach split-horizon internal hostnames correctly.

The response includes the new provider's id. Now go back to your IdP (§2 step 4) and set the real redirect URI:

{PUBLIC_BASE_URL}/api/auth/oidc/{provider_id}/callback

4. New vs. existing users (JIT provisioning and account linking)

  • JIT provisioning ON: a first-time sign-in from someone with no matching platform account auto-creates one — global role is always user (never platform_admin); org-scoped providers additionally join that org with a role from the group/role mapping (§5) or default_role if unmapped/unmatched, while platform-wide providers join no org at all (see §0). The new account's username comes from the OIDC-standard preferred_username claim if the IdP sends one (GitLab, Keycloak, and Okta all do) — falling back to the email local-part only if it doesn't. This matters because two people whose email both start with the same local-part (e.g. two admin@ addresses across different systems, or just common first-name collisions) would otherwise both fight over the same base username and get an arbitrary name2/name3 suffix, even though the IdP already had a perfectly good, actually-unique handle.
  • JIT provisioning OFF: a first-time sign-in from someone with no matching account is rejected ("no account found and self-signup is disabled") — use this if you want to pre-provision accounts yourself (e.g. via POST /api/orgs/{org_id}/users) and only let already- known people through SSO.
  • Existing account with the same email: never auto-linked, regardless of the JIT setting. The user is shown a confirmation screen and must enter that account's actual password once to link it — this is deliberate: without it, anyone who can get any email accepted as legitimate at a less-trusted IdP could otherwise take over an existing account by matching its email.

5. Mapping groups/roles to org roles (optional)

If your IdP sends a groups/roles claim (Keycloak: roles by default under a custom mapper; Azure AD: groups — needs the Group Claims option turned on in the app registration; Okta: configure an groups claim on the authorization server), you can map specific values to org roles so JIT- provisioned (and newly linked) users land with the right permissions immediately:

{
  "claim_mapping": {"email": "email", "name": "name", "groups": "groups"},
  "role_mapping": {"platform-admins": "admin", "analysts": "analyst"},
  "default_role": "viewer"
}

claim_mapping only needs entries for claims your IdP names differently than the OIDC standard (email, name, groups) — omit anything that already matches.

A group present in the token but not listed in role_mapping never grants anything beyond default_role — there's no "give them something reasonable" fallback for unrecognized groups, by design.


6. Platform MFA on top of OIDC

Corporate IdPs usually already enforce their own MFA — whether the platform's own TOTP-MFA is also required after a successful OIDC sign-in is a per-provider toggle (require_platform_mfa), not a global policy:

  • Off (default): a valid OIDC sign-in is sufficient — the IdP is trusted to have done its own MFA.
  • On: after OIDC succeeds, a user with platform MFA already enabled on their account (Settings → Security → Enable MFA) is sent through the normal /api/auth/mfa/verify step before getting a session — same mechanism as password login's MFA step, not a separate one. A user who hasn't set up platform MFA yet is not blocked by this toggle — it only adds a second factor for accounts that already opted into one.

7. Testing before you rely on it

Just sign in through it once — from the login screen, either enter your @your-domain.com email and click the suggested "Sign in with {name}" button, or navigate directly to {PUBLIC_BASE_URL}/api/auth/oidc/{provider_id}/login. Any full successful round trip (any user, not a special "test mode") records a verified timestamp on the provider — visible in the SSO/OIDC admin tab as "last verified sign-in: ...".

This verified timestamp is a hard requirement for §8 — you cannot skip this step.


8. Making SSO mandatory ("obligatory OIDC")

Turning on Require SSO (disable passwords) for a provider rejects password login for every member of that organization from then on (platform_admin accounts are always exempt, so the platform itself can never be locked out).

The toggle is refused with a 400 until §7's verified sign-in has happened at least once through that exact provider — this is intentional, not a bug: it stops a broken configuration (wrong client secret, unreachable issuer, wrong redirect URI at the IdP) from locking out every org member simultaneously the moment you flip the switch.

If you need to roll back — e.g. the IdP is down and nobody can sign in — turn the toggle back off via the API (or ask a platform admin, who can always reach the org regardless) to restore password login immediately:

curl -X PATCH https://umbryx.example.com/api/orgs/$ORG_ID/oidc-providers/$PROVIDER_ID \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"enforced": false}'

Vendor-specific notes

IdP Issuer URL shape Where to find it
Keycloak https://{host}/realms/{realm} Realm settings → General → Endpoints → OpenID Endpoint Configuration (issuer is the base, strip /.well-known/...)
Okta https://{your-org}.okta.com or https://{your-org}.okta.com/oauth2/{authServerId} if using a custom authorization server Security → API → Authorization Servers
Azure AD / Entra ID https://login.microsoftonline.com/{tenant-id}/v2.0 App registration → Endpoints → OpenID Connect metadata document (issuer is the base)
Google Workspace https://accounts.google.com Fixed, same for every Google Workspace tenant
GitLab (gitlab.com or self-hosted) https://gitlab.example.com (self-hosted) or https://gitlab.com User Settings → Applications (or Admin Area → Applications for an instance-wide client) — create an application, grant it the openid, email, profile scopes, mark it Confidential. GitLab sends preferred_username as the actual GitLab login (see §4) and groups/groups_direct if you need role mapping.

Group/role claims are the one thing that varies most:

  • Keycloak: add a "Group Membership" or "User Realm Role" mapper to the client's dedicated scope, name the claim groups (or set claim_mapping.groups to whatever you name it).
  • Azure AD: App registration → Token configuration → Add groups claim. Azure sends group object IDs, not names, by default — map those IDs in role_mapping, or switch the claim to emit group display names if your tenant supports it.
  • Okta: Security → API → Authorization Servers → your server → Claims → add a groups claim filtered to the groups you want to expose.
  • Google Workspace: group claims require the Cloud Identity/Admin SDK and aren't exposed via plain OIDC scopes — role mapping is generally not practical with Google; use default_role and manage org roles manually instead.

Troubleshooting

Symptom Likely cause
OIDC discovery failed on create/update Issuer URL wrong, or the IdP's discovery endpoint isn't reachable from the backend (check PUBLIC_BASE_URL/network egress — outbound requests go through the platform's SSRF guard, so a URL resolving to a private/internal address is rejected)
Target hostname resolves to private/internal address The issuer (or a discovered endpoint) resolves to an RFC1918/loopback/link-local address — either it's genuinely a self-hosted internal IdP (set allow_private_issuer, see §3 — needs platform_admin) or the backend's DNS resolver is returning the wrong (public) address for a split-horizon hostname (check DNS_SERVER_1/DNS_SERVER_2)
redirect_uri mismatch at the IdP, or callback lands on the wrong node in an HA deployment PUBLIC_BASE_URL isn't set identically on every app node — see §0b
Login redirects back with oidc_error=... in the URL fragment The fragment message is human-readable; check the backend audit log (oidc.failed events) for the underlying reason (state_expired, state_cookie_mismatch, an IdP-side error, etc.)
"No account found and the organization does not allow self-signup" JIT provisioning is off and no existing account matches the login's email — either enable JIT or pre-create the account
New JIT user has the wrong role Check role_mapping against the actual claim value the IdP sends — log the raw claims once (temporarily) if unsure, since group claim values (especially Azure AD object IDs) rarely match what's shown in the IdP's admin UI
Can't enable "Require SSO" Complete one successful sign-in through that exact provider first — this can't be skipped (§7/§8)