Features Apograph CMS on GitHub

Single sign-on

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

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

Documents 0.5.2 Updated Edit this page Report a problem

On this page

New in 0.4.0. Apograph can authenticate against an external identity provider alongside — or instead of — its own 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.0An identity provider that speaks SAML rather than OIDC

One adapter covers most of the market, because the wire does not differ between OIDC vendors: a vendor is an issuer, a set of scopes and a claim mapping. Those are presets inside the OIDC package — createGoogleProvider, createEntraProvider, createOktaProvider, createAuth0Provider, createKeycloakProvider — not packages of their own.

GitHub and SAML are the exceptions that prove it. GitHub is OAuth2 with no identity token, so “verify” means spending the code and reading the profile back. SAML’s response is not a redirect at all — the provider POSTs a form to the callback.

Registering one

Adapters are constructed at the composition root and passed in IdentityPlugin’s second argument. Config holds the typed view of the environment; an adapter instance is not an environment value.

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

IdentityPlugin(config.plugins.identity, {
    sso: {
        providers: [
            { name: 'okta', provider: createOidcProvider(config.plugins.identity.ssoProviders.oidc) }
        ]
    }
});

The name is permanent

It appears in /api/auth/sso/<name>/start and in every sso_identities row, so renaming a registration orphans the links naming it — everyone signs in as a stranger and, under provisioning, gets a second account.

Register the callback URL with the provider exactly as ssoCallbackUrl(config, name) builds it. Most providers match that string byte for byte, and a trailing slash is a different URL to them.

A strict cookie refuses to boot

IdentityPlugin fails to start with providers registered and session.cookieSameSite: 'strict'. A strict cookie is not sent on the provider’s cross-site redirect back, so every sign-in would fail with a generic error and nothing in the response would say why.

Environment

Shared

VariableDefaultWhat it does
SSO_PUBLIC_BASE_URLfirst allowed originThe origin browsers reach the API on. Builds the redirect_uri
SSO_REQUEST_TTL_SECONDS600How long one sign-in attempt stays live
SSO_PROVISION_DOMAINSunset — offComma-separated email domains that may be provisioned just in time
SSO_PROVISION_ROLEviewerRole a provisioned account gets
SSO_ALLOW_PASSWORD_LOGINtruefalse turns passwords off
SSO_SESSION_TTL_SECONDSunsetA shorter lifetime for SSO sessions alone

SSO_PUBLIC_BASE_URL is configured rather than read from the request’s Host header, which a client controls and could therefore point at an origin of its choosing. Leave it unset when the admin and the API share an origin — the deployed shape, and the dev one where Vite proxies /api.

OpenID Connect

VariableWhat it does
SSO_OIDC_ISSUERThe issuer URL. Compared byte for byte
SSO_OIDC_CLIENT_IDThe client id
SSO_OIDC_CLIENT_SECRETOptional for a public client using PKCE
SSO_OIDC_NAMERegistration name. Defaults to oidc
SSO_OIDC_LABELWhat the sign-in button says
SSO_OIDC_SCOPESScopes requested
SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENTTreat a missing email_verified claim as verified

The provider is registered only when both the issuer and the client id are set. An issuer with no client id would be a sign-in button that can only fail, and every SSO failure looks the same, so whoever clicked it would learn nothing.

The issuer is compared byte for byte

Auth0 issues with a trailing slash; omitting it is the single most common way to get a working discovery document and a token that will not verify.

The discovery path is appended to the issuer’s path, not its origin: https://sso.acme.com/realms/apograph discovers at …/realms/apograph/.well-known/…. Treating an issuer as a bare origin works against Google and fails against Keycloak, Auth0 custom domains, and every multi-tenant provider.

SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT is an operator’s assertion, never a default. Microsoft Entra ID simply never emits email_verified, so a first sign-in cannot claim an existing account until somebody says this directory is authoritative for the addresses it reports. A provider that sends email_verified: false is believed regardless.

GitHub

VariableWhat it does
SSO_GITHUB_CLIENT_IDThe OAuth app’s client id
SSO_GITHUB_CLIENT_SECRETRequired — there is no PKCE in this flow
SSO_GITHUB_NAMERegistration name
SSO_GITHUB_LABELWhat the sign-in button says
SSO_GITHUB_ORGCosmetic: shapes the account chooser
SSO_GITHUB_ENTERPRISE_URLFor GitHub Enterprise Server

The address comes from GitHub’s verified-emails endpoint, never the public profile — a profile address is something a person can set to anything. An account with no verified address is refused outright, because there is no address there worth linking.

SSO_GITHUB_ORG shapes the chooser and nothing else. Apograph’s own rules decide who gets in.

SAML 2.0

