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
| 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 | An 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
| Variable | Default | What it does |
|---|---|---|
SSO_PUBLIC_BASE_URL | first allowed origin | The origin browsers reach the API on. Builds the redirect_uri |
SSO_REQUEST_TTL_SECONDS | 600 | How long one sign-in attempt stays live |
SSO_PROVISION_DOMAINS | unset — off | Comma-separated email 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 |
SSO_SESSION_TTL_SECONDS | unset | A 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
| Variable | What it does |
|---|---|
SSO_OIDC_ISSUER | The issuer URL. Compared byte for byte |
SSO_OIDC_CLIENT_ID | The client id |
SSO_OIDC_CLIENT_SECRET | Optional for a public client using PKCE |
SSO_OIDC_NAME | Registration name. Defaults to oidc |
SSO_OIDC_LABEL | What the sign-in button says |
SSO_OIDC_SCOPES | Scopes requested |
SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT | Treat 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
| Variable | What it does |
|---|---|
SSO_GITHUB_CLIENT_ID | The OAuth app’s client id |
SSO_GITHUB_CLIENT_SECRET | Required — there is no PKCE in this flow |
SSO_GITHUB_NAME | Registration name |
SSO_GITHUB_LABEL | What the sign-in button says |
SSO_GITHUB_ORG | Cosmetic: shapes the account chooser |
SSO_GITHUB_ENTERPRISE_URL | For 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
| Variable | What it does |
|---|---|
SSO_SAML_ENTRY_POINT | The identity provider’s sign-in URL |
SSO_SAML_ISSUER | This service provider’s entity id |
SSO_SAML_IDP_CERT | The identity provider’s signing certificate |
SSO_SAML_SUBJECT_ATTRIBUTE | An immutable directory id, if the NameID is unsuitable |
SSO_SAML_EMAIL_ATTRIBUTE | Attribute carrying the address |
SSO_SAML_GROUPS_ATTRIBUTE | Attribute carrying group membership |
SSO_SAML_EMAIL_VERIFIED | Assert that this directory owns the addresses it reports |
SSO_SAML_NAME / SSO_SAML_LABEL | Registration 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
sidends 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
subwith nosidends 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:
completeverifies, 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.subjectis stable, issuer-scoped, and never the email. The link table keys on it.emailVerifiedreports 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.