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

Single sign-on

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

Configuring an identity provider, and the defaults that decide what a sign-in can do.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Ortha can authenticate against an external identity provider alongside — or instead of — email and password.

The three adapters

PackageSpeaksUse it for
identity-provider-oidcOpenID ConnectOkta, Auth0, Keycloak, Google, Entra ID, Authentik, Zitadel, JumpCloud, Ping, GitLab
identity-provider-githubOAuth2, no identity tokengithub.com and GitHub Enterprise Server
identity-provider-samlSAML 2.0A provider that speaks SAML rather than OIDC

OIDC vendors differ only in issuer, scopes and claims, so they are presets in the OIDC package: createGoogleProvider, createEntraProvider, createOktaProvider, createAuth0Provider, createKeycloakProvider.

Quick setup

  1. Scaffold with --sso sso-oidc (or sso-github, sso-saml), or copy the matching config/sso-*.ts and plugins.ts lines from a fresh app.

  2. Register the callback URL with the provider, exactly:

    <SSO_PUBLIC_BASE_URL or your origin>/api/auth/sso/<name>/callback
  3. Fill in .env:

Example language
SSO_OIDC_ISSUER=https://sso.example.com/realms/orthacms   # byte-for-byte, trailing slash included
SSO_OIDC_CLIENT_ID=orthacms
SSO_OIDC_CLIENT_SECRET=…                                     # optional for a public PKCE client
SSO_OIDC_NAME=keycloak                                       # permanent: appears in URLs and link rows
SSO_OIDC_LABEL=Keycloak
# Entra ID never sends email_verified:
# SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT=true
SSO_GITHUB_CLIENT_ID=…
SSO_GITHUB_CLIENT_SECRET=…          # required: no PKCE in this flow
SSO_GITHUB_NAME=github
SSO_GITHUB_LABEL=GitHub
# generated app:
SSO_GITHUB_ENTERPRISE_BASE_URL=     # https://github.acme.com for GHES
SSO_GITHUB_ORGANIZATION=            # cosmetic: shapes the account chooser
SSO_SAML_ENTRY_POINT=https://idp.example.com/sso/saml
SSO_SAML_IDP_CERT="-----BEGIN CERTIFICATE-----\nMIIC…\n-----END CERTIFICATE-----"
SSO_SAML_ISSUER=https://cms.example.com          # this CMS's entity id
SSO_SAML_SUBJECT_ATTRIBUTE=objectGUID            # when NameID is an email or transient
SSO_SAML_EMAIL_VERIFIED=true                     # your assertion; SAML has no such claim
  1. Check it is registered:
curl http://localhost:3000/api/auth/sso
[{ "name": "keycloak", "label": "Keycloak", "kind": "oidc" }]

An empty [] means no provider is configured — only the password form shows. kind is oidc, oauth2 (GitHub) or saml.

Local Keycloak

The monorepo’s compose file has one: docker compose --profile sso up -d, admin console on http://localhost:8080 (admin/admin). Create realm orthacms, a confidential client orthacms with redirect URI http://localhost:4200/api/auth/sso/keycloak/callback, and a user whose verified email matches an existing account.

Registering providers in code

Adapters are constructed at the composition root and passed as IdentityPlugin’s second argument. ssoProviders(config) in the generated plugins.ts registers what the environment configured. Add more, or a preset:

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

IdentityPlugin(config.plugins.identity, {
    sso: {
        providers: [
            ...ssoProviders(config),
            {
                name: 'contractors',
                provider: createEntraProvider({ tenantId: '…', clientId: '…', clientSecret: '…' })
            }
        ],
        // optional: map a directory group to a role; null leaves the role alone
        resolveRole: ({ profile }) =>
            profile.groups?.includes('cms-editors') ? 'contributor' : null
    }
});
  • List order = sign-in button order. Nothing else.
  • ssoCallbackUrl(config, name) from @orthacms/identity-server builds the exact callback string.

The name is permanent

It appears in /api/auth/sso/<name>/start and every sso_identities row. Renaming it orphans every link — people sign in as strangers, and under provisioning get a second account.

A strict cookie refuses to boot

session.cookieSameSite: 'strict' with providers registered fails at startup: a strict cookie is not sent on the provider’s redirect back, so every sign-in would fail silently. Use lax.

Environment

Shared

VariableDefaultWhat it does
SSO_PUBLIC_BASE_URLfirst allowed originThe origin browsers reach the API on. Builds the redirect_uri. Leave unset when admin and API share an origin
SSO_REQUEST_TTL_SECONDS600How long one sign-in attempt stays live
SSO_PROVISION_DOMAINSunset — offComma-separated domains that may be provisioned just in time
SSO_PROVISION_ROLEviewerRole a provisioned account gets
SSO_ALLOW_PASSWORD_LOGINtruefalse turns passwords off (root admin exempt)
SSO_SESSION_TTL_SECONDSunsetShorter lifetime for SSO sessions

The last four are read by the product repository’s config/identity.ts. A generated app does not read them — add them to its ssoConfig():

// apps/server/config/identity.ts (generated app) — inside ssoConfig()
return defined({
    publicBaseUrl: readEnv('SSO_PUBLIC_BASE_URL'),
    requestTtlSeconds: readPositiveInt('SSO_REQUEST_TTL_SECONDS', 600),
    provisioning: when(readEnv('SSO_PROVISION_DOMAINS'), () => ({
        domains: readList('SSO_PROVISION_DOMAINS', ''),
        defaultRole: readEnv('SSO_PROVISION_ROLE') ?? 'viewer'
    })),
    allowPasswordLogin: readEnv('SSO_ALLOW_PASSWORD_LOGIN') !== 'false',
    sessionTtlSeconds: readOptionalPositiveInt('SSO_SESSION_TTL_SECONDS')
});

