@substrat-run/vertical-auth
Pluggable authentication for a vertical — one interface the application codes against, and three interchangeable implementations behind it. It is the concrete form of the identity seam for verticals, the counterpart to @substrat-run/oidc-rp, which serves the platform's own surfaces.
A vertical scaffolded with create-substrat answers /api/* with 401 until real auth is wired into authenticatedPrincipal. This package fills that seam without the vertical hand-rolling session handling or the owner-claim logic.
The AuthProvider contract
import type { AuthProvider, AuthSubject } from '@substrat-run/vertical-auth';
interface AuthProvider {
handle(request: Request): Promise<Response>; // /api/auth/* — login, logout, callbacks
resolve(headers: Headers): Promise<AuthSubject | null>; // request → verified subject
}
interface AuthSubject {
sub: string; // the provider's stable subject id
email: string | null;
name: string | null;
emailVerified?: boolean; // what the provider says about `email` — three states, not two
}The provider proves who a caller is and nothing more. Authorization stays in the kernel — roles, grants and tenancy are never this package's concern. Mapping sub to a Substrat PrincipalId is a separate, per-scope step (the identity directory below), which is what lets the implementation swap without touching the app.
emailVerified is three-state. true and false are the provider asserting something about the address; undefined is the provider saying nothing — what an OIDC issuer that never emits email_verified looks like, and what a session minted before the field existed looks like for the rest of its life. All three providers below populate it: the two OIDC ones carry the issuer's email_verified claim through unchanged, and doAuthProvider reports its own directory's flag. Nothing in this package gates on it — an address becomes an identifier in invite flows and on a staff roster, and a gate there could not be written against a claim nobody transported (#1359, #1373) — so the vertical that eventually writes such a gate decides what undefined means.
instanceAuthFor — what a vertical actually mounts
A vertical does not pick a provider at build time. One serving script runs every install, and each install may have been given a different issuer in the dashboard, so the provider is a function of this scope's delivered configuration. instanceAuthFor is that whole step — read the delivered config, parse substrat:auth, resolve the declared settings, and hand back the selector for a provider — in one DO hop. Nothing is selected during the hop: provider() is a closure, and the selection (and any AuthConfigError) happens when it is called.
import { instanceAuthFor, AuthConfigError } from '@substrat-run/vertical-auth';
const instance = await instanceAuthFor({
directory: identityDo(env, node), // the tenant's IdentityDO stub
scopeId: node.scopeId,
envSpec: TICKET0_ENV, // the vertical's declared env spec
env, // the worker's own bindings
});
// { identity, sessionSecret, settings, config, provider() }| Field | What it is |
|---|---|
identity | the parsed substrat:auth choice, or null when nothing usable was delivered |
sessionSecret | the tenant's DO-minted session-signing secret |
settings | the declared env spec, resolved delivered > binding > manifest default |
config | the whole delivered map, for a vertical's own non-declared keys |
provider() | the AuthProvider this instance's configuration selects |
Three things it owns, each of which a caller assembling this by hand gets wrong:
provideris a function, not a field. A route that only readssettingsmust not fail because nobody has configured a login yet, so selection — and its throw — happens when the provider is asked for, not when the config is read.- Settings resolve delivered > binding > default. A spec
defaultrides as a binding shared by every install of one serving script, so readingenv.OIDC_ISSUERdirectly hands every tenant the same string no matter what any of them saved in the dashboard. That was a real bug in one of the four hand-written copies this replaces. AuthConfigErrorcarries its HTTP status with the throw. An instance nobody has configured yet is a503;AUTH_PROVIDER=oidcwith noOIDC_ISSUERis a500, because a deployment that asked for a provider and did not finish configuring it is an operator's mistake, not a tenant's missing choice. A plainErrorwould have arrived as a 500 by accident. Each worker re-raises it in its own framework's exception:
try {
return instance.provider();
} catch (err) {
if (err instanceof AuthConfigError) throw new HTTPException(err.status, { message: err.message });
throw err;
}callout, meridian, manyfold and ticket0 all mount it. The create-substrat template deliberately does not: its config-do.ts mirrors the same scope_config table shape so a project can swap the binding and adopt vertical-auth later, which is why the scaffold and a demo look different here.
What it selects between
The composition is OIDC-only, so provider() picks between the first two. doAuthProvider is never selected by it — a vertical that owns credentials constructs one directly.
| Export | Login happens | Selected when |
|---|---|---|
oidcRpAuthProvider | server-side, in your worker | a substrat:auth choice was delivered with mode: 'oidc' and both an issuer and a clientId — the hosted path, one script and many issuers |
oidcAuthProvider | at the issuer; the app verifies a presented JWT | nothing usable was delivered and AUTH_PROVIDER=oidc names a fixed OIDC_ISSUER — standalone deploys, or an SPA already holding a token from Supabase, Auth0, AuthHero, Keycloak, Zitadel |
doAuthProvider | in your own Durable Object | never by the composition; construct it yourself when the vertical owns credentials rather than delegating to an issuer |
oidcAuthProvider is discovery-driven: the issuer URL is the only wired-in value, and the ID token is signature-verified against the issuer's JWKS. oidcRpAuthProvider runs Authorization-Code + PKCE through @substrat-run/oidc-rp, so verticals and the platform share one verifier rather than two copies of the security-critical path. With neither — no usable delivered OIDC choice, and no AUTH_PROVIDER=oidc with an OIDC_ISSUER — the composition fails closed: provider() throws AuthConfigError with a 503. A delivered choice missing its issuer or clientId is the same 503.
Signing out, and the issuer's own session
GET /api/auth/logout clears this app's session cookie and sends the browser to a same-origin returnTo. That is not the whole sign-out: the issuer holds a session on its own domain, so the next "Sign in" is a silent re-authentication as the same person, and "use another account" never works.
GET /api/auth/logout?federated ends that one too, through OIDC RP-Initiated Logout — the issuer's end_session_endpoint, with the ID token from this login as id_token_hint. The hint is what makes it a straight redirect: without it the issuer cannot tell a real sign-out from a link somebody was tricked into following, so it interrupts with a Confirm logout page. The provider keeps that token in its own cookie, scoped to the logout path and SameSite=Strict, and hands it back exactly once.
Two conditions live at the issuer, not here — at an Auth Server app they are the two fields on the application: RP-initiated logout must be enabled for the client, and the exact post_logout_redirect_uri the provider sends — the app's origin plus the post-logout path, / by default or the same-origin returnTo the logout link carried — must be on the post-logout list, which is separate from the sign-in callbacks. An origin alone does not match, and the mismatch is quiet: the person is signed out and left at the issuer instead of coming back. The first sign-out after turning them on can still show the confirmation page: the ID token minted before then carries no session id for the issuer to match, and the next sign-in puts one there.
Local sign-out happens first, always. An issuer that is down, advertises no end-session endpoint, or refuses the redirect URI can only leave its own session standing — never keep somebody signed in here.
The identity directory
IdentityDO is one Durable Object per tenant, running its own SQLite. A tenant's users, sessions and credentials live in that tenant's DO — there is no shared AUTH_DB for a bug to leak across. It is one of the vertical's own DO classes, so it stays sandbox-clean: the platform refuses cross-script bindings, not a vertical's own.
Its sub → principal mapping is provider-agnostic and used under every provider, including the two OIDC ones where Better Auth stays dormant:
setPendingOwner— records the owner seat at provision. The seat is minted empty (the platform knows the principal it minted, not the login the tenant's issuer will emit) and bound later by a verified subject. For 15 minutes after provision the first authenticated subject claims it — the install flow, where the installer opens the app seconds later. After that a plain sign-in binds nobody, so an instance nobody opened is not a seat anyone can take indefinitely. A re-provision keeps the window it has and never re-opens a claimed seat.resolvePrincipal— the per-request lookup that turns a verifiedsubinto thePrincipalIdthe kernel checks permissions against (and performs the first-sign-in claim while the window is open).ownerSeat/mintOwnerClaim/claimOwner— the claim link. Once the window has closed (or instead of relying on it), the platform mints a short-lived/?claim=<token>link under its secret — the dashboard's Owner seat card — andclaimOwnerbinds the subject that presents it. Only the token's hash is stored; minting again retires the earlier link.mintOwnerClaimLinkdoes the token, the hash and the URL in one call, so the vertical'smintOwnerClaimhook is a one-liner. A closed window is not a lost instance: the seat stays pending —needsSetupkeeps saying so, andownerSeatsays why — until a claim binds it.createInvite/listInvites/revokeInvite/claimInvite— member invites, the post-setup join path. An invite pre-mints a member principal, grants it a role at scope level, and records the token's hash; accepting binds the invitee's verifiedsubto that principal. The four HTTP routes over these —GET/POST /api/invites,POST /api/invites/:principal/revoke,POST /api/accept-invite— are written once asmountInviteRoutes(app, deps)from@substrat-run/vertical-auth/invite-routes— a subpath, because it is the one module here that importshonoat runtime, and the root import must stay free of it for a consumer that wants only anAuthProvider. A vertical supplies only what is its own: how a request resolves to a scope, what "admin" means, which roles a teammate may be invited at, its directory, the host'sassignScopeRoleandrevokeScopeRole, and its auth provider. Every error the mount raises itself is anHTTPException— a body that is not JSON or does not fit the route's schema is a 400 naming the problem, never a bareSyntaxErrororZodError— so the vertical's ownonErrorrenders them with no branch for this mount; what its own deps throw passes through untouched. The grant and the invite row live in two Durable Objects, so when the row cannot be written after the role was granted the mount revokes the grant before reporting the failure, which is whatrevokeScopeRoleis for.
Cookie-domain safety
resolveCookieDomain decides the session cookie's Domain for multi-surface installs, and refuses to set one on a public suffix — the boundary where one tenant's cookie could reach another. It is guarded by the real Public Suffix List via @substrat-run/psl, not a label-count heuristic.
Runtime
Workers-targeted: jose + Web Crypto, no node:*. IdentityDO extends the cloudflare:workers DurableObject base, and the vertical's worker bundles this package and re-exports the DO class so wrangler can see it.