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
emailclaim identifies the user.email_verifiedmust betrue. - The token’s
subclaim 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| Variable | Notes |
|---|---|
MAESTRO_BASE_URL | Must exactly match the redirect URI registered with your IdP |
OIDC_ISSUER_URL | Used for JWKS discovery ({issuer}/.well-known/openid-configuration) |
OIDC_AUDIENCE | The aud claim Cardinal UI expects on incoming tokens. Defaults to maestro-ui |
OIDC_CLIENT_ID | OAuth 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_GROUP | Name of a groups claim value granting superadmin. Default maestro-superadmin |
OIDC_SUPERADMIN_EMAILS | Comma-separated email allowlist for superadmin. Case-insensitive. Use this to bootstrap before groups are configured |
OIDC_JWKS_URL | Override if your JWKS is at a non-standard path |
OIDC_TRUST_UNVERIFIED_EMAILS | true treats all OIDC emails as verified. Only use with IdPs that gate email ownership in another way |
OIDC_BEARER_TOKEN | Which token the browser sends as its bearer: access (default) or id. Set id for IdPs whose access tokens carry no aud or email, such as Amazon Cognito |
OIDC_EMAIL_CLAIM | Token claim to read the user’s email from. Default email |
OIDC_EMAIL_VERIFIED_CLAIM | Token claim to read the email-verified boolean from. Default email_verified |
OIDC_DISPLAY_NAME_CLAIMS | Comma-separated fallback chain for the display name. Default name,preferred_username,email |
OIDC_GROUPS_CLAIM | Token claim to read the group array from. Default groups |
OIDC_EXTERNAL_ID_CLAIM | Token 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 site root, with the trailing slash, as both the sign-in redirect URI and the post-logout redirect URI (where MAESTRO_BASE_URL is whatever you set):
https://ui.example.com/The SPA handles the callback itself; there is no /api/auth/callback route.
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 claim | Default token key | Required | Purpose |
|---|---|---|---|
| Account key | sub | Yes | Persistent account identifier. Must be stable for a given user for the life of the install |
email | Yes | Primary user key | |
| Email verified | email_verified | Yes (or set OIDC_TRUST_UNVERIFIED_EMAILS=true) | Prevents email spoofing |
| Display name | name → preferred_username → email | Recommended | First non-empty value from the chain is shown in the UI |
| Groups | groups | If using group-based superadmin | Array of group names |
Bootstrapping the first superadmin
On a fresh install, no users exist in the database. When the first person logs in:
- They authenticate with your IdP as usual.
- Cardinal UI looks at their
email(andgroups, if any). - If the email is listed in
OIDC_SUPERADMIN_EMAILSor their groups containOIDC_SUPERADMIN_GROUP, the user is auto-upserted withrole=superadmin. - 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.
- Create a new realm (e.g.
cardinal). - 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
- Client ID:
- 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
- Token Claim Name:
- Create a group
maestro-superadminand add yourself to it. - 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
-
Create an OIDC Single-Page Application.
-
Sign-in redirect URI and sign-out redirect URI:
https://ui.example.com/. -
Add a Groups claim to the ID token — filter to the groups you care about.
-
Assign users to a group (e.g.
maestro-superadmin) and assign that group to the app. -
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-superadminIf 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_IDequalsOIDC_AUDIENCEand 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.comA few things to know about this combination:
OIDC_EXTERNAL_ID_CLAIMmust not resolve to the same token claim asOIDC_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=truelogs 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_EMAILSto 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.comAmazon Cognito
Cognito access tokens carry no aud, azp or email claim, so Cardinal UI must send the ID token instead. Set OIDC_BEARER_TOKEN=id.
-
In the user pool, create an app client: public (no client secret), authorization code grant with PKCE.
-
Allowed callback URL and sign-out URL:
https://ui.example.com/. -
OpenID Connect scopes:
openid,profile,email. -
For group-based superadmin, create a user pool group
maestro-superadminand add yourself. Group names arrive in thecognito:groupsclaim. -
Set:
- name: OIDC_ISSUER_URL value: https://cognito-idp.{region}.amazonaws.com/{user-pool-id} - name: OIDC_JWKS_URL value: https://cognito-idp.{region}.amazonaws.com/{user-pool-id}/.well-known/jwks.json - name: OIDC_AUDIENCE value: {app client id} - name: OIDC_BEARER_TOKEN value: id - name: OIDC_GROUPS_CLAIM value: cognito:groupsOIDC_CLIENT_IDdefaults toOIDC_AUDIENCE, so it can be omitted. LeaveOIDC_SUPERADMIN_GROUPunset: setting it makes the browser request agroupsscope, which Cognito does not offer. The superadmin group already defaults tomaestro-superadmin, and Cognito includescognito:groupsin the ID token without any extra scope.
This is also the route for Microsoft Entra ID when a Keycloak broker is not an option: federate Entra into the Cognito user pool (Sign-in experience → Federated identity provider sign-in → OpenID Connect) and point Cardinal UI at Cognito. Entra cannot be the issuer directly, because the access token it mints is for Microsoft Graph and cannot be verified by anyone else.
Requires maestro v1.128 or later (OIDC_BEARER_TOKEN).
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 var | Maps to | Default | Notes |
|---|---|---|---|
OIDC_EMAIL_CLAIM | email | ||
OIDC_EMAIL_VERIFIED_CLAIM | emailVerified | email_verified | |
OIDC_DISPLAY_NAME_CLAIMS | displayName | name,preferred_username,email | Comma-separated fallback chain, evaluated left-to-right until a non-empty value is found |
OIDC_GROUPS_CLAIM | groups | groups | |
OIDC_EXTERNAL_ID_CLAIM | externalId (account key) | sub | Single claim; never a fallback chain |
Rules the verifier enforces:
OIDC_EXTERNAL_ID_CLAIMandOIDC_EMAIL_CLAIMmust not resolve to the same token claim. Cardinal UI refuses to boot otherwise.OIDC_DISPLAY_NAME_CLAIMSrejects empty entries (e.g."name,,email"fails at startup) so the operator intent is unambiguous.- If
OIDC_TRUST_UNVERIFIED_EMAILS=trueandOIDC_EMAIL_CLAIMis 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
subfor 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
| Symptom | Cause |
|---|---|
| Redirect loop after login | MAESTRO_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 menu | You’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 logs | OIDC_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 startup | You 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.