OpenID Connect

VariableWhat it does
SSO_OIDC_ISSUERIssuer URL, compared byte for byte
SSO_OIDC_CLIENT_IDClient id
SSO_OIDC_CLIENT_SECRETOptional for a public client using PKCE
SSO_OIDC_NAMERegistration name. Default oidc
SSO_OIDC_LABELButton text. Default: the issuer’s host
SSO_OIDC_SCOPESScopes requested. Product repository only
SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENTTreat a missing email_verified as verified
  • Registered only when both issuer and client id are set.
  • Auth0 issues with a trailing slash — omit it and tokens will not verify.
  • Discovery is appended to the issuer’s path: https://sso.acme.com/realms/orthacms → …/realms/orthacms/.well-known/….
  • EMAIL_VERIFIED_WHEN_ABSENT is your assertion that the directory owns its addresses (Entra ID). An explicit email_verified: false is always believed.

GitHub

VariableWhat it does
SSO_GITHUB_CLIENT_IDOAuth app client id
SSO_GITHUB_CLIENT_SECRETRequired
SSO_GITHUB_NAME / SSO_GITHUB_LABELRegistration name (default github) / button text (default GitHub)
SSO_GITHUB_SCOPESDefault read:user,user:email. Generated app only
SSO_GITHUB_ENTERPRISE_BASE_URLGitHub Enterprise Server. Generated app; the product repository reads SSO_GITHUB_ENTERPRISE_URL
SSO_GITHUB_ORGANIZATIONCosmetic chooser hint. Generated app; the product repository reads SSO_GITHUB_ORG
  • Registered only when both id and secret are set.
  • The address comes from GitHub’s verified emails, never the public profile. No verified address → refused.

SAML 2.0

VariableWhat it does
SSO_SAML_ENTRY_POINTThe IdP’s sign-in URL
SSO_SAML_IDP_CERTThe IdP’s signing certificate, PEM or bare base64; \n escapes are accepted
SSO_SAML_ISSUERThis CMS’s entity id. Required in a generated app; the product repository falls back to SSO_PUBLIC_BASE_URL
SSO_SAML_SUBJECT_ATTRIBUTEAn immutable directory id, when NameID is unsuitable
SSO_SAML_EMAIL_ATTRIBUTE / SSO_SAML_NAME_ATTRIBUTE / SSO_SAML_GROUPS_ATTRIBUTEAttribute names, when the usual spellings are not what the IdP sends (NAME is generated-app only)
SSO_SAML_EMAIL_VERIFIEDAssert the directory owns its addresses. Default false
SSO_SAML_NAME / SSO_SAML_LABELRegistration name (default saml) and button text

Transient and email NameIDs are refused

A transient NameID changes every sign-in; an email is not a stable identifier. Configure a persistent NameID or set SSO_SAML_SUBJECT_ATTRIBUTE.

What a sign-in may do to an account

By default: nothing. It signs in existing active accounts, creates none, changes no roles. A first sign-in claims an existing account only when the provider says the email is verified. pending and disabled accounts are refused.

Three opt-ins:

  • Just-in-time provisioning — creates an active, passwordless account for a verified profile with no match. Needs SSO_PROVISION_DOMAINS.
  • Role mapping — resolveRole returns a role key, or null to leave the role alone. An unknown key is logged and ignored; an existing admin is never demoted.
  • Accepting an invitation with a work account — /api/auth/sso/<name>/start?invite=<token>. The callback checks the verified address matches the invitation, burns the token and activates the account.

The domain allow-list is required, and exact

Without it, anyone with an account at the provider could get in. Matching is exact — acme.com never admits evil-acme.com.

Turning passwords off

SSO_ALLOW_PASSWORD_LOGIN=false

The root administrator is always exempt

Otherwise a mis-scoped provider locks you out with no way back short of a database client. The refusal still runs one bcrypt comparison, so the exempt address cannot be found by timing.

Password recovery does not look at this switch: with mail configured, Forgot it? still sends an active member a reset link, and the password it sets still cannot sign in.

Ending a session because the provider says so

POST /api/auth/sso/:provider/backchannel-logout

Disabling someone in the directory does not reach an existing session row. This route does — the provider calls it directly, no browser involved.

  • sid in the logout token → ends only the sessions that provider session opened.
  • sub without sid → ends every session of the linked account (offboarding).
  • A provider whose adapter cannot verify logout tokens gets 400 {"error":"invalid_request"}, the same as a token that fails to verify. An unknown provider name gets 404.

No back-channel logout at your provider? SSO_SESSION_TTL_SECONDS shortens SSO sessions to narrow the window, and since 0.13.0 an administrator can end them by hand: Members → the member → Sessions & access → Sign out everywhere (DELETE /api/users/:id/sessions).

What is audited

  • user.sso_linked, user.sso_provisioned, user.sso_role_mapped — separate from the sign-in row.
  • The sign-in row records the method and provider.

Writing an adapter

See writing an SSO provider. The SsoProvider port lives in @orthacms/identity-domain (no NestJS, Drizzle or jose). A conformance kit turns its three obligations into tests:

  1. complete verifies, or throws.
  2. subject is stable, issuer-scoped, and never the email.
  3. emailVerified reports what the provider claimed.

authorize must never put the PKCE verifier in the URL — only its S256 challenge.