Your directory checks the person. Apograph still decides who has an account
Single sign-on usually arrives as a second identity model bolted beside the first. Here it is a port. The identity provider verifies who somebody is, and everything that decides whether they may be here — the invite, the account status, the role, the session — is the same code the password path runs, tested on both.
For whoever runs the directory and gets the ticket when somebody leaves
Invite-only, on both paths
There is no registration form. The only route into an account is an administrator’s invite, and the link is one-time: redeeming it is a conditional update that returns a row to exactly one request, and every failure — unknown, expired, used, revoked — is the same bare 404, so nobody can probe for live invites.
Single sign-on keeps that property. By default a provider can sign in an account that already exists and is active, and nothing else. On a first sign-in it may link to an existing account only when the provider asserts the address is verified; a pending or disabled account is refused exactly as with a password. The link is keyed on the provider’s stable subject, never the email, which would hand the account to whoever inherits the address. An invited person can accept with the provider instead of choosing a password, and their account then has no credential of its own.
Creating accounts from the directory is a separate, explicit decision. Just-in-time provisioning is off until configured, and configuring it requires a non-empty list of email domains, matched exactly — because a public provider answers for everyone it knows, and "sign in with Google" with no domain restriction means the internet has an account here, with nothing breaking to say so.
| Capability | OpenID Connect | GitHub | SAML 2.0 |
|---|---|---|---|
| Callback | Redirect | Redirect | Form POST |
| PKCE | Yes | No | No |
| Replay defence | state + nonce | state | RelayState |
| Verified email | id token claim | /user/emails | operator asserts |
| Group claims for role mapping | Yes | No | Yes |
| Provider sign-out URL | when published | No | No |
| Back-channel logout | Yes | No | No |
| Presets | Google · Entra ID · Okta · Auth0 · Keycloak | github.com · Enterprise Server | No |
The core owns the handshake
An adapter does two things: build the URL that starts a sign-in, and turn the provider’s response into a verified profile or throw. Everything security-critical sits on this side of that line. The state, the nonce and the PKCE verifier are minted here and stored in a row keyed by an opaque token in a short-lived cookie — not in a signed cookie, because sessions here are rows rather than signed blobs, and a signing secret would mean a rotation procedure and a new answer to what a backup contains.
The order of the callback is the design. state is compared before anything is exchanged, so a forged callback costs one indexed lookup; the attempt is burned before the token exchange, so a replay finds nothing; the exchange runs outside any transaction, because holding one across a call to a third party is how a slow provider becomes database contention. Only then does one unit of work resolve the account, record the link, open the session and write the audit event — so a link can never outlive a sign-in that failed.
Every failure is the same redirect with the same error. The caller is anonymous and the provider is not; told apart, those failures would let anyone with an account at a public provider discover which addresses hold accounts here.
What is in the box
The password path and the provider path, side by side.
| Thing | How it works |
|---|---|
| Sessions | A database row, delivered as an httpOnly cookie. Revocation is a delete; the account’s status is re-checked on every request. Signing out revokes that one device. |
| Passwords | Twelve characters or more, seventy-two bytes at most, hashed with bcrypt. A reset link is one-time, revokes every session, and opens none. |
| Adapters | Three packages — identity-provider-oidc, -github, -saml — each constructed in your configuration and registered by name. An empty list mounts no routes and shows no buttons. |
| Enforced SSO | Set allowPasswordLogin: false and the provider is the only way in — except for the root administrator named in configuration, who keeps a password path so a mis-scoped provider cannot lock you out of your own CMS. |
| Role mapping | A handler you supply maps the provider’s groups to a role. No handler, no change. An unknown key is logged and ignored, and an administrator is never demoted here — that is a decision for the admin, not a directory group. |
| Provisioning | Off by default. On, it needs a domain allow-list and a default role, and the audit log records the account as created, linked and signed in. |
When somebody leaves
What operators assume SSO already does, and what actually happens.
- With back-channel logout, they are out at once
- An OpenID Connect provider that supports it posts a signed logout token straight to the CMS, with no browser involved — so it works after the tab was closed. A token naming one provider session ends the sessions opened from it; one naming the person ends every session the account holds. The adapter must verify it, because the endpoint is public.
- Without it, the session’s own expiry ends access
- A session is a row with a lifetime, and a directory that disables somebody does not reach it. Sessions opened through a provider can be given a shorter lifetime than password sessions for exactly this reason — the trade between an occasional re-authentication and a smaller window is yours to make.
- Disabling the account here closes both doors
- Status is checked on every request, so disabling an account in the admin ends its access immediately whichever way it signed in, and a disabled account with a live provider link stays refused.
The Monday after an offboarding
Somebody left on Friday and IT disabled them in Entra ID at five. On Monday the security lead asks whether they could have opened the CMS over the weekend. With back-channel logout configured, no — the provider posted a logout token at five past five and every session ended then. Without it, "until their session expired", and the honest follow-up is to shorten the SSO session lifetime.
Questions before you connect a directory
Do you support Okta, Entra ID, Google Workspace, Keycloak?
Yes, through the one OpenID Connect adapter — discovery plus published keys, with a preset per vendor so you write a tenant id rather than five URLs. Entra ID never emits an email-verified claim; a setting lets you assert that your tenant is authoritative for its addresses — an assertion you make, not a default.
Can the provider decide roles?
Only through a handler you write, given the groups the provider sent. Without one, the role an administrator set stands on every sign-in. With one, a mapped change is written to the audit log, and an administrator is never demoted by it.
What does SAML need that the others do not?
A persistent NameID or an immutable directory attribute for the subject — a transient one changes on every sign-in and is refused, as is one that is an email address. And because no SAML assertion carries an email-verified claim, linking to an existing account waits until you state that your provider vouches for its addresses.
Next
Every feature is included, free
The core is MIT licensed and every feature is in it — none of them is paid-only. Community runs free in production; a plan buys room and governance, not a different product.