OIDC Setup
Maestro 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 Maestro uses OIDC
- The browser performs the standard Authorization Code flow against your IdP.
- Maestro 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 Maestro 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://maestro.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 Maestro 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_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 Maestro 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://maestro.example.com/api/auth/callbackRequired token claims
Maestro 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.
- Maestro 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
Maestro 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://maestro.example.com/* - Web origins:
https://maestro.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:
https://maestro.example.com/api/auth/callback. -
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://maestro) rather than the app’s client ID, split the two:- name: OIDC_AUDIENCE value: api://maestro # 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 Maestro 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. Maestro 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.comClaim mapping
Maestro 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. Maestro 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.