The generic OpenID Connect adapter: authorization code flow, PKCE, and identity tokens verified against the provider’s published keys.
One adapter covers most of the market — Okta, Auth0, Keycloak, Google, Entra ID, Authentik, Zitadel, JumpCloud, Ping and GitLab all speak this. It is the SSO equivalent of the copilot’s OpenAI-wire adapter.
Why this is one package and not five
The copilot ships a package per vendor because their SDKs differ. Here the wire does not: every provider above answers the same discovery document and the same token endpoint, so a vendor is an issuer, a set of scopes and a claim mapping.
That is a preset, not a package:
import {
createGoogleProvider,
createEntraProvider,
createOktaProvider,
createAuth0Provider,
createKeycloakProvider
} from '@apograph/identity-provider-oidc';GitHub and
SAML are the exceptions that prove it, which is
why kind and callbackMethod are in the port’s descriptor from the first
release.
Install
npm install @apograph/identity-provider-oidcimport { createOidcProvider } from '@apograph/identity-provider-oidc';
IdentityPlugin(config.plugins.identity, {
sso: {
providers: [{ name: 'okta', provider: createOidcProvider(settings) }]
}
});Configuration
| 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 | The sign-in button’s text. Defaults to the issuer’s host |
SSO_OIDC_SCOPES | Comma-separated. Defaults to openid,profile,email |
SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT | Treat a missing email_verified as verified |
The provider is registered only when both the issuer and the client id are set. An issuer with no client id is a sign-in button that can only fail, and every SSO failure looks the same, so whoever clicks it learns nothing.
The two issuer mistakes
The issuer is compared byte for byte
In the discovery document, and again on every token. Auth0 issues with a trailing slash; its preset keeps it, and omitting it is the single most common way to get a working discovery document and a token that will not verify.
Discovery appends to the issuer's path, not its origin
https://sso.acme.com/realms/apograph discovers at
…/realms/apograph/.well-known/openid-configuration.
Treating an issuer as a bare origin is how an implementation works against Google and fails against Keycloak, Auth0 custom domains, and every multi-tenant provider.
emailVerifiedWhenAbsent
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 that this directory is
authoritative for the addresses it reports. That is usually true of a corporate
tenant; it is not something an adapter may decide.
A provider that sends email_verified: false is believed regardless of this
setting.
Cryptography is jose’s
createRemoteJWKSet caches the provider’s keys, refetches on an unknown key id
— which is how rotation is survived without a restart — and rate-limits that
refetch, which is what stops a stream of tokens bearing invented key ids
turning this CMS into a load generator pointed at somebody else’s identity
provider.
Hand-rolling JWT and JWKS validation is not where to demonstrate independence. The dependency is permitted here for exactly this reason, and forbidden in the identity domain and server packages.
Decisions worth knowing before changing anything
Reserved authorization parameters cannot be overridden.
authorizationParams is for a provider’s own knobs — hd, prompt, idp.
state, nonce, code_challenge, redirect_uri, response_type,
client_id and scope are refused loudly at authorize time. Every one is
either a security control the core owns or the thing that decides which flow
runs; a typo replacing one would not fail, it would produce a sign-in that works
and is not protected.
A failed discovery is never cached. Caching it would turn a transient outage into a fixed window in which every sign-in fails for a reason that has already gone away. A successful one is cached, and concurrent first sign-ins share a single in-flight request.
The nonce is checked by the adapter, not by the token verifier. It is not a property of the token — it is the link between this token and the attempt the browser started. Without the check, a token captured from another attempt, validly signed and unexpired, would be accepted.
Groups are read only when a deployment names the claim. A claim nobody asked for should not be collected, and one that is never sent reads as “this person is in no groups” — which a role-mapping handler would quietly act on.
Back-channel logout
This adapter implements logout-token verification, and checks the events
claim specifically. Everything else about a logout token matches an identity
token, so without that check anyone holding a stolen one could sign its owner
out at will.
See single sign-on.