Skip to main content
Alpha

Protect routes with authz

Your workspace service still answers anyone. This chapter closes the loop: you gate its routes with the real, tested .withAuthn() / .withAuthz() seam from @netscript/service/auth. An authenticated, correctly-scoped request succeeds with a 200; an unauthenticated one is rejected with a 401. This is the seam NetScript actually ships — not a bespoke middleware you write yourself.

  1. 1 · Scaffold
  2. 2 · Auth
  3. 3 · Workspace data
  4. 4 · Provision job
  5. 5 · Route authz
  6. 6 · Deploy

What you will build

A guarded workspace service: .withAuthn() turns each request into a Principal, and .withAuthz() decides whether that principal may reach the route. By the end an unauthenticated request to a guarded path returns 401 UNAUTHORIZED, a request with the wrong scope returns 403 FORBIDDEN, and a correctly-scoped request returns 200 — exactly the behavior the framework's own test asserts.

Before you begin

You need the workspace service from chapter 1 and the provision job from chapter 4. The route-authz seam is part of @netscript/service itself — it does not need the auth plugin or Aspire to type-check, though you run the service under Aspire to exercise it live. Confirm the workspace builds:

# In my-workspace/
deno task check

Step 1 — The seam: authn resolves, authz decides

@netscript/service/auth gives you two factories and two builder stages:

  • .withAuthn({ authenticator }) runs an AuthenticatorPort that turns an incoming request into a Principal (subject, scopes, roles) — or rejects it. By default it guards /api and leaves /health anonymous.
  • .withAuthz({ authorizer }) runs an AuthorizerPort that makes an allow/deny decision from that Principal. createScopeAuthorizer matches a request and requires named scopes.
@netscript/service/auth — the route-authz surface
NameTypeDescription
createStaticCredentialAuthenticator(opts) AuthenticatorPort Maps bearer tokens to principals — each credential carries a subject, scopes, and roles. Good for tests and machine-to-machine callers.
createTrustedHeaderAuthenticator(opts) AuthenticatorPort Trusts a principal asserted by an upstream gateway via request headers.
createScopeAuthorizer(opts) AuthorizerPort Rules of { match, requireScopes } — the principal must carry every required scope for a matched route.
.withAuthn({ authenticator, protect?, allowAnonymous? }) builder stage protect defaults to ['/api']; allowAnonymous defaults to ['/health'].
.withAuthz({ authorizer, denyByDefault? }) builder stage denyByDefault defaults to true — fail closed when no decision is reachable.

Step 2 — Build the authenticator and authorizer

Define the credentials and the scope rule. This mirrors the framework's own builder-auth_test.ts: a read credential carries the workspace:read scope; the rule requires that scope on /api/workspace paths:

// services/workspace/src/auth.ts
import {
  createScopeAuthorizer,
  createStaticCredentialAuthenticator,
} from '@netscript/service/auth';

export const authenticator = createStaticCredentialAuthenticator({
  credentials: {
    read: {
      subject: 'user:reader',
      scopes: ['workspace:read'],
      roles: ['reader'],
    },
    write: {
      subject: 'user:writer',
      scopes: ['workspace:write'],
      roles: ['writer'],
    },
  },
});

export const authorizer = createScopeAuthorizer({
  rules: [{
    match: (request) => request.path.startsWith('/api/workspace'),
    requireScopes: ['workspace:read'],
  }],
});

Step 3 — Guard the service

Apply the two stages on the service builder. Add the .withAuthz() and .withAuthn() calls to your workspace service. The route handler reads the resolved Principal off the request context — it only ever runs for an authenticated, authorized caller:

// services/workspace/src/main.ts
import { createService } from '@netscript/service';
import type { Principal } from '@netscript/service/auth';
import { authenticator, authorizer } from './auth.ts';

const app = createService({}, { name: 'workspace' })
  .route('get', '/api/workspace', (c: unknown) => {
    const ctx = c as { get(key: string): unknown; json(data: unknown): Response };
    // The authn stage injected the resolved principal into the context.
    const principal = ctx.get('principal') as Principal;
    return ctx.json({ subject: principal.subject });
  })
  .withAuthz({ authorizer })
  .withAuthn({ authenticator })
  .build();

export { app };

Step 4 — Exercise the three outcomes

The seam produces three distinct responses, each a real assertion in the framework's builder-auth_test.ts. Drive them against the running service (start it under Aspire, or call app.request(...) in a test):

# No Authorization header → authn rejects before the handler runs.
curl -i http://localhost:3001/api/workspace

# HTTP/1.1 401 Unauthorized
# { "error": "UNAUTHORIZED", "message": "missing-credential" }
# 'write' authenticates (it is a valid credential) but lacks workspace:read,
# so authz denies the scope-guarded route.
curl -i -H 'authorization: Bearer write' http://localhost:3001/api/workspace

# HTTP/1.1 403 Forbidden
# { "error": "FORBIDDEN", "message": "authz.missing-scope:workspace:read" }
# 'read' carries workspace:read → authn resolves the principal, authz allows it.
curl -i -H 'authorization: Bearer read' http://localhost:3001/api/workspace

# HTTP/1.1 200 OK
# { "subject": "user:reader" }

Verify your progress

The three curl calls above are the verification. The unauthenticated call must fail closed, and the scoped call must succeed:

  • [ ] services/workspace/src/auth.ts defines the authenticator and a scope authorizer.
  • [ ] The workspace service applies .withAuthn() and .withAuthz().
  • [ ] An unauthenticated GET /api/workspace returns 401 UNAUTHORIZED (missing-credential).
  • [ ] Bearer write returns 403 FORBIDDEN (authz.missing-scope:workspace:read).
  • [ ] Bearer read returns 200 with { "subject": "user:reader" }.
  • [ ] GET /health still answers without a credential.

What you built

A guarded workspace service that resolves a Principal with .withAuthn() and authorizes by scope with .withAuthz() — proven by a 401 for an anonymous request, a 403 for the wrong scope, and a 200 for a correctly-scoped one. You also saw the boundary: this is route-level scope authz, not org/role RBAC. The last chapter runs the whole authenticated workspace locally under Aspire.