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.
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 anAuthenticatorPortthat turns an incoming request into aPrincipal(subject, scopes, roles) — or rejects it. By default it guards/apiand leaves/healthanonymous..withAuthz({ authorizer })runs anAuthorizerPortthat makes an allow/deny decision from thatPrincipal.createScopeAuthorizermatches a request and requires named scopes.
| Name | Type | Description |
|---|---|---|
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.tsdefines the authenticator and a scope authorizer. - [ ] The
workspaceservice applies.withAuthn()and.withAuthz(). - [ ] An unauthenticated
GET /api/workspacereturns401 UNAUTHORIZED(missing-credential). - [ ]
Bearer writereturns403 FORBIDDEN(authz.missing-scope:workspace:read). - [ ]
Bearer readreturns200with{ "subject": "user:reader" }. - [ ]
GET /healthstill 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.