Skip to content

@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

ts
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.

ts
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() }
FieldWhat it is
identitythe parsed substrat:auth choice, or null when nothing usable was delivered
sessionSecretthe tenant's DO-minted session-signing secret
settingsthe declared env spec, resolved delivered > binding > manifest default
configthe 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:

  • provider is a function, not a field. A route that only reads settings must 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 default rides as a binding shared by every install of one serving script, so reading env.OIDC_ISSUER directly 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.
  • AuthConfigError carries its HTTP status with the throw. An instance nobody has configured yet is a 503; AUTH_PROVIDER=oidc with no OIDC_ISSUER is a 500, 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 plain Error would have arrived as a 500 by accident. Each worker re-raises it in its own framework's exception:
ts
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.

ExportLogin happensSelected when
oidcRpAuthProviderserver-side, in your workera substrat:auth choice was delivered with mode: 'oidc' and both an issuer and a clientId — the hosted path, one script and many issuers
oidcAuthProviderat the issuer; the app verifies a presented JWTnothing 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
doAuthProviderin your own Durable Objectnever 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 verified sub into the PrincipalId the 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 — and claimOwner binds the subject that presents it. Only the token's hash is stored; minting again retires the earlier link. mintOwnerClaimLink does the token, the hash and the URL in one call, so the vertical's mintOwnerClaim hook is a one-liner. A closed window is not a lost instance: the seat stays pending — needsSetup keeps saying so, and ownerSeat says 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 verified sub to that principal. The four HTTP routes over these — GET/POST /api/invites, POST /api/invites/:principal/revoke, POST /api/accept-invite — are written once as mountInviteRoutes(app, deps) from @substrat-run/vertical-auth/invite-routes — a subpath, because it is the one module here that imports hono at runtime, and the root import must stay free of it for a consumer that wants only an AuthProvider. 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's assignScopeRole and revokeScopeRole, and its auth provider. Every error the mount raises itself is an HTTPException — a body that is not JSON or does not fit the route's schema is a 400 naming the problem, never a bare SyntaxError or ZodError — so the vertical's own onError renders 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 what revokeScopeRole is for.

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.

The hard parts, hosted.