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-oidcConfigure
# .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,groupsThe 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:
| Option | Default | Notes |
|---|---|---|
issuer | required | HTTPS (only localhost exempt). Compared byte for byte |
clientId | required | |
clientSecret | none | |
label | issuer host | |
scopes | openid, profile, email | Must include openid |
authorizationParams | none | hd, prompt, idp… Reserved ones are refused |
endpoints | discovered | { authorization, token, jwks, endSession? } |
groupsClaim | unset — groups not read | Claim a resolveRole handler reads |
emailClaims | email, preferred_username, upn | Tried in order |
emailVerifiedWhenAbsent | false | |
discoveryCacheMs | 3600000 | Failures are never cached |
clockToleranceSeconds | 60 | |
fetch | global fetch | Test 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' }) }
];| Preset | Issuer it builds |
|---|---|
https://accounts.google.com; hostedDomain is sent as hd (cosmetic) | |
| Entra ID | https://login.microsoftonline.com/<tenantId>/v2.0 |
| Okta | https://<domain> or https://<domain>/oauth2/<authorizationServerId> |
| Auth0 | https://<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/callbackA 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_verifiedgates linking. A first sign-in may claim an existing account only when it istrue. Entra ID never sends it; setSSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT=trueonly if the directory owns its addresses. An explicitfalseis 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
groupsClaimis set. - Back-channel logout is supported. The
eventsclaim is checked. See single sign-on.