Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

OpenID Connect

Identity plugin @orthacms/identity-server@orthacms/identity-admin

Okta, Auth0, Keycloak, Google, Entra ID and the rest — one package, five presets.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Authorization code flow with PKCE. Identity tokens are verified against the provider’s published keys (via jose). Covers Okta, Auth0, Keycloak, Google, Entra ID, Authentik, Zitadel, JumpCloud, Ping and GitLab.

Install

npm install @orthacms/identity-provider-oidc

Configure

# .env
# registered only when both ISSUER and CLIENT_ID are set
SSO_OIDC_ISSUER=https://acme.okta.com
SSO_OIDC_CLIENT_ID=0oa1b2c3d4
# optional for a public client (PKCE is always used)
SSO_OIDC_CLIENT_SECRET=...
# route segment and link-row key; renaming it orphans existing links
SSO_OIDC_NAME=oidc
# button text; default is the issuer's host
SSO_OIDC_LABEL=Sign in with Okta
# only for a provider that never sends email_verified (Entra ID)
SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT=false
# reference server only; default openid,profile,email
SSO_OIDC_SCOPES=openid,profile,email,groups

The reference server, apps/server/config/identity.ts:

function oidcProvider(): (OidcProviderConfig & { name: string }) | undefined {
    const issuer = readEnv('SSO_OIDC_ISSUER');
    const clientId = readEnv('SSO_OIDC_CLIENT_ID');
    return when(issuer && clientId, () =>
        defined({
            name: readEnv('SSO_OIDC_NAME') ?? 'oidc',
            issuer: issuer as string,
            clientId: clientId as string,
            clientSecret: readEnv('SSO_OIDC_CLIENT_SECRET'),
            label: readEnv('SSO_OIDC_LABEL'),
            scopes: readOptionalList('SSO_OIDC_SCOPES'),
            emailVerifiedWhenAbsent: readFlag('SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT', false)
        })
    );
}

A generated app’s config/sso-oidc.ts is the same without SSO_OIDC_SCOPES; add scopes there by hand.

All options

OidcProviderConfig:

OptionDefaultNotes
issuerrequiredHTTPS (only localhost exempt). Compared byte for byte
clientIdrequired
clientSecretnone
labelissuer host
scopesopenid, profile, emailMust include openid
authorizationParamsnonehd, prompt, idp… Reserved ones are refused
endpointsdiscovered{ authorization, token, jwks, endSession? }
groupsClaimunset — groups not readClaim a resolveRole handler reads
emailClaimsemail, preferred_username, upnTried in order
emailVerifiedWhenAbsentfalse
discoveryCacheMs3600000Failures are never cached
clockToleranceSeconds60
fetchglobal fetchTest seam

Refused in authorizationParams: state, nonce, code_challenge, redirect_uri, response_type, client_id, scope.

Register

// apps/server/src/plugins.ts
import { createOidcProvider } from '@orthacms/identity-provider-oidc';

const { oidc } = config.plugins.identity.ssoProviders;
const providers = [];
if (oidc) {
    const { name, ...settings } = oidc;
    providers.push({ name, provider: createOidcProvider(settings) });
}

IdentityPlugin(config.plugins.identity, { sso: { providers } });

Vendor presets

Same options minus issuer, plus what builds it:

import {
    createAuth0Provider,
    createEntraProvider,
    createGoogleProvider,
    createKeycloakProvider,
    createOktaProvider
} from '@orthacms/identity-provider-oidc';

const secret = process.env.SSO_CLIENT_SECRET;

const providers = [
    { name: 'google', provider: createGoogleProvider({ clientId: 'x.apps.googleusercontent.com', clientSecret: secret, hostedDomain: 'acme.com' }) },
    { name: 'entra', provider: createEntraProvider({ clientId: '…', clientSecret: secret, tenantId: '…', emailVerifiedWhenAbsent: true }) },
    { name: 'okta', provider: createOktaProvider({ clientId: '…', clientSecret: secret, domain: 'acme.okta.com', authorizationServerId: 'default' }) },
    { name: 'auth0', provider: createAuth0Provider({ clientId: '…', clientSecret: secret, domain: 'acme.eu.auth0.com' }) },
    { name: 'keycloak', provider: createKeycloakProvider({ clientId: 'orthacms', clientSecret: secret, baseUrl: 'https://sso.acme.com', realm: 'staff' }) }
];
PresetIssuer it builds
Googlehttps://accounts.google.com; hostedDomain is sent as hd (cosmetic)
Entra IDhttps://login.microsoftonline.com/<tenantId>/v2.0
Oktahttps://<domain> or https://<domain>/oauth2/<authorizationServerId>
Auth0https://<domain>/ — trailing slash kept
Keycloak<baseUrl>/realms/<realm>

Verify

# 1. The issuer's discovery document — its "issuer" must equal SSO_OIDC_ISSUER exactly
curl -s https://acme.okta.com/.well-known/openid-configuration | jq -r .issuer

# 2. The CMS lists the provider
curl -s http://localhost:3000/api/auth/sso
# [{"name":"oidc","label":"Sign in with Okta","kind":"oidc"}]

# 3. Start redirects to the provider with state, nonce and a PKCE challenge
curl -si http://localhost:3000/api/auth/sso/oidc/start | grep -i '^location'

Register this redirect URI with the provider:

https://cms.example.com/api/auth/sso/oidc/callback

A failed sign-in lands on /identity/signin?error=sso. The server logs why:

WARN [CompleteSsoUseCase] SSO sign-in refused: <reason>

The two issuer mistakes

The issuer is compared byte for byte

In discovery and on every token. Auth0 issues with a trailing slash. Dropping it gives a working discovery and tokens that never verify.

Discovery appends to the issuer's path

https://sso.acme.com/realms/orthacms discovers at …/realms/orthacms/.well-known/openid-configuration, not at the origin.

Behaviour worth knowing

  • email_verified gates linking. A first sign-in may claim an existing account only when it is true. Entra ID never sends it; set SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT=true only if the directory owns its addresses. An explicit false is always believed.
  • Key rotation needs no restart. An unknown key id triggers a rate-limited JWKS refetch.
  • The nonce is checked by the adapter, binding the token to this attempt.
  • Groups are read only when groupsClaim is set.
  • Back-channel logout is supported. The events claim is checked. See single sign-on.