Features Apograph CMS on GitHub

Writing an SSO provider

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

Reaching an identity provider Apograph ships no adapter for.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

An identity provider Apograph does not ship an adapter for is one factory function returning an SsoProvider, plus a run of the conformance kit.

Check first whether you need one. The OIDC adapter reaches every provider that speaks OpenID Connect, which is most of the market — a vendor there is a preset, not a package. A new package is for a wire that genuinely differs, as GitHub’s and SAML’s do.

Where the port lives

@apograph/identity-domain, which imports nothing. No NestJS, no Drizzle, no class-validator, no jose — its manifest has no dependencies key at all, and that is deliberate rather than an oversight.

That is what lets an adapter depend on the port without dragging in the server plugin and everything pinned to it. If a change there needs a dependency, the change belongs in an adapter or in the plugin.

sso-provider.ts     # the port: descriptor / authorize / complete / logoutUrl
sso-profile.ts      # the normalised profile, and its assertions
redirect-target.ts  # the open-redirect guard
conformance.ts      # the kit every adapter runs

The three clauses

The port states three obligations the core has no way to verify for itself. The conformance kit is what turns them from prose into tests.

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. The profile assertion refuses the specific mistake — a subject equal to the email — because a person’s address follows a mailbox, not a person.

3. emailVerified reports what the provider claimed. It is the only gate on claiming an account that already exists, so an adapter that reports true because it seems likely has removed the gate.

What the core mints, so you do not

state, the nonce and the PKCE verifier all arrive in the authorize request and come back in the callback. Adapters generate none of them.

CSRF and replay defence is one rule, and implementing it once where it can be tested once beats implementing it per adapter with a fresh chance to differ.

Never put the code verifier in the URL

Only its S256 challenge belongs there. An adapter that passes the verifier itself hands the browser — and every log between there and the provider — the one value PKCE exists to withhold.

The conformance kit checks this directly.

The descriptor

kind and callbackMethod were in the port from the first release, before there was a second protocol to need them.

Declare callbackMethod: 'POST' and the plugin mounts the form-post callback — which is what made adding SAML a package rather than a change to the seam.

Optional: back-channel logout

Implement verifyLogoutToken and the plugin’s back-channel logout route works for your provider. Do not implement it if you cannot verify — the route then answers 404 rather than pretending to have acted, which is right, because the endpoint is unauthenticated and reachable by anyone.

An unverified notification would be an open way to sign arbitrary people out.

The conformance kit

Run it in your own suite, against a stub that signs real tokens with a generated key pair where the protocol has signatures. That is what makes the tampering case mean something: the tampered scenario is a token signed by a key the provider does not publish, and it fails because the signature is checked — not because a flag says so.

The scripted provider is the worked example, and short enough to read in one sitting.

Registering it

IdentityPlugin(config.plugins.identity, {
    sso: {
        providers: [{ name: 'mine', provider: createMyProvider({ /* … */ }) }]
    }
});

The name appears in the route and in every identity-link row, so it is permanent — renaming a registration orphans the links naming it. See single sign-on for the handshake settings and what a sign-in is allowed to do.