Skip to Content
Cardinal UIInstallationOIDC Setup

OIDC Setup

Cardinal UI delegates all authentication to an external OIDC provider. There is no built-in password login. Every user who signs in must exist in your IdP first.

How Cardinal UI uses OIDC

  • The browser performs the standard Authorization Code flow against your IdP.
  • Cardinal UI validates the resulting ID token against the provider’s JWKS.
  • The token’s email claim identifies the user. email_verified must be true.
  • The token’s sub claim is the persistent account key — it’s what Cardinal UI stores to recognise a returning user across email changes. Both the email and account-key claim names are remappable (see Claim mapping) for IdPs whose token shape differs from the Keycloak defaults.
  • Superadmin status is derived from either an OIDC group claim or an email allowlist. A user who matches either becomes a superadmin on first login.
  • Regular users start with no org membership. A superadmin (or an org owner) must place them in an org before they can do anything useful.

Required environment variables

Set these in maestro.env:

maestro: env: - name: MAESTRO_BASE_URL value: https://ui.example.com - name: OIDC_ISSUER_URL value: https://auth.example.com/realms/cardinal - name: OIDC_AUDIENCE value: maestro-ui - name: OIDC_SUPERADMIN_EMAILS value: admin@example.com,ops@example.com
VariableNotes
MAESTRO_BASE_URLMust exactly match the redirect URI registered with your IdP
OIDC_ISSUER_URLUsed for JWKS discovery ({issuer}/.well-known/openid-configuration)
OIDC_AUDIENCEThe aud claim Cardinal UI expects on incoming tokens. Defaults to maestro-ui
OIDC_CLIENT_IDOAuth client ID the SPA sends in /authorize requests. Defaults to OIDC_AUDIENCE. Set this explicitly when your IdP issues tokens whose aud differs from the client ID (e.g. Okta with a custom authorization server)
OIDC_SUPERADMIN_GROUPName of a groups claim value granting superadmin. Default maestro-superadmin
OIDC_SUPERADMIN_EMAILSComma-separated email allowlist for superadmin. Case-insensitive. Use this to bootstrap before groups are configured
OIDC_JWKS_URLOverride if your JWKS is at a non-standard path
OIDC_TRUST_UNVERIFIED_EMAILStrue treats all OIDC emails as verified. Only use with IdPs that gate email ownership in another way
OIDC_EMAIL_CLAIMToken claim to read the user’s email from. Default email
OIDC_EMAIL_VERIFIED_CLAIMToken claim to read the email-verified boolean from. Default email_verified
OIDC_DISPLAY_NAME_CLAIMSComma-separated fallback chain for the display name. Default name,preferred_username,email
OIDC_GROUPS_CLAIMToken claim to read the group array from. Default groups
OIDC_EXTERNAL_ID_CLAIMToken claim Cardinal UI uses as the persistent account key. Default sub. Changing this on an existing deployment is a user migration event — see Claim mapping

All five claim-mapping variables are optional; leaving them unset preserves the pre-existing Keycloak-shaped behavior.

Redirect URI

Register the following redirect URI with your IdP (where MAESTRO_BASE_URL is whatever you set):

https://ui.example.com/api/auth/callback

Required token claims

Cardinal UI reads these claims out of the ID token. Configure your IdP to include them (or, if the claim names differ, remap them with the OIDC_*_CLAIM env vars — see Claim mapping):

Logical claimDefault token keyRequiredPurpose
Account keysubYesPersistent account identifier. Must be stable for a given user for the life of the install
EmailemailYesPrimary user key
Email verifiedemail_verifiedYes (or set OIDC_TRUST_UNVERIFIED_EMAILS=true)Prevents email spoofing
Display namenamepreferred_usernameemailRecommendedFirst non-empty value from the chain is shown in the UI
GroupsgroupsIf using group-based superadminArray of group names

Bootstrapping the first superadmin

On a fresh install, no users exist in the database. When the first person logs in:

  1. They authenticate with your IdP as usual.
  2. Cardinal UI looks at their email (and groups, if any).
  3. If the email is listed in OIDC_SUPERADMIN_EMAILS or their groups contain OIDC_SUPERADMIN_GROUP, the user is auto-upserted with role=superadmin.
  4. Otherwise they land as a regular user with no org — and they’ll see a “contact your administrator” screen.

