OIDC / SSO Integration Guide
Pulp Engine supports SSO authentication via OpenID Connect (OIDC). When enabled, users can sign in to the editor through your identity provider (Okta, Azure AD/Entra ID, Auth0, Keycloak, Google Workspace, etc.) instead of using personal API keys.
OIDC sits alongside the existing authentication methods — API keys and named-user keys continue to work. You can run a deployment with OIDC only, OIDC plus API keys, or no OIDC at all.
Quick start
-
Register Pulp Engine as a confidential OAuth client with your identity provider.
-
Configure the redirect URI as
https://your-api.example.com/auth/oidc/callback. -
Set the following environment variables on the API:
OIDC_DISCOVERY_URL=https://your-idp.example.com OIDC_CLIENT_ID=your-client-id OIDC_CLIENT_SECRET=your-client-secret OIDC_REDIRECT_URI=https://your-api.example.com/auth/oidc/callback OIDC_COOKIE_SECRET=$(openssl rand -hex 32) EDITOR_USERS_FILE=/var/pulp-engine/users.json -
Restart the API. The editor login screen will show a “Sign in with SSO” button.
Configuration reference
Required when OIDC is enabled
| Variable | Description |
|---|---|
OIDC_DISCOVERY_URL |
Issuer URL — the server fetches {url}/.well-known/openid-configuration at startup. |
OIDC_CLIENT_ID |
Confidential client ID registered with the OIDC provider. |
OIDC_CLIENT_SECRET |
Confidential client secret. |
OIDC_REDIRECT_URI |
Absolute callback URL. Must match what is registered with the provider. Must use HTTPS in hardened production mode. |
OIDC_COOKIE_SECRET |
32+ character secret used to sign the short-lived PKCE state cookie. Generate with openssl rand -hex 32. |
Optional
| Variable | Default | Description |
|---|---|---|
OIDC_SCOPES |
openid profile email |
Space-separated OAuth scopes to request. |
OIDC_CLAIM_SUB |
sub |
Claim used as the OIDC subject identifier. |
OIDC_CLAIM_EMAIL |
email |
Claim used to populate user email. |
OIDC_CLAIM_DISPLAY_NAME |
name |
Claim used as the user’s display name. |
OIDC_CLAIM_GROUPS |
groups |
Claim used to read group/role membership. |
OIDC_ADMIN_GROUPS |
(none) | Comma-separated group values mapped to admin scope. |
OIDC_EDITOR_GROUPS |
* |
Comma-separated group values mapped to editor scope (template create/update/delete + asset write). * grants editor access to any authenticated user. Empty string means no default access — only users in OIDC_ADMIN_GROUPS can sign in. In hardened production mode this must be set explicitly (the server refuses to start otherwise); outside hardened mode a startup warning is logged when the default * is in effect. Set it to a concrete group list to restrict who can edit. |
OIDC_AUTO_PROVISION |
true |
When true, new users are auto-created in the user registry on first OIDC login. Persistence depends on storage mode — see Auto-provisioning and the user registry below. |
OIDC_PROVIDER_NAME |
SSO |
Display name for the SSO button in the editor. |
Authorization model
OIDC users are mapped to Pulp Engine scopes through group claims:
- The server reads the configured
OIDC_CLAIM_GROUPSclaim from the verified ID token. - If any value matches
OIDC_ADMIN_GROUPS, the user receivesadminscope. - Otherwise, if any value matches
OIDC_EDITOR_GROUPS(orOIDC_EDITOR_GROUPS=*), the user receiveseditorscope. - Otherwise, matching configured or stored group grants allow access with base role
none; without a matching grant, access is denied (403).
The stored base role controls named-user permissions; matching group mappings and
stored group bindings add tenant or folder grants. Current groups are saved on
every login. OIDC_ROLE_SYNC=true is now the default and refreshes OIDC-owned
base roles on every login. Setting it to false preserves sticky stored base
roles. With sync enabled, losing all mapped access deactivates the account and
revokes existing sessions. Accounts deactivated by SCIM or an admin require
explicit reactivation. Users admitted only by group grants receive base role
none.
OIDC_ROLE_MAPPINGS_JSON (or _FILE) accepts entries such as
[{"group":"fin-pub","role":"publisher","folder":"/Finance"}]. The optional
folder is resolved by path; an unknown folder grants nothing and logs a warning.
See Access control for the complete action and role catalogue.
Auto-provisioning and the user registry
When OIDC_AUTO_PROVISION=true (the default), the first time a user signs in via OIDC their record is created in the Pulp Engine user registry. The record includes:
id: derived from the OIDCsubclaim (sanitized to[a-zA-Z0-9_-], truncated to 64 chars)displayName: from the configured display name claimoidcSub: the originalsubclaimauthMethod: 'oidc'(distinguishes OIDC users from key-based users)key: a random UUID (never used for authentication — OIDC users authenticate via their IDP)
Persistence depends on the storage mode (v0.81.0+):
- Postgres / SQL Server — auto-provisioned users persist automatically in the DB-backed
editor_usersregistry. This is the HA-safe path: every instance shares one registry, so a user provisioned on instance A is immediately known to instance B. SetEDITOR_USERS_DB=trueto enable DB-backed named-user mode with no JSON/file seed. - File mode — persistence requires
EDITOR_USERS_FILE(or anEDITOR_USERS_JSONseed). Without it, OIDC users live in memory and must re-provision on every restart.
Without any persistence store configured, auto-provisioned users are in-memory only; the startup logs warn when this is the case.
Pre-provisioned mode: set OIDC_AUTO_PROVISION=false to require operators to create OIDC users manually via POST /admin/users. The user record must include authMethod: "oidc" and oidcSub: "<provider-sub>".
De-provisioning: removing an OIDC user requires DELETE /admin/users/:id. The replaceAll operation (used by bulk user updates) preserves OIDC-provisioned users automatically — they cannot be removed by omitting them from a bulk update.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/auth/oidc/login |
Redirects the browser to the OIDC authorization endpoint with PKCE + nonce. |
GET |
/auth/oidc/callback |
Handles the provider redirect, exchanges the code for tokens, mints a Pulp Engine session, redirects with a one-time completion code. |
POST |
/auth/oidc/complete |
Exchanges a single-use completion code for a Pulp Engine editor token. |
POST |
/auth/oidc/exchange |
Embed mode: exchanges an OIDC id_token directly for a Pulp Engine editor token. |
GET |
/auth/oidc/logout |
RP-initiated logout — redirects to the provider’s end_session_endpoint. |
A raw OIDC
id_tokenis not accepted as anAuthorization: BearerAPI credential (removed in v0.82.0, audit SEC-A1). Doing so would have bypassed the 5-minute freshness andOriginchecks that/auth/oidc/exchangeenforces. SSO integrations exchange theid_tokenat/auth/oidc/exchange(browser/embed) for a short-lived editor token; machine-to-machine callers use scoped API keys or configured IdP access-token issuers, as described below.
Provider-specific setup
Okta
-
Create a new OIDC application of type Web.
-
Set the sign-in redirect URI to
https://your-api.example.com/auth/oidc/callback. -
Under Sign On → OpenID Connect ID Token, ensure
groupsclaim is included. Add a claim filter (e.g.Matches regex: .*) to send all groups, or scope it. -
Note the issuer (typically
https://your-org.okta.com), client ID, and client secret. -
Configure Pulp Engine:
OIDC_DISCOVERY_URL=https://your-org.okta.com OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... OIDC_ADMIN_GROUPS=pulp-engine-admins OIDC_EDITOR_GROUPS=pulp-engine-editors
Azure AD / Entra ID
- Register a new application in App registrations.
- Add a redirect URI of type Web pointing to
https://your-api.example.com/auth/oidc/callback. - Under Token configuration, add an optional groups claim — choose either security groups or directory roles depending on how you want to assign permissions.
- Generate a client secret under Certificates & secrets.
- The discovery URL is
https://login.microsoftonline.com/{tenant-id}/v2.0. - Note: Azure AD returns group object IDs, not display names. Configure
OIDC_ADMIN_GROUPSandOIDC_EDITOR_GROUPSwith the actual GUIDs.
Auth0
-
Create a new Regular Web Application.
-
Set Allowed Callback URLs to
https://your-api.example.com/auth/oidc/callback. -
Under Advanced Settings → OAuth, ensure OIDC Conformant is enabled.
-
To send a
groupsclaim, create an Auth0 Action that adds it to the ID token:exports.onExecutePostLogin = async (event, api) => { api.idToken.setCustomClaim('groups', event.user.app_metadata?.groups || []); }; -
The discovery URL is
https://your-tenant.auth0.com.
Keycloak
- Create a new OpenID Connect client with Access Type: confidential.
- Set Valid Redirect URIs to
https://your-api.example.com/auth/oidc/callback. - Under Client Scopes → Add → roles, add a mapper of type Group Membership with token claim name
groups. - The discovery URL is
https://your-keycloak.example.com/realms/{realm-name}.
Embed mode
The <pulp-engine-editor> custom element supports OIDC tokens through the oidcToken field on the init message:
const editor = document.querySelector('pulp-engine-editor')
editor.init({
apiUrl: 'https://your-api.example.com',
oidcToken: hostOidcIdToken, // valid id_token from your IDP
templateKey: 'invoice',
})
The iframe calls POST /auth/oidc/exchange with the OIDC token, which validates it against the provider JWKS and returns a Pulp Engine session token. The id_token must be issued within the last 5 minutes (replay protection) and the request Origin must match CORS_ALLOWED_ORIGINS.
Silent token refresh
OIDC-authenticated sessions automatically attempt a silent refresh five minutes before the editor token expires. This uses a hidden iframe pointed at /auth/oidc/login?prompt=none&return_url=/oidc-silent-callback.html to re-authenticate without user interaction.
Provider compatibility: silent refresh requires the OIDC provider to support prompt=none AND to allow framing of its authorization endpoint. Some providers (notably Azure AD/Entra ID under certain tenant configurations) set X-Frame-Options: DENY on their authorization endpoint, which causes prompt=none to fail silently inside the iframe. In that case, Pulp Engine falls back to a full re-login when the token expires — the user is redirected to the SSO sign-in screen and (if their provider session is still active) signed back in immediately without re-entering credentials.
Production hardening
When HARDEN_PRODUCTION=true (auto-derived in production), the following OIDC-specific checks are enforced:
OIDC_REDIRECT_URImust use HTTPS — plain-HTTP redirect URIs leak the authorization code to network observers.REQUIRE_HTTPS=trueandTRUST_PROXY=trueare required (existing controls). The PKCE state cookie’sSecureattribute is tied toREQUIRE_HTTPS.
The following security controls are always active:
- PKCE (S256) on every authorization request, plus nonce binding through the full flow.
- Signed HttpOnly cookies for PKCE state (5-minute TTL,
SameSite=Lax). - Single-use completion codes for the callback → editor handoff (30-second TTL, in-memory store with periodic sweep).
- Origin validation on
POST /auth/oidc/exchange— fails closed whenCORS_ALLOWED_ORIGINSis set. - 5-minute freshness check on
id_tokens submitted to/auth/oidc/exchange(replay protection). - Rate limiting on
/auth/oidc/login(30/min),/auth/oidc/exchange(10/min), and/auth/oidc/complete(10/min).
Audit trail
OIDC authentication events appear in the standard audit log:
editor_token_mintedevents from/auth/oidc/callbackcarrydetails.identityMode = "oidc", the verifiedactor(user ID), anddetails.oidcSub(the IDP subject claim).editor_token_mintedevents from/auth/oidc/exchangecarrydetails.identityMode = "oidc-exchange".oidc_provision_conflictevents are emitted when an OIDCsubclaim maps to an existing key-based user — auto-provisioning is refused and the login is denied.
All template, asset, and other mutations performed by OIDC users carry the user’s Pulp Engine ID as the actor field, identical to named-user attribution.
Troubleshooting
“OIDC discovery failed” at startup
The server cannot fetch {OIDC_DISCOVERY_URL}/.well-known/openid-configuration. Check network access from the API host to the IDP, and verify the URL is correct (no trailing slash needed). The server retries with exponential backoff up to 5 attempts, then continues to recheck every 5 minutes. OIDC routes return 503 until discovery succeeds — the rest of the API continues to serve normally.
“OIDC user does not have access” (403 on callback)
The user’s group claims do not match OIDC_ADMIN_GROUPS or OIDC_EDITOR_GROUPS. Check the actual claim values returned by your IDP — for Azure AD, these are object IDs, not display names. Enable debug logging on the API to inspect the claims:
LOG_LEVEL=debug
“OIDC sub maps to existing non-OIDC user”
A user with the same ID as the sanitized OIDC subject already exists in the registry as a key-based user. This is a deliberate guard against account takeover. Either remove the existing user (DELETE /admin/users/:id) or change their ID to avoid the collision.
Silent refresh never succeeds
Check the browser network panel during a refresh attempt. Common causes:
- Provider sets
X-Frame-Options: DENYon the authorization endpoint (Azure AD/Entra ID is the most common — see “Provider compatibility” above). - Provider does not honor
prompt=noneand shows a login UI inside the iframe instead of returning silently. - Provider session has expired — silent refresh requires an active provider session.
In all cases, Pulp Engine falls back to the full re-login flow when the token actually expires. Users are redirected to the SSO sign-in and (if their provider session is still active elsewhere) signed back in immediately.
Auto-provisioned users disappear after restart
No persistence store is configured for the named-user registry. The fix depends on storage mode:
- Postgres / SQL Server (v0.81.0+) — set
EDITOR_USERS_DB=trueand ensure the API can create/write theeditor_userstable. The DB is the authoritative store and is shared across instances (HA-safe). Restart. - File mode — set
EDITOR_USERS_FILE=/var/pulp-engine/users.json(or seed withEDITOR_USERS_JSON) so the registry is backed by a file, and restart.
Machine-to-machine: IdP access tokens
Configure API_JWT_ISSUERS_JSON or its _FILE alternative, then send Authorization: Bearer <access_token>. These settings are independent of browser OIDC login. No issuer metadata is fetched at boot; only an exact configured iss selects a lazy JWKS client. Production issuer/discovery/JWKS URLs require HTTPS. Unverified token URLs are never fetched.
[{
"name": "services",
"issuer": "https://identity.example.com/",
"audiences": ["pulp-engine-api"],
"scopeClaim": "scope",
"scopeMap": {"reports:render": "render", "templates:write": "editor"},
"tenant": {"claim": "organization", "map": {"external-acme-id": "acme"}},
"actorClaim": "sub",
"algorithms": ["RS256"],
"allowedTyps": ["at+jwt"],
"clockToleranceSec": 60,
"maxTokenAgeSec": 3600
}]
Use tenant:{"fixed":"default"} or omit it for a single-tenant deployment. Multi-tenant issuers must declare a fixed tenant or claim/map; JWTs never grant super-admin. Unknown tenant slugs fail with tenant_unknown. Dot paths select nested scope/actor/tenant/required claims. Scope claims accept space-delimited strings or string arrays; the default reads scp, then scope. Map only the privileges the workload needs. Effective scope is selected per route from the mapped privileges; one token can map to both render and editor without widening either route’s permissions.
Tokens require iat and exp, an accepted asymmetric algorithm and an API audience distinct from OIDC_CLIENT_ID. Default algorithms are RS256, PS256, ES256 and EdDSA; HS* and none are refused. Default typs are at+jwt and application/at+jwt (case-insensitive). allowedTyps:null permits providers using generic JWT typ, while signature, audience and claim checks still apply. Any nonce or interactive client audience is rejected. requiredClaims maps claim paths to an accepted string or list, for example {"tid":"your-directory-guid"}. Token age defaults to 24 hours (maximum 24 hours); choose a short lifetime for workloads. JWT revocation before expiry, opaque introspection and mTLS are outside this implementation.
Provider mapping examples (use your provider’s issued claim names):
- Entra: issue an access token for a dedicated API application; map app roles from
scopeClaim:"roles", setallowedTyps:nullwhen needed, and pin the directory withrequiredClaims.tid. - Okta/Auth0: configure a custom API audience and map its
scporscopevalues. Keep browser client audiences separate. - Keycloak: use a dot path such as
realm_access.roles, with explicit role-to-scope mappings. - Kubernetes service accounts: pin
jwksUriand a dedicated projected-token audience;scopeClaim:"sub"can map exact service-account subjects to render scope. Pin tenant bindings explicitly.
Actors are jwt:<issuer-name>:<actor-claim>, hashed to a stable 32-hex suffix if over 200 characters. Failed discovery/JWKS returns 503 with Retry-After: 30; invalid tokens return 401 with a Bearer invalid_token challenge. JWKS cache lifetime is 10 minutes with 30-second refresh cooldown and 5-second network timeout. API-key requests do not depend on IdP availability.
The TypeScript SDK accepts tokenProvider: async ({forceRefresh}) => token and exports clientCredentialsTokenProvider({tokenUrl,clientId,clientSecret,scope?,audience?}). Python accepts token_provider: Callable[[bool],str] and exports ClientCredentialsTokenProvider(token_url=...,client_id=...,client_secret=...). Providers cache until 60 seconds before expiry and refresh once when a request receives a 401 invalid_token challenge. Combining token providers with API keys or editor tokens is rejected. Use HTTPS token endpoints in production.
The CLI reads PULP_ENGINE_TOKEN, PULP_ENGINE_TOKEN_CMD or --token-cmd <command>. A command prints one access token; it runs with a 10-second timeout and bounded output, cached until 60 seconds before JWT expiry. Treat commands as trusted local shell code. Project .pulp-engine.json token commands/tokens are ignored. Explicit API-key flags override environment token settings; an explicit token command overrides an environment static token.
Provisioning and tenant providers
SCIM linking matches externalId within the target tenant using OIDC_CLAIM_SCIM_LINK (default sub, usually oid for Entra). Email linking is disabled unless OIDC_LINK_BY_EMAIL=true. SCIM-owned base roles are preserved when linking.
OIDC_TENANT_GROUP_MAPPING_JSON maps verified group names to tenants, for example {"acme-pulp":"acme","globex-pulp":"globex"}. New and existing environment-provider users follow the mapping on each login. Moving tenants advances the session revocation floor. Matching two different tenants fails with oidc_tenant_ambiguous; no match uses OIDC_DEFAULT_TENANT unless OIDC_REQUIRE_TENANT_GROUP=true. Every mapped tenant is validated at boot.
The environment provider remains env. Native database deployments can also configure separate OIDC or SAML providers under /admin/identity-providers; subjects are unique per provider. Tenant OIDC uses its own discovery and JWKS cache, PKCE, nonce and browser state, canonical callbacks and encrypted client secrets. Use the tenant SSO guide for setup and Redis handoffs, and the SCIM guide for provisioning.
OIDC email linking also requires the verified email_verified=true claim; SAML email values come from the signed assertion.