Authentication
One env var and five endpoints separate a scaffolded workspace from a working OAuth sign-in — and the boundaries fail loud with typed errors instead of degrading to a silent anonymous session. Auth is the part of a backend where "compiles and demos fine" and "actually holds" diverge most easily, so NetScript puts the conventions in the contract rather than in notes an agent has to remember to apply.
NetScript ships authentication as a first-class official plugin: netscript plugin install @netscript/plugin-auth
adds an auth-api oRPC service that boots on port 8094 alongside your workers, sagas, and
triggers. The plugin is pure-backend — it owns the session lifecycle, OAuth/OIDC redirect
flow, and a stable five-endpoint REST/RPC surface, but it does not ship UI. You compose
exactly one active backend (single-active-backend), chosen by an environment variable, and
the service exposes the same contract no matter which backend is wired in.
alpha
The design follows the framework's contracts-first doctrine: a core seam package
(@netscript/plugin-auth-core) defines the AuthBackendPort, three interchangeable adapter
packages implement it, and the @netscript/plugin-auth plugin composes the selected one into a
running service. Of the three adapters, only the KV-OAuth backend implements the full
interactive sign-in/callback flow today; the WorkOS and better-auth adapters are
non-interactive by design.
Where agent-built auth goes wrong
Auth is a security-sensitive surface, and security-sensitive surfaces have a specific
build-efficiency failure shape: the mistake does not stop the build. A missing job queue fails a
request; a mis-wired sign-in flow ships. Supabase's published agent-skills guidance names the
pattern for its own platform — agents building on it skip RLS policies, hallucinate CLI commands,
and create views without security_invoker = true — and its remedy is a skill: reference material
the agent loads so it applies the conventions at the right moment. NetScript takes the other route
for the same problem: move the conventions out of the agent's working memory and into the shipped
contract, so the common wrong turns either don't type-check, don't configure, or fail loud at
runtime with a typed error.
Concretely, the contract closes off the classic failure modes:
- The silent anonymous fallback. An agent wiring auth by hand tends to catch the failure and
degrade to an unauthenticated default.
backend.authenticate()cannot express that: it returns a typedAuthnResult—{ ok: true, principal }or{ ok: false, reason }— never a silent anonymous principal. - Calling a capability the backend doesn't have. Asking a non-interactive backend (WorkOS,
better-auth) to run the interactive sign-in returns a typed
AUTH_PROVIDER_ERROR, and direct session mutations throwAuthBackendOperationUnsupportedError— a capability boundary, not a no-op that looks like success. - Two half-wired providers. There is nothing to reconcile:
NETSCRIPT_AUTH_BACKENDselects exactly one active backend (single-active-backend), and every backend normalizes to the samePrincipal, so downstream authorization code is identical regardless of provider. - Hand-rolled session cookies. The session cookie is
__Host-ns_session; the__Host-prefix makes the browser itself refuse a cookie with aDomain, a non-rootPath, or a plain-HTTP origin — the misconfiguration fails to set rather than silently weakening. - Logging raw subjects. The audit surface (
createAuthTelemetry) redacts by construction — subject hashes, stripped token-bearing claims — and runs as a no-op until a salt is configured, because an un-salted hash would be worse than nothing.
Each item above is the shipped behavior of the published packages, documented mechanism-by-mechanism in the sections below — start with
the authentication model for why the seam is shaped this way.
What it is
Auth is a port-and-adapter seam, not a monolith. The core package
@netscript/plugin-auth-core is contract-only: it defines the AuthBackendPort (and the four
sub-ports it composes — provider registry, session store, token crypto, and principal mapper),
the normalized AuthSession and Principal shapes, the AUTH_SESSION_STATES, the auth oRPC
contract, the config schemas, and the stream-event schemas. It contains no provider SDK code.
Each backend adapter (@netscript/auth-kv-oauth, @netscript/auth-workos,
@netscript/auth-better-auth) implements AuthBackendPort against a concrete identity provider,
and the @netscript/plugin-auth plugin selects exactly one of them at boot via
NETSCRIPT_AUTH_BACKEND and serves it as the auth-api service. Because every backend produces
the same Principal, downstream services authorize identically regardless of which provider is
wired in. The full rationale — why auth is pure-backend, what the single-active boundary buys you,
and how InteractiveFlowPort gates the redirect flow — is in
Learn → / Do →
Add it in two shapes
The fastest path is the provider preset: add the plugin, set NETSCRIPT_AUTH_BACKEND
(it already defaults to kv-oauth), and hand a preset like providers.google({...}) to
createKvOAuthBackend. The advanced shape uses the same factory but defines a custom OIDC
provider explicitly with defineOAuthProvider when your identity provider is not one of the
fourteen built-in presets.
// Add the plugin from the workspace root:
// netscript plugin install @netscript/plugin-auth
// Default backend is already kv-oauth (NETSCRIPT_AUTH_BACKEND=kv-oauth).
import { createKvOAuthBackend, getRequiredEnv, providers } from '@netscript/auth-kv-oauth';
// One of 14 presets: github, google, gitlab, discord, slack, spotify,
// facebook, twitter, auth0, okta, awsCognito, azureAd, logto, clerk.
export const backend = await createKvOAuthBackend({
provider: providers.google({
clientId: getRequiredEnv('NETSCRIPT_AUTH_CLIENT_ID'),
clientSecret: getRequiredEnv('NETSCRIPT_AUTH_CLIENT_SECRET'),
redirectUri: getRequiredEnv('NETSCRIPT_AUTH_REDIRECT_URI'),
}),
});
// backend.name === 'kv-oauth' — the only adapter with backend.interactive.
// Same factory, but define the OIDC provider yourself when your IdP
// is not one of the built-in presets.
import { createKvOAuthBackend, defineOAuthProvider, getRequiredEnv } from '@netscript/auth-kv-oauth';
const provider = defineOAuthProvider({
id: 'my-idp',
clientId: getRequiredEnv('NETSCRIPT_AUTH_CLIENT_ID'),
clientSecret: getRequiredEnv('NETSCRIPT_AUTH_CLIENT_SECRET'),
authorizationEndpoint: getRequiredEnv('NETSCRIPT_AUTH_AUTHORIZATION_ENDPOINT'),
tokenEndpoint: getRequiredEnv('NETSCRIPT_AUTH_TOKEN_ENDPOINT'),
userInfoEndpoint: getRequiredEnv('NETSCRIPT_AUTH_USERINFO_ENDPOINT'),
redirectUri: getRequiredEnv('NETSCRIPT_AUTH_REDIRECT_URI'),
scopes: ['openid', 'profile', 'email'],
});
export const backend = await createKvOAuthBackend({ provider });
Sign in, resolve the session, sign out
Once the plugin is wired, your front end or a typed client drives the five-endpoint surface. A sign-in begins the redirect, the provider returns to your callback, the service mints a session and sets the cookie, and every subsequent request resolves the current session from that cookie. The snippet below is a copy-ready client flow against the REST surface.
// app/sign-in.ts — drive the auth-api REST surface from the browser
const AUTH = 'http://localhost:8094/api/v1/auth';
// 1. Begin sign-in. The kv-oauth backend responds with a provider redirect;
// follow it to authenticate with the IdP.
location.href = `${AUTH}/signin`;
// 2. The IdP redirects back to /api/v1/auth/callback, which mints the
// session and sets the __Host-ns_session cookie automatically.
// 3. Resolve the current session on any later request (cookie is sent
// automatically; the response is { authenticated, user, session }).
const me = await fetch(`${AUTH}/me`, { credentials: 'include' })
.then((r) => r.json());
if (me.authenticated) {
console.log('signed in as', me.user.subject, '— state', me.session.state);
}
// 4. Sign out — revokes the session and clears the cookie.
await fetch(`${AUTH}/signout`, { method: 'POST', credentials: 'include' });
// server/auth-guard.ts — use the resolved backend directly in app code
import { AuthBackendOperationUnsupportedError } from '@netscript/plugin-auth-core/ports';
import { backend } from './backend.ts';
// Resolve the current session from an incoming Request via the session store.
const session = await backend.sessions.getSession({ request: incoming });
if (session?.state === 'active') {
// Map the normalized session to a NetScript Principal for authorization.
const { principal } = backend.principalMapper.mapSessionToPrincipal(session);
console.log(principal.subject, principal.scopes, principal.roles);
}
// Interactive sign-in is optional capability — guard before you call it.
if (!backend.interactive) {
throw new AuthBackendOperationUnsupportedError(
backend.name,
'interactive.signIn',
'The active backend authenticates sessions only.',
);
}
const redirect = await backend.interactive.signIn(incoming);
Key types first — the core auth contract
Every backend normalizes to the same two shapes the rest of NetScript depends on: an
AuthSession (what the store persists) and a Principal (what services authorize against). The
AuthSession is mapped to a Principal by the backend's principalMapper, and the
authenticate() method on every backend returns an AuthnResult. These are the contract-only
types from @netscript/plugin-auth-core — confirmed against the live export surface.
| Name | Type | Description |
|---|---|---|
subject |
string |
Stable subject identifier — a user id, service id, or API-key id. |
scopes |
readonly string[] |
Granted scopes for per-operation RPC/REST permission checks. |
roles |
readonly string[] |
Granted roles for role-based access checks. |
scheme |
'api-key' | 'bearer' | 'trusted-header' | 'custom' |
How the principal was established. Auth-plugin backends use 'custom' with the claim bag below. |
claims |
Readonly |
Opaque verified claims — organization/tenant id, session id, provider permissions, or normalized WorkOS/better-auth metadata. |
| Name | Type | Description |
|---|---|---|
id |
string |
Stable session id; the value an opaque session token resolves to. |
userId / subject |
string |
User id and stable subject the session belongs to. |
state |
AuthSessionState |
Lifecycle state: 'active' | 'expired' | 'revoked' (from AUTH_SESSION_STATES). |
scopes / roles |
readonly string[] |
Scopes and roles carried into the mapped Principal. |
claims |
Readonly |
Verified provider claims preserved on the session. |
issuedAt / expiresAt |
string (ISO) |
Issue and expiry timestamps; refreshedAt / revokedAt are set on those transitions. |
accountId / providerId |
string? |
Optional linked provider account and provider id. |
traceparent / tracestate |
string? |
Optional W3C trace context carried for audit correlation. |
| Name | Type | Description |
|---|---|---|
{ ok: true, principal } |
success |
A resolved Principal; optional responseHeaders and setCookies the service flushes to the client. |
{ ok: false, reason } |
rejection |
A typed rejection reason — fail-loud, never a silent anonymous principal. |
The service surface
The plugin's service is named auth-api and is built with @netscript/service's oRPC builder
(createService(...).withRPC()), not raw Hono. It mounts a public REST surface at
/api/v1/auth/* and the equivalent oRPC surface at /api/rpc/v1/auth/*, plus standard
/health/live and /health/ready probes.
| Name | Type | Description |
|---|---|---|
signin |
POST |
Begins the interactive sign-in. Requires backend.interactive; on WorkOS / better-auth it returns AUTH_PROVIDER_ERROR (502) because those backends are non-interactive. |
callback |
POST |
Completes the OAuth/OIDC redirect, mints the session, sets the session cookie. Interactive-only — same non-interactive caveat as signin. |
signout |
POST |
Revokes the current session and clears the cookie. |
session |
GET |
Resolves the current AuthSession from the cookie (active | expired | revoked), refreshing on read when policy allows. |
me |
GET |
Returns { authenticated: true, user, session } when a valid active session exists, or { authenticated: false } (HTTP 200) when there is none. |
The three backends
All three implement the same AuthBackendPort, so the auth-api contract is identical across
them. They differ in one decisive capability: whether they expose an interactive
sign-in/callback flow. Each ships a single factory that returns an AuthBackendPort.
| Name | Type | Description |
|---|---|---|
@netscript/auth-kv-oauth |
createKvOAuthBackend(options) |
Interactive (default). Full OAuth/OIDC redirect flow, Deno KV session store, real createSession / refreshSession / revokeSession, refresh-on-read (refreshMode / refreshSkewMs), opaque session token, __Host-ns_session cookie, AES-256-GCM token-at-rest. Needs a provider preset or defineOAuthProvider config. |
@netscript/auth-workos |
createWorkosBackend({ workos, cookiePassword }) |
Non-interactive. WorkOS AuthKit sealed sessions, stateless verification. Needs a WorkOS SDK client and a cookie password. signin / callback return AUTH_PROVIDER_ERROR; session mutations throw AuthBackendOperationUnsupportedError. |
@netscript/auth-better-auth |
createBetterAuthBackend({ auth, sessionTokenSecret }) |
Non-interactive. Validates externally-issued sessions via auth.api.getSession over a Prisma store (createNetscriptBetterAuth wires the better-auth Prisma adapter). Needs a better-auth instance and a token secret. |
How auth integrates with services
The auth plugin (this page) signs human users in and resolves their sessions. A
NetScript service gates its own routes with the separate, provider-agnostic
@netscript/service/auth seam — .withAuthn() resolves a Principal and .withAuthz() makes
an authorization decision from it. Both layers speak the same Principal type, so they compose:
the auth plugin establishes identity, and a service's .withAuthn({ authenticator }) can trust a
backend's authenticator to turn a request into that Principal. By default a service protects
/api and leaves /health anonymous. The full builder seam — createStaticCredentialAuthenticator,
createTrustedHeaderAuthenticator, createScopeAuthorizer, and the preset
defineService(router, { auth: { authn, authz } }) form — is documented on the services hub.
Database & runtime events
The plugin contributes a package-provided auth.prisma schema with four
better-auth-shaped models — User → auth_users, Session → auth_sessions,
Account → auth_accounts, Verification → auth_verifications. These tables back the
better-auth adapter; kv-oauth keeps its sessions in Deno KV, and WorkOS is stateless. As with
every plugin schema, you bring the tables to life by running the database workflow after Aspire
is up — see Database migrations.
The plugin also emits five durable auth.* runtime events through the durable-streams runtime —
the AUTH_STREAM_EVENT_TYPES: auth.signin.started, auth.signin.failed,
auth.token.refreshed, auth.session.revoked, and auth.oidc.completed. The session projection
is described by the authStreamSchema entity stream. For the streams runtime itself see
Endpoints & ports
| Name | Type | Description |
|---|---|---|
:8094 |
port |
auth-api default port (AUTH_API_DEFAULT_PORT). Family: workers :8091, sagas :8092, triggers :8093, auth :8094. |
auth-api |
service name |
AUTH_API_SERVICE_NAME — the service contribution the auth plugin (AUTH_PLUGIN_ID 'auth') adds. |
/api/v1/auth/* |
REST |
Public REST surface: signin, callback, signout, session, me. |
/api/rpc/v1/auth/* |
oRPC |
The oRPC surface for the same five operations, for typed NetScript clients. |
/health/live |
HTTP |
Liveness probe; /health/ready for readiness. OpenAPI + docs served via .withOpenAPI()/.withDocs(). |
NETSCRIPT_AUTH_BACKEND |
env |
Selects the single active backend: kv-oauth (default) | workos | better-auth. |
Production notes
Reference
The auth runtime is a @netscript/service; the auth plugin, core contract package, and backend
adapters now have dedicated generated reference pages.