The practical pattern: set OIDC_SUPERADMIN_EMAILS to your own email during install so you can log in and configure the rest. Once group mappings are working end-to-end, you can remove the allowlist.

Keycloak

Cardinal UI is developed against Keycloak and the docker-compose dev stack uses it as the default IdP.

  1. Create a new realm (e.g. cardinal).
  2. Create a client:
    • Client ID: maestro-ui
    • Client type: OpenID Connect
    • Client authentication: off (public client — the UI is a browser SPA)
    • Standard flow: on
    • Valid redirect URIs: https://ui.example.com/*
    • Web origins: https://ui.example.com
  3. Under Client scopes → maestro-ui-dedicated → Add mapper, add a Group Membership mapper:
    • Token Claim Name: groups
    • Full group path: off
    • Add to ID token: on
    • Add to userinfo: on
  4. Create a group maestro-superadmin and add yourself to it.
  5. Set:
    - name: OIDC_ISSUER_URL value: https://keycloak.example.com/realms/cardinal - name: OIDC_AUDIENCE value: maestro-ui - name: OIDC_SUPERADMIN_GROUP value: maestro-superadmin

Okta

  1. Create an OIDC Single-Page Application.

  2. Sign-in redirect URI: https://ui.example.com/api/auth/callback.

  3. Add a Groups claim to the ID token — filter to the groups you care about.

  4. Assign users to a group (e.g. maestro-superadmin) and assign that group to the app.

  5. Set:

    - name: OIDC_ISSUER_URL value: https://{your-okta-domain}/oauth2/default - name: OIDC_AUDIENCE value: {the app's client ID} - name: OIDC_SUPERADMIN_GROUP value: maestro-superadmin

    If your Okta tenant uses a custom authorization server whose audience is a resource URI (e.g. api://ui) rather than the app’s client ID, split the two:

    - name: OIDC_AUDIENCE value: api://ui # the `aud` on issued tokens (the auth server's audience) - name: OIDC_CLIENT_ID value: 0oabc123ClientIdXYZ # the SPA app's client ID (used in /authorize)

    For the default Okta authorization server (no custom audience), OIDC_CLIENT_ID equals OIDC_AUDIENCE and can be omitted.

Some Okta setups don’t populate email_verified. If users hit a “your email is not verified” error and you trust Okta’s identity proofing, add:

- name: OIDC_TRUST_UNVERIFIED_EMAILS value: "true"

Okta access tokens (non-standard claim shape)

Some Okta tenants issue access tokens rather than ID tokens, with a claim layout that doesn’t match the defaults: sub carries the user’s email (not an opaque id), the stable user id lives in a custom claim like uid, and email / email_verified / name / groups may be absent entirely.

Tell Cardinal UI where to find each logical claim:

- name: OIDC_EMAIL_CLAIM value: "sub" - name: OIDC_EXTERNAL_ID_CLAIM value: "uid" - name: OIDC_DISPLAY_NAME_CLAIMS value: "name,preferred_username,sub" - name: OIDC_TRUST_UNVERIFIED_EMAILS value: "true" - name: OIDC_SUPERADMIN_EMAILS value: first-admin@example.com

A few things to know about this combination:

  • OIDC_EXTERNAL_ID_CLAIM must not resolve to the same token claim as OIDC_EMAIL_CLAIM. Cardinal UI refuses to boot if they do — account keys must be stable, and emails aren’t. The literal case (OIDC_EXTERNAL_ID_CLAIM=email) and the subtle case (OIDC_EMAIL_CLAIM=sub + OIDC_EXTERNAL_ID_CLAIM=sub) both fail fast.
  • Remapping the email claim plus OIDC_TRUST_UNVERIFIED_EMAILS=true logs a warning at startup. It’s warn-only, not fatal, because this is a legitimate Okta configuration — but the warning is worth reading. The resolved email feeds five authorization paths: superadmin allowlist matching, domain auto-join, invite acceptance, email-collision account linking, and UI display. Anything upstream that can control the remapped claim effectively controls those paths.
  • Groups may be scoped out entirely in this shape. Use OIDC_SUPERADMIN_EMAILS to bootstrap the first superadmin and manage further promotions from the admin UI.

Google Workspace

Google OIDC does not issue a groups claim out of the box. Use the email allowlist:

- name: OIDC_ISSUER_URL value: https://accounts.google.com - name: OIDC_AUDIENCE value: {your-client-id}.apps.googleusercontent.com - name: OIDC_SUPERADMIN_EMAILS value: alice@example.com,bob@example.com

Claim mapping

Cardinal UI reads five logical claims out of each token: email, email-verified, display-name chain, groups, and a persistent account key (called externalId internally, defaulted to sub). Each one can be remapped to a different token claim name via an env var. The defaults match Keycloak’s shape; operators on Okta, Auth0, Azure AD, and so on typically override one or two.

Env varMaps toDefaultNotes
OIDC_EMAIL_CLAIMemailemail
OIDC_EMAIL_VERIFIED_CLAIMemailVerifiedemail_verified
OIDC_DISPLAY_NAME_CLAIMSdisplayNamename,preferred_username,emailComma-separated fallback chain, evaluated left-to-right until a non-empty value is found
OIDC_GROUPS_CLAIMgroupsgroups
OIDC_EXTERNAL_ID_CLAIMexternalId (account key)subSingle claim; never a fallback chain

Rules the verifier enforces:

  • OIDC_EXTERNAL_ID_CLAIM and OIDC_EMAIL_CLAIM must not resolve to the same token claim. Cardinal UI refuses to boot otherwise.
  • OIDC_DISPLAY_NAME_CLAIMS rejects empty entries (e.g. "name,,email" fails at startup) so the operator intent is unambiguous.
  • If OIDC_TRUST_UNVERIFIED_EMAILS=true and OIDC_EMAIL_CLAIM is non-default, startup logs a warning listing the authorization paths that consume the resolved email. This is intentionally not fatal — legitimate Okta configs need this combination — but operators should audit upstream control of the remapped claim.

Changing OIDC_EXTERNAL_ID_CLAIM on an existing deployment is a migration event

The account key is persisted per user. Switching OIDC_EXTERNAL_ID_CLAIM on a live install means every existing user’s stored key stops matching the one in their next token:

  • Email-matching users will be silently relinked to the new key (the same path used when an IdP issues a brand-new sub for an existing email).
  • Users whose email also changed at the same time will appear as brand-new accounts, losing org memberships and roles.

Do not toggle this variable on a populated database without a planned migration. On a fresh install it’s free; after the first login it isn’t.

Troubleshooting

SymptomCause
Redirect loop after loginMAESTRO_BASE_URL doesn’t match the redirect URI registered with the IdP, or it’s behind a different hostname than the browser is using
”Invalid token audience”OIDC_AUDIENCE doesn’t match the token’s aud claim (for some IdPs this is the client ID; for others — e.g. Okta with a custom authorization server — it’s a resource identifier distinct from the client ID, in which case set OIDC_CLIENT_ID separately)
“Your email is not verified”The IdP is omitting email_verified or setting it to false. Fix at the IdP, or set OIDC_TRUST_UNVERIFIED_EMAILS=true if appropriate
Logged in but no admin menuYou’re not in OIDC_SUPERADMIN_GROUP and not in OIDC_SUPERADMIN_EMAILS. Add yourself to one of them and log out / back in
JWKS fetch errors in logsOIDC_ISSUER_URL unreachable from the pod, or the IdP’s JWKS lives outside the standard discovery path — set OIDC_JWKS_URL explicitly
OIDC_EXTERNAL_ID_CLAIM ('x') resolves to the same claim as OIDC_EMAIL_CLAIM at startupYou pointed the email and account-key to the same token claim (either directly, or via defaults + one override). Pick a different stable claim for OIDC_EXTERNAL_ID_CLAIM — see Claim mapping

Reach out to support@cardinalhq.io for support or to ask questions not answered in our documentation.

Last updated on