Pulp EngineDocument Rendering
Get started

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

  1. Register Pulp Engine as a confidential OAuth client with your identity provider.

  2. Configure the redirect URI as https://your-api.example.com/auth/oidc/callback.

  3. 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
  4. 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:

  1. The server reads the configured OIDC_CLAIM_GROUPS claim from the verified ID token.
  2. If any value matches OIDC_ADMIN_GROUPS, the user receives admin scope.
  3. Otherwise, if any value matches OIDC_EDITOR_GROUPS (or OIDC_EDITOR_GROUPS=*), the user receives editor scope.
  4. 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 OIDC sub claim (sanitized to [a-zA-Z0-9_-], truncated to 64 chars)
  • displayName: from the configured display name claim
  • oidcSub: the original sub claim
  • authMethod: '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_users registry. This is the HA-safe path: every instance shares one registry, so a user provisioned on instance A is immediately known to instance B. Set EDITOR_USERS_DB=true to enable DB-backed named-user mode with no JSON/file seed.
  • File mode — persistence requires EDITOR_USERS_FILE (or an EDITOR_USERS_JSON seed). 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_token is not accepted as an Authorization: Bearer API credential (removed in v0.82.0, audit SEC-A1). Doing so would have bypassed the 5-minute freshness and Origin checks that /auth/oidc/exchange enforces. SSO integrations exchange the id_token at /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

  1. Create a new OIDC application of type Web.

  2. Set the sign-in redirect URI to https://your-api.example.com/auth/oidc/callback.

  3. Under Sign On → OpenID Connect ID Token, ensure groups claim is included. Add a claim filter (e.g. Matches regex: .*) to send all groups, or scope it.

  4. Note the issuer (typically https://your-org.okta.com), client ID, and client secret.

  5. 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

  1. Register a new application in App registrations.
  2. Add a redirect URI of type Web pointing to https://your-api.example.com/auth/oidc/callback.
  3. Under Token configuration, add an optional groups claim — choose either security groups or directory roles depending on how you want to assign permissions.
  4. Generate a client secret under Certificates & secrets.
  5. The discovery URL is https://login.microsoftonline.com/{tenant-id}/v2.0.
  6. Note: Azure AD returns group object IDs, not display names. Configure OIDC_ADMIN_GROUPS and OIDC_EDITOR_GROUPS with the actual GUIDs.

Auth0

  1. Create a new Regular Web Application.

  2. Set Allowed Callback URLs to https://your-api.example.com/auth/oidc/callback.

  3. Under Advanced Settings → OAuth, ensure OIDC Conformant is enabled.

  4. To send a groups claim, 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 || []);
    };
  5. The discovery URL is https://your-tenant.auth0.com.

Keycloak

  1. Create a new OpenID Connect client with Access Type: confidential.
  2. Set Valid Redirect URIs to https://your-api.example.com/auth/oidc/callback.
  3. Under Client Scopes → Add → roles, add a mapper of type Group Membership with token claim name groups.
  4. 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_URI must use HTTPS — plain-HTTP redirect URIs leak the authorization code to network observers.
  • REQUIRE_HTTPS=true and TRUST_PROXY=true are required (existing controls). The PKCE state cookie’s Secure attribute is tied to REQUIRE_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 when CORS_ALLOWED_ORIGINS is 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_minted events from /auth/oidc/callback carry details.identityMode = "oidc", the verified actor (user ID), and details.oidcSub (the IDP subject claim).
  • editor_token_minted events from /auth/oidc/exchange carry details.identityMode = "oidc-exchange".
  • oidc_provision_conflict events are emitted when an OIDC sub claim 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: DENY on the authorization endpoint (Azure AD/Entra ID is the most common — see “Provider compatibility” above).
  • Provider does not honor prompt=none and 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=true and ensure the API can create/write the editor_users table. 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 with EDITOR_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", set allowedTyps:null when needed, and pin the directory with requiredClaims.tid.
  • Okta/Auth0: configure a custom API audience and map its scp or scope values. 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 jwksUri and 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.