The pure-backend auth model
This page explains what NetScript authentication actually is, why it is designed as a
pure-backend seam rather than a built-in identity provider, and how a single typed port lets
you swap GitHub OAuth for WorkOS or better-auth without touching one line of application code. It is
understanding-oriented — read it to build the mental model before you wire authentication into a
project. When you want the headline API and endpoints, see the
auth capability; when you want to do the task, follow
Add authentication; when you want exact exported symbols, follow
reference/service/ and the auth adapter references it links to.
The thesis: NetScript owns the seam, not the identity provider
Most backend frameworks make a choice for you. They either bundle a session store, a password table, and a set of OAuth strategies — and you live inside their opinions forever — or they hand you nothing and you reassemble cookies, token verification, and provider redirects by hand in every project. Both extremes leak the integration tax NetScript exists to remove: the first couples your app to one identity vendor; the second makes you the integration.
NetScript takes the same stance on authentication that it takes on contracts and plugins: the cross-cutting concern belongs to a typed boundary the framework owns, and the concrete provider is an adapter that plugs into that boundary. The framework defines the shape of an authenticator — a port — and ships several pure backends that satisfy it. Your application depends on the port; it never depends on GitHub, WorkOS, or better-auth directly.
The payoff is the one promise this whole design exists to keep: you swap the provider by changing one environment variable, not your code.
The seam: AuthBackendPort and its sub-ports
The core package @netscript/plugin-auth-core defines a single port,
AuthBackendPort, that every backend must satisfy. It is not a god-interface — it is composed
of small, single-responsibility sub-ports, so a backend implements exactly the concerns it
owns and nothing more.
| Name | Type | Description |
|---|---|---|
name |
string |
The backend's stable identifier — kv-oauth, workos, or better-auth. Used by the registry to resolve the one active backend. |
providers |
AuthProviderRegistryPort |
The set of identity providers this backend understands (GitHub, Google, Okta, …). Resolves a named provider to its OAuth/OIDC configuration. |
sessions |
AuthSessionStorePort |
Create, read, refresh, and revoke sessions. The mutation surface — and the one non-interactive backends decline. |
crypto |
AuthSessionCryptoPort |
Signs and verifies the opaque session token. The default is a WebCrypto HMAC-SHA256 token (createHmacSessionTokenCrypto) — an opaque string, not a JWT you parse. |
principalMapper |
AuthPrincipalMapperPort |
Maps a backend-specific identity into the framework's neutral Principal shape, so the rest of the app never sees vendor types. |
interactive? |
InteractiveFlowPort (optional) |
The redirect-based sign-in flow: signIn, handleCallback, getSessionId, signOut. OPTIONAL — only a backend that drives its own OAuth redirect implements it. |
authenticate(request) |
(AuthnRequest) => AuthnResult |
The non-interactive verify step inherited from AuthenticatorPort: given a request (cookie/header), return a Principal or a typed failure. |
Two things make this a seam rather than a base class. First, every member is a port — a small
interface — so a backend is composition, not inheritance: it assembles four required sub-ports
(plus one optional one) instead of subclassing framework internals. Second, the optional
interactive member is where the whole capability matrix below comes from: a backend that owns its
redirect flow provides it; a backend that delegates sign-in to a hosted page leaves it out.
The neutral currency: Principal and AuthnResult with scheme: "custom"
A backend's job is to turn its notion of an authenticated user into NetScript's notion. That
neutral currency lives one layer down, in @netscript/service under the
/auth subpath, and it is the same authentication contract the rest of the framework already
speaks — API keys, bearer tokens, trusted headers. Auth backends produce the same Principal shape.
// A backend never returns vendor types to your app. It returns these.
// Who the request is, in framework-neutral terms.
interface Principal {
readonly subject: string; // stable user id
readonly scheme: "api-key" | "bearer" | "trusted-header" | "custom";
readonly claims: Readonly<Record<string, unknown>>;
// ...
}
// The result of authenticating a request: success xor typed failure.
type AuthnResult =
| { ok: true; principal: Principal; readonly setCookies?: readonly string[] }
| { ok: false; reason: string };
// Every OAuth/OIDC backend stamps its principals with scheme: "custom" —
// distinguishing an interactively-authenticated user from an api-key or
// bearer caller, while still flowing through the SAME AuthnResult seam.
const result: AuthnResult = {
ok: true,
principal: {
subject: "user_01H...",
scheme: "custom", // <- auth adapters always use "custom"
claims: { email: "a@b.dev", provider: "github" },
},
setCookies: ["__Host-ns_session=...; HttpOnly; Secure; SameSite=Lax"],
};
The discipline here is what makes provider-swapping real. The principalMapper sub-port is the
only place a backend's vendor identity touches NetScript types; everywhere downstream — your
service handlers, your contract middleware, your authorization checks — sees a Principal with
scheme: "custom" and never imports a WorkOS or kv-oauth type. Change the backend and the
Principal your code reads does not change shape.
Three backends, three different capability matrices
The framework ships three pure backends. They satisfy the same AuthBackendPort, but they fill
the optional interactive slot differently — and that single difference produces three genuinely
different capability profiles. Only kv-oauth is a full interactive backend; WorkOS and
better-auth are non-interactive by design.
| Name | Type | Description |
|---|---|---|
@netscript/auth-kv-oauth |
interactive |
Drives its own OAuth/OIDC redirect flow. Full interactive port (signIn/handleCallback/signOut), KV-backed sessions with real create/refresh/revoke, refresh-on-read, default cookie __Host-ns_session. The default backend. Provider presets: github, google, gitlab, discord, slack, spotify, facebook, twitter, plus tenant-based auth0, okta, awsCognito, azureAd, logto, clerk. |
@netscript/auth-workos |
non-interactive |
Delegates sign-in to WorkOS AuthKit's hosted flow; verifies the sealed wos-session cookie. NO interactive port. Session mutations throw AuthBackendOperationUnsupportedError. |
@netscript/auth-better-auth |
non-interactive |
Wraps a better-auth instance over Prisma; verifies its session. NO interactive port. Session mutations throw AuthBackendOperationUnsupportedError. |
This is not a missing-feature apology — it is the seam working as intended. A hosted provider like WorkOS owns the sign-in page and session lifecycle; asking NetScript to also mutate that session would be wrong, so the backend declines it loudly rather than pretending. The next section explains exactly how it declines.
The capability boundary is a typed error, not a silent gap
When a backend cannot do something its sub-port nominally exposes, it does not return undefined,
log a warning, or no-op. It throws a typed AuthBackendOperationUnsupportedError(name, op, reason) — naming the backend, the operation, and why. This is the same fail-loud discipline the
rest of the framework follows (streams helpers throw StreamUnsupportedOperationError; trigger
defer throws and routes to a DLQ): an unsupported capability is a visible, typed boundary, never
a quiet surprise in production.
// A non-interactive backend declining a session mutation. The error names
// the backend, the operation, and the reason — you find out at the call site,
// not three hops later from a malformed cookie.
const revoke = async (sessionId: string): Promise<void> => {
throw new AuthBackendOperationUnsupportedError(
"workos", // backend name
"sessions.revoke", // the operation
"WorkOS AuthKit owns session lifecycle; revoke via the WorkOS dashboard or API.",
);
};
// At the service edge, the interactive endpoints translate the same boundary
// into a typed HTTP error rather than a stack trace:
// POST /api/v1/auth/signin -> AUTH_PROVIDER_ERROR (502)
// POST /api/v1/auth/callback -> AUTH_PROVIDER_ERROR (502)
Because the boundary is typed, you can program against it. A handler that wants to support both
interactive and hosted backends can branch on whether backend.interactive is present rather than
catching errors after the fact — the optionality of the port and the typed error are two views of
the same fact: this backend does not own sign-in.
One active backend — a hard v1 boundary
The unifying plugin @netscript/plugin-auth composes exactly one active
backend. It reads NETSCRIPT_AUTH_BACKEND (or auth.backend in appsettings), resolves it through a
registry — valid values kv-oauth | workos | better-auth, default kv-oauth — and serves
the auth-api oRPC service on :8094 with five endpoints under /api/v1/auth/:
signin, callback, signout, session, me.
NETSCRIPT_AUTH_BACKEND (default: kv-oauth)
│
▼
┌──────────────────────────────────────────────┐
│ @netscript/plugin-auth (auth-api :8094) │
│ resolves ONE active backend from the registry│
└──────────────────────────────────────────────┘
│ exactly one of:
┌────────────────────────┼─────────────────────────┐
▼ ▼ ▼
auth-kv-oauth auth-workos auth-better-auth
(interactive) (non-interactive) (non-interactive)
full redirect flow verify wos-session verify better-auth
KV sessions hosted AuthKit Prisma-backed
│ │ │
└────────────── all satisfy AuthBackendPort ────────┘
│
▼
Principal (scheme: "custom") → your app
Single-active-backend is a deliberate v1 boundary, not an incidental limitation. It means there is exactly one authenticator answering at a time. The following are explicitly out of scope for v1 — do not design against them yet:
| Name | Type | Description |
|---|---|---|
Multi-active routing |
not supported |
You cannot run kv-oauth and WorkOS simultaneously and route requests between them. One backend is active per deployment. |
Cross-backend account linking |
not supported |
There is no linking of a kv-oauth identity to a WorkOS identity. Each backend owns its own identities. |
Global logout |
not supported |
No fan-out logout across backends or across a user's sessions in other backends. |
Historical replay / paged session mirror |
not supported |
No replay of past auth events into a backend, and no paged mirror of every session across backends. |
Choosing one active backend keeps the model coherent: a single Principal source, a single session
authority, and a single place to reason about who is signed in. Multi-backend identity is a hard
problem (consistent logout, link reconciliation, conflicting session lifetimes); v1 declines it on
purpose rather than ship a half-correct version.
Why this seam — the design in one sentence
Everything above serves one outcome: the provider is an implementation detail your application
never imports. Your handlers read a Principal; the framework owns the AuthBackendPort; a pure
backend fills it; and which backend fills it is a registry decision driven by one environment
variable.
- Capability authors implement a backend by satisfying sub-ports — they think about OAuth and sessions, never about how a host wires them in.
- The plugin composes exactly one backend and exposes a stable five-endpoint service, so the rest of the app sees a fixed surface regardless of provider.
- Your application depends on
Principal/AuthnResultwithscheme: "custom"— so swapping GitHub OAuth for WorkOS is an env-var change and a redeploy, not a refactor.
This is the same shape as the plugin model (a thin integration layer over a pure capability) and the contracts model (the framework owns the boundary, you own the logic) — authentication is just the identity-shaped instance of the pattern.
Glossary
AuthBackendPort— the seam every auth backend satisfies; composed ofproviders,sessions,crypto,principalMappersub-ports plus an optionalinteractiveflow. Defined in@netscript/plugin-auth-core.InteractiveFlowPort— the optional redirect flow (signIn/handleCallback/getSessionId/signOut). Present only on kv-oauth; absent on WorkOS and better-auth.- single-active-backend — the v1 rule that exactly one backend is active per deployment; no multi-active routing, cross-backend linking, global logout, or replay.
Principal/scheme: "custom"— the framework-neutral identity from@netscript/service/auth; auth adapters stamp principals withscheme: "custom".AuthBackendOperationUnsupportedError— the typed error a backend throws when an operation is outside its capability matrix (for example session mutation on a non-interactive backend).- opaque session token — the default WebCrypto HMAC-SHA256 token
(
createHmacSessionTokenCrypto); an opaque string you do not parse, not a JWT.
Where to go next
- The capability: Authentication — the headline API, the
auth-apiservice on :8094, the five endpoints, and the backend matrix at a glance. - Do it: Add authentication — add the
authplugin, choose a backend withNETSCRIPT_AUTH_BACKEND, run the migration, and wire provider env. - Related model: The plugin system — why a thin plugin composes a pure capability, the same shape this auth seam follows.
- Reference: the neutral authentication contract lives in
reference/service/(the/authsubpath:Principal,AuthnResult,AuthenticatorPort).