Skip to main content

Authentication

Enterprise validates inbound JWTs against your OIDC provider on every request. There is no user database lookup - identity, organization roles, and project memberships all come from the token claims you configure.

If you're an end user looking to obtain a token to call the API, see the general Authentication page. This page is for operators wiring up the IdP.

Stateless model

The backend reads exactly five OIDC-related environment variables:

VariableRequiredDefaultPurpose
OIDC_ISSUERYes-Provider base URL. The backend fetches {issuer}/.well-known/openid-configuration at startup and uses the published JWKS to verify token signatures.
OIDC_CLIENT_IDYes-Client ID registered with your IdP. Accepted as a valid aud claim.
OIDC_AUDIENCESNo-Extra accepted aud values beyond OIDC_CLIENT_ID. Comma-separated.
OIDC_ROLES_CLAIMNorolesPath to the global roles array in the token. Dot-notation supported (e.g., realm_access.roles).
OIDC_PROJECTS_CLAIMNoprojectsPath to the per-project memberships claim.

What the platform reads from a token

Token verification checks:

  • Signature - RS256 against the JWKS published at the discovery endpoint.
  • Issuer - must match OIDC_ISSUER.
  • Audience - must contain OIDC_CLIENT_ID or one of OIDC_AUDIENCES.
  • Expiry - standard exp check.

Claims read:

ClaimPurpose
subUser identifier.
OIDC_ROLES_CLAIM (default roles)Dual purpose - see Roles, groups, and user tasks. Drives organization-level RBAC and BPMN user-task candidate-group matching.
OIDC_PROJECTS_CLAIM (default projects)Per-project memberships. Shape below.

Project membership shape

The projects claim must carry one entry per project the caller has access to:

{
"projects": [
{ "id": "00000000-0000-0000-0000-000000000001", "roles": ["editor"] },
{ "id": "00000000-0000-0000-0000-000000000002", "roles": ["viewer"] }
]
}

Each entry needs:

  • id - the project UUID, matching the id from your provisioning YAML.
  • roles - array of project-level roles (admin, editor, executor, viewer).

The platform also accepts the claim as a JSON-encoded string for providers that don't ship structured claims natively.

Wildcard (*) - all-projects grant

A project entry with id set to "*" grants its roles on every project, present and future. This is intended for a platform-operations team that administers the whole BPMN stack:

{
"projects": [
{ "id": "*", "roles": ["admin"] }
]
}

The wildcard's own roles array is honored - "*" with ["admin"] grants admin everywhere, ["viewer"] grants read-only everywhere. An explicit per-project entry takes effect when it grants a higher role than the wildcard (e.g. a ["viewer"] wildcard plus an ["admin"] entry for one project = admin on that project, viewer elsewhere).

Security note: a "*" entry is a broad grant. Restrict who your IdP maps it to - anyone whose token carries {"id":"*","roles":["admin"]} becomes an administrator of every project.

Claim-path dot notation

OIDC_ROLES_CLAIM and OIDC_PROJECTS_CLAIM accept dot-notated paths to nested claims. For example, Keycloak ships realm roles under realm_access.roles:

{
"realm_access": {
"roles": ["admin", "user"]
}
}

Setting OIDC_ROLES_CLAIM=realm_access.roles makes the platform traverse that path.

Wiring up a provider

The platform works with any OIDC-compliant IdP as long as you can shape the tokens it issues to match the contract above. The only piece of code tested against a specific provider's claim layout is Keycloak (used by the in-repo mock OIDC service for end-to-end tests), other providers work generically.

For any provider, you need to:

  1. Register a client for the platform (SPA or confidential, as your IdP requires).
  2. Configure a claim named per OIDC_ROLES_CLAIM containing the user's organization roles, or accept the default roles path.
  3. Configure a claim named per OIDC_PROJECTS_CLAIM containing the per-project memberships in the shape above. Most providers expose this through a custom claim mapper - consult their documentation.
  4. Set OIDC_ISSUER and OIDC_CLIENT_ID on the backend.

Keycloak example

Keycloak is the worked example because it's what the in-repo test fixtures use.

# Backend environment
OIDC_ISSUER=https://keycloak.example.com/realms/quantumbpm
OIDC_CLIENT_ID=quantumbpm-backend
OIDC_ROLES_CLAIM=realm_access.roles
OIDC_PROJECTS_CLAIM=projects

Configure a custom claim mapper in Keycloak to populate the projects claim in the documented shape. Realm roles end up under realm_access.roles automatically.

Other providers

For Okta, Auth0, Azure AD, Zitadel, and others, the same contract applies. The specifics - which UI screens, where the claim mapper lives, whether it's "groups" or "roles" by default - vary by provider, consult their documentation, then point OIDC_ROLES_CLAIM and OIDC_PROJECTS_CLAIM at the right paths. If you hit a wall, contact support@quantumbpm.com.

Mock OIDC for local testing

The repo bundles a small mock OIDC provider (tools/mockoidc) that issues tokens in the shape the platform expects. It is a local/CI testing tool only - it hands out a one-click global-admin token and must never be exposed in a real deployment. Run it locally with make run-mock-oidc (or it is built on demand by the kind test harness). In production, point OIDC_ISSUER at your real IdP.

Roles, groups, and user tasks

There is no separate "groups" claim. The single array at OIDC_ROLES_CLAIM does double duty:

  1. Organization-level RBAC - recognized role names like user, admin, and system-admin drive global permissions (see the role model).
  2. BPMN user-task candidate-group matching - every other entry in that array is treated as an identity-provider group name. When a BPMN user task carries candidateGroups, the platform authorizes a caller to complete or fail it if any entry of their roles claim matches any of the task's candidate groups (alongside the assignee and candidate-user checks).

This means your roles claim is essentially "the union of role names and group names this user has." Mix them freely:

{
"roles": ["user", "approvers", "finance", "ops-oncall"]
}

In that token:

  • user is interpreted as the platform's user org-role.
  • approvers, finance, ops-oncall are treated as identity-provider groups. A user task with candidateGroups: ["approvers"] will accept this caller, even if they aren't executor on the project.

You don't have to declare which entries are roles and which are groups - the platform doesn't distinguish at parse time. Org-level role enforcement matches against the known role names, user-task authorization just checks for any overlap between the caller's roles array and the task's candidate-group list.

The same applies on the SaaS side (Zitadel project roles play the role-and-group role), so user-task models port cleanly between deployments.

The role model

The platform applies access at two levels: organization roles and per-project roles, with a strict hierarchy:

admin  ⊃  editor  ⊃  executor  ⊃  viewer

The model is identical to SaaS - see Authentication → The role model for the full table.

A request that the platform refuses to authorize returns 404 Not Found rather than 403, to avoid confirming whether a project ID exists at all. If you're getting unexpected 404s, the most common cause is a missing or wrong-cased project entry in the user's projects claim.