VariableWhat it does
SSO_SAML_ENTRY_POINTThe identity provider’s sign-in URL
SSO_SAML_ISSUERThis service provider’s entity id
SSO_SAML_IDP_CERTThe identity provider’s signing certificate
SSO_SAML_SUBJECT_ATTRIBUTEAn immutable directory id, if the NameID is unsuitable
SSO_SAML_EMAIL_ATTRIBUTEAttribute carrying the address
SSO_SAML_GROUPS_ATTRIBUTEAttribute carrying group membership
SSO_SAML_EMAIL_VERIFIEDAssert that this directory owns the addresses it reports
SSO_SAML_NAME / SSO_SAML_LABELRegistration name and button text

SAML carries no verification claim at all, so emailVerified is always an operator’s assertion and defaults to false. Until you set it, a first sign-in cannot claim an existing account.

A transient NameID is refused

It is a different value on every sign-in — that is its entire purpose — so keying an account link on one would mint a new link, and under provisioning a new account, every single time.

A NameID that is an email address is refused too. Configure a persistent NameID, or point SSO_SAML_SUBJECT_ATTRIBUTE at an immutable directory id.

What a sign-in may do to an account

By default: nothing. It signs in accounts that already exist and are active, creates none, and changes nobody’s role. A first sign-in may claim an existing account only when the provider asserts the email is verified, and a pending or disabled account is refused exactly as on the password path.

Three things a deployment can turn on, each off by default.

Just-in-time provisioning creates an active account with no password the first time a verified profile arrives with no matching one.

The domain allow-list is required, and exact

Provisioning without one means anybody with an account at that provider can sign in here — and nothing breaks to say so, the user list simply grows. An identity provider answers for everyone it knows, and a public one knows everyone.

Matching is exact on the domain, deliberately not a suffix match, so acme.com never admits evil-acme.com.

Role mapping is plain code at the composition root — resolveRole returns a role key, or null to leave the role alone, which is also what no handler means. Two rules protect it: an unknown role key is logged and ignored rather than failing the sign-in, because a typo in a handler must not lock a directory out; and an account already holding admin is never demoted by a mapping. That grant is deliberate and a directory group is not.

Accepting an invitation with a work account. /start?invite=<token> carries the one-time token on the attempt. The callback checks that the address the provider vouched for is the one that was invited, burns the token, and activates the account without a credential. Following a spent link again as the same person just signs them in; anyone else following it is refused.

Turning passwords off

SSO_ALLOW_PASSWORD_LOGIN=false.

The root administrator is always exempt

The alternative has no recovery. An operator who mis-scopes their provider and has no password left is locked out with no way back short of a database client.

The refusal still performs one bcrypt comparison, so the break-glass address cannot be found by timing.

Ending a session because the provider says so

POST /api/auth/sso/:provider/backchannel-logout is the answer to the one thing operators assume SSO already does.

A session in Apograph is a row with a TTL, and disabling somebody in the directory does not reach it. Until this route existed, the honest answer to “we offboarded them, are they out?” was “within SESSION_TTL_SECONDS”.

The provider calls it directly, with no browser in the loop, so it works after the person has closed the tab. Two shapes of notification are handled differently on purpose:

  • A sid ends only the sessions that provider session opened — somebody signed in on a laptop and a phone through two provider sessions keeps the other one.
  • A sub with no sid ends every session the linked account holds. That is the offboarding case, and being blunt is the point.

Verification is the adapter’s. A provider that cannot verify a logout token does not implement it, and the route answers 404 rather than pretending to have acted — this endpoint is unauthenticated and reachable by anyone, so an unverified notification would be an open way to sign arbitrary people out.

For a provider with no back-channel logout, SSO_SESSION_TTL_SECONDS shortens SSO sessions alone: a partial mitigation, trading a re-authentication now and then for a smaller window after an offboarding.

What is audited

Three facts, deliberately not folded into the sign-in row: user.sso_linked, user.sso_provisioned and user.sso_role_mapped. “Somebody signed in” cannot answer “where did this account come from?”, which is the first question anyone reviewing an SSO deployment asks.

The sign-in row itself records the method and the provider, so the log can separate people who came in through the directory from those who still hold a password.

Writing an adapter

Each adapter’s own page covers its configuration in full; writing an SSO provider covers the port. In brief:

The SsoProvider port lives in @apograph/identity-domain, which imports nothing — no NestJS, no Drizzle, no jose. An adapter depends on the port without depending on the server plugin.

It states three obligations, and a conformance kit turns them into tests you run in your own suite:

  1. complete verifies, or it throws. From the outside a decoded profile and a verified one are the same object, so an adapter that skips verification produces a sign-in that looks entirely normal.
  2. subject is stable, issuer-scoped, and never the email. The link table keys on it.
  3. emailVerified reports what the provider claimed. It is the only gate on claiming an account that already exists.

The core mints state, nonce and the PKCE verifier — adapters generate none of them. CSRF and replay defence is one rule, implemented once where it is tested once. One conformance check follows directly: authorize must not put the code verifier in the URL. Only its S256 challenge belongs there.