# The pure-backend auth model

*This essay is the **why** — the model and its trade-offs. The day-to-day *how* (guides, recipes, API) lives in **Build › [Identity & Access](https://rickylabs.github.io/netscript/identity-access/)**.*

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](https://rickylabs.github.io/netscript/capabilities/auth/); when you want to do the task, follow [Add authentication](https://rickylabs.github.io/netscript/identity-access/how-to/add-authentication/); when you want exact exported symbols, follow [`reference/service/`](https://rickylabs.github.io/netscript/reference/service/) and the auth adapter references it links to.

> Alpha status
>
> The CLI scaffold pins exact alpha specifiers like
>
> jsr:@netscript/plugin-auth-core@0.0.6
>
> . Auth telemetry is real: an opt-in, redacted audit surface ships via
>
> createAuthTelemetry
>
> , enabled when a
>
> subjectHashSalt
>
> is configured. The model below is the authentication seam; for that audit surface and its salt requirement see
>
> [Observability](https://rickylabs.github.io/netscript/netscript/explanation/observability/) .

## 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](https://rickylabs.github.io/netscript/explanation/contracts/) and [plugins](https://rickylabs.github.io/netscript/explanation/plugin-system/): 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`](https://rickylabs.github.io/netscript/capabilities/auth/) 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.

**AuthBackendPort — the seam every backend satisfies (from `@netscript/plugin-auth-core/ports`)**

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

> Why a port and not a base class
>
> A base class would force every backend to inherit a session store, a crypto scheme, and a flow it might not have. A
>
> port
>
> lets each backend supply only the sub-ports it owns — WorkOS brings its AuthKit sealed cookie and skips
>
> interactive
>
> entirely, while kv-oauth brings a full KV-backed session store and a real OAuth redirect. Same seam, different fills. This is the doctrine's
>
> composition over inheritance
>
> applied to identity.

## 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`](https://rickylabs.github.io/netscript/reference/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.

```ts
// 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 };
```

```ts
// 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 distinct capability profiles. Only **kv-oauth** is a full interactive backend; WorkOS and better-auth are **non-interactive by design**.

**The three backends and where they diverge**

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

> Non-Interactive Adapter Design Boundary
>
> Under non-interactive backends (WorkOS and better-auth), NetScript enforces a strict verification-only boundary: the runtime verifies existing sessions via the
>
> session
>
> ,
>
> me
>
> , and
>
> signout
>
> endpoints, but does not drive user-facing sign-in redirects or execute session mutations. This design isolates redirect-based authentication from core verification logic. So calling
>
> /api/v1/auth/signin
>
> or
>
> /api/v1/auth/callback
>
> returns a typed
>
> AUTH_PROVIDER_ERROR
>
> (HTTP 502), and mutations throw
>
> AuthBackendOperationUnsupportedError
>
> . For direct redirect management, use the
>
> kv-oauth
>
> backend, or implement sign-in client-side using your provider's SDK before verification. Extending interactive flow capability to the better-auth backend is tracked under roadmap item R2.

## 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`): an unsupported capability is a *visible, typed boundary*, never a quiet surprise in production.

```ts
// 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`](https://rickylabs.github.io/netscript/capabilities/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`.

```text
                    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. This boundary is drawn to keep the identity model coherent: a single `Principal` source, a single session authority, and a single place to reason about who is signed in. Multi-backend identity synchronization (e.g. cross-provider logout, account linking, conflicting session lifetimes) requires a complex state orchestrator that is decoupled from NetScript's core validation layer.

The following table details what lies on either side of this v1 deployment boundary:

**V1 Boundary Mapping (single-active-backend)**

| Name | Type | Description |
| --- | --- | --- |
| `Multi-active routing` | `Out of scope` | Routing requests across multiple backends simultaneously is not supported. Use a single active backend configuration per deployment. |
| `Cross-backend account linking` | `Out of scope` | Identity association between separate providers must be handled upstream; each adapter manages its own independent identities. |
| `Global logout` | `Out of scope` | Session revocation is local to the active backend store. Cross-backend token revocation must be handled by the provider's management console. |
| `Historical replay / session mirroring` | `Out of scope` | Synchronizing historical login logs or mirroring session state between backends is deferred to future identity orchestration layers. |

## 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` / `AuthnResult` with `scheme: "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](https://rickylabs.github.io/netscript/explanation/plugin-system/) (a thin integration layer over a pure capability) and the [contracts model](https://rickylabs.github.io/netscript/explanation/contracts/) (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 of `providers`, `sessions`, `crypto`, `principalMapper` sub-ports plus an optional `interactive` flow. 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`](https://rickylabs.github.io/netscript/reference/service/)`/auth`; auth adapters stamp principals with `scheme: "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](https://rickylabs.github.io/netscript/capabilities/auth/) — the headline API, the `auth-api` service on :8094, the five endpoints, and the backend matrix at a glance.
- **Do it:** [Add authentication](https://rickylabs.github.io/netscript/identity-access/how-to/add-authentication/) — add the `auth` plugin, choose a backend with `NETSCRIPT_AUTH_BACKEND`, run the migration, and wire provider env.
- **Related model:** [The plugin system](https://rickylabs.github.io/netscript/explanation/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/`](https://rickylabs.github.io/netscript/reference/service/) (the `/auth` subpath: `Principal`, `AuthnResult`, `AuthenticatorPort`).

[The plugin system](https://rickylabs.github.io/netscript/netscript/explanation/plugin-system/) [Durability model](https://rickylabs.github.io/netscript/netscript/explanation/durability-model/)
