Ortha can authenticate against an external identity provider alongside — or instead of — email and password.
The three adapters
| Package | Speaks | Use it for |
|---|---|---|
identity-provider-oidc | OpenID Connect | Okta, Auth0, Keycloak, Google, Entra ID, Authentik, Zitadel, JumpCloud, Ping, GitLab |
identity-provider-github | OAuth2, no identity token | github.com and GitHub Enterprise Server |
identity-provider-saml | SAML 2.0 | A 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
-
Scaffold with
--sso sso-oidc(orsso-github,sso-saml), or copy the matchingconfig/sso-*.tsandplugins.tslines from a fresh app. -
Register the callback URL with the provider, exactly:
<SSO_PUBLIC_BASE_URL or your origin>/api/auth/sso/<name>/callback -
Fill in
.env:
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 - 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-serverbuilds 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
| Variable | Default | What it does |
|---|---|---|
SSO_PUBLIC_BASE_URL | first allowed origin | The origin browsers reach the API on. Builds the redirect_uri. Leave unset when admin and API share an origin |
SSO_REQUEST_TTL_SECONDS | 600 | How long one sign-in attempt stays live |
SSO_PROVISION_DOMAINS | unset — off | Comma-separated domains that may be provisioned just in time |
SSO_PROVISION_ROLE | viewer | Role a provisioned account gets |
SSO_ALLOW_PASSWORD_LOGIN | true | false turns passwords off (root admin exempt) |
SSO_SESSION_TTL_SECONDS | unset | Shorter 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
| Variable | What it does |
|---|---|
SSO_OIDC_ISSUER | Issuer URL, compared byte for byte |
SSO_OIDC_CLIENT_ID | Client id |
SSO_OIDC_CLIENT_SECRET | Optional for a public client using PKCE |
SSO_OIDC_NAME | Registration name. Default oidc |
SSO_OIDC_LABEL | Button text. Default: the issuer’s host |
SSO_OIDC_SCOPES | Scopes requested. Product repository only |
SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT | Treat 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_ABSENTis your assertion that the directory owns its addresses (Entra ID). An explicitemail_verified: falseis always believed.
GitHub
| Variable | What it does |
|---|---|
SSO_GITHUB_CLIENT_ID | OAuth app client id |
SSO_GITHUB_CLIENT_SECRET | Required |
SSO_GITHUB_NAME / SSO_GITHUB_LABEL | Registration name (default github) / button text (default GitHub) |
SSO_GITHUB_SCOPES | Default read:user,user:email. Generated app only |
SSO_GITHUB_ENTERPRISE_BASE_URL | GitHub Enterprise Server. Generated app; the product repository reads SSO_GITHUB_ENTERPRISE_URL |
SSO_GITHUB_ORGANIZATION | Cosmetic 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
| Variable | What it does |
|---|---|
SSO_SAML_ENTRY_POINT | The IdP’s sign-in URL |
SSO_SAML_IDP_CERT | The IdP’s signing certificate, PEM or bare base64; \n escapes are accepted |
SSO_SAML_ISSUER | This CMS’s entity id. Required in a generated app; the product repository falls back to SSO_PUBLIC_BASE_URL |
SSO_SAML_SUBJECT_ATTRIBUTE | An immutable directory id, when NameID is unsuitable |
SSO_SAML_EMAIL_ATTRIBUTE / SSO_SAML_NAME_ATTRIBUTE / SSO_SAML_GROUPS_ATTRIBUTE | Attribute names, when the usual spellings are not what the IdP sends (NAME is generated-app only) |
SSO_SAML_EMAIL_VERIFIED | Assert the directory owns its addresses. Default false |
SSO_SAML_NAME / SSO_SAML_LABEL | Registration 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. NeedsSSO_PROVISION_DOMAINS. - Role mapping —
resolveRolereturns a role key, ornullto leave the role alone. An unknown key is logged and ignored; an existingadminis 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=falseThe 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-logoutDisabling someone in the directory does not reach an existing session row. This route does — the provider calls it directly, no browser involved.
sidin the logout token → ends only the sessions that provider session opened.subwithoutsid→ 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:
completeverifies, or throws.subjectis stable, issuer-scoped, and never the email.emailVerifiedreports what the provider claimed.
authorize must never put the PKCE verifier in the URL — only its S256 challenge.