Skip to content

Pre-release. v0.1 is not out yet, so there is nothing to install and no public source to clone — the quickstart builds from a checkout.

@onbe/server

Section 9 of the contract, implemented once: fetch and cache the control plane’s keys, validate the token, enforce the route’s scope, introspect when the token or the route says to, log the human and the agent on every request, and refuse a chain deeper than this server allows.

The package is @onbe/server, published from a release tag. Nothing outside the platform: WebCrypto and fetch, Node 20 or later, ESM only. Express and Fastify are not dependencies; the adapters are typed to the part of a request they touch.

Protect a tool server is the narrative version.

const onbe = new OnbeToolServer({
issuer: 'https://onbe.internal.example.com',
audience: 'https://jira.internal',
});
Option Type Default What it is
issuer string The control plane’s issuer URL
audience string What this server is: the aud a token must carry
requireActor boolean true Only agents acting for a human
maxDelegationDepth number 1 Longest act chain accepted. Raise it deliberately
clockSkewSeconds number 60 Skew tolerated on exp, nbf and iat
timeoutMs number 10000 Ceiling on a call to the control plane
realm string the audience Named in WWW-Authenticate
log (event: AccessEvent) => void one JSON line on stdout Where every request is recorded
fetch, now the globals For tests
Method Returns What it does
verifyToken(token) Promise<TaskToken> Signature, iss, aud, exp, and the claim shape
authenticate(authorization) Promise<TaskToken> The same, from an Authorization header
authorize(claims, policy, options?) Promise<OnbeAuthError | undefined> The route’s rules. undefined means allowed. Does not log
guard(authorization, policy, route) Promise<TaskToken> Both, and logs the decision either way. Throws the refusal
log(route, claims, denial?) void One AccessEvent, when you did the checks yourself
refusal(error) Refusal Status, headers and body for anything thrown here

guard is the one to reach for. The others exist because a transport that is not HTTP still has to do the same things in the same order.

app.post(
'/issues/:id/comments',
onbeExpress(onbe, { scope: 'jira:comment', highRisk: true }),
handler,
);
Field Type What it does
scope string | string[] Every scope listed must be present. Omitted means any verified token
highRisk boolean Also ask the control plane on every request whether the token is still active, instead of trusting the local check until it expires
requireActor boolean Overrides the server’s default for this route
maxDelegationDepth number Overrides the server’s default for this route

An empty scope — an empty string as much as an empty array — is refused rather than treated as “no scope needed”: a policy whose values went missing on the way in is a configuration accident, and serving it would be serving whatever was left.

highRisk is not the only way introspection happens. A token whose audience the agent’s registration lists in high_risk_audiences carries introspect_required, and this server honours it whether or not the route was marked — so the operator’s decision arrives without anybody editing this file. AuthorizeOptions.alreadyIntrospected exists for a caller that guards at two levels and does not want to pay for the round trip twice.

What a verified token looks like on this side:

interface TaskToken {
sub: string; // the human, never an agent
act?: Actor; // the agent, when one is acting
scopes: string[];
audience: string;
jti: string;
taskId?: string;
exp: number; // seconds since the epoch
claims: Record<string, unknown>; // everything else, introspect_required included
token: string; // a credential: never log it
}

Actor is { sub, depth, instance?, act? }: the agent, its depth, the copy of it that asserted instance if any, and the actor it acts for when the chain is deeper than one. A chain whose depths do not descend by one is malformed_token.

claimsOf(request) pulls it back out of an Express request after the middleware ran.

Every refusal is an OnbeAuthError with a status, a stable machine-readable reason, and a message that names the reason and never the token. refusal(error) turns one into the status, WWW-Authenticate header and JSON body to send.

Reason Status What happened
missing_token 401 No bearer token
malformed_token 401 Not a JWT, or not one with the claims a task token has
unsupported_algorithm 401 Signed with something this server does not accept
unknown_key 401 No such kid in the control plane’s JWKS
invalid_signature 401 It did not verify
wrong_type 401 Not a task token
wrong_issuer 401 From a different control plane
wrong_audience 401 For a different tool server
expired, not_yet_valid 401 Outside its lifetime, after clock skew
no_subject 401 No sub
subject_is_agent 401 An agent in sub. Delegation, not impersonation
no_actor 401 No act, where the route requires one
delegation_too_deep 403 A chain longer than this server accepts
unknown_route 403 A route with no policy: nobody may call it
insufficient_scope 403 The token lacks a scope the route requires
not_active 401 Introspection said the token is revoked or its task is over
introspection_unavailable 503 The control plane could not be asked
keys_unavailable 503 Its keys could not be fetched

The two 503s are the ones worth noticing: they mean this server could not reach the control plane, not that the caller did anything wrong. It fails closed — a token that could not be checked is not served — and says so with a status that tells the caller to try again.

not_active means the control plane did not say active: true for this very token — a sub or jti that does not match counts as not active — and carries the revocation_reason if one was given. A 503 carries no WWW-Authenticate; a 401/403 does, with error_description made safe for a header.

One AccessEvent per request, allowed or refused, as a JSON line on stdout by default:

{
"event": "request",
"at": "2026-09-12T14:31:05.412Z",
"route": "POST /issues/:id/comments",
"decision": "deny",
"reason": "insufficient_scope",
"sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"act": "agent:jira-triage",
"depth": 1,
"task_id": "task_01HQZX9K4M",
"jti": "tok_01HQZX9K5P"
}

sub and act on every line is step 5 of the contract and the step people skip. No token, no query string, no key ever appears in one. Pass log to send them wherever your other logs go.

onbeExpress(onbe, policy) is Express middleware; onbeFastify(onbe, policy) is a Fastify onRequest hook, and onbeFastifyPlugin registers it for a whole scope. Both are the same class with the plumbing of one framework around it, and both put the verified claims on the request.

onbeFastifyPlugin(onbe, { unguarded }) takes each route’s policy from its config.onbe and refuses a route with none (unknown_route). unguarded names routes by /healthz or GET /healthz; a route that has a policy is guarded whatever unguarded says.

verifyTaskToken(token, options) is step 2 on its own, and JwksCache is step 1 on its own, for a caller doing the rest itself; OnbeToolServer builds its own cache and does not take one. A cache serves a fetched set for ten minutes, fetches again when a token names a kid it does not know — at most every thirty seconds, and a request that arrives during that fetch waits for it rather than being refused — and keeps serving the last good set if a fetch fails.

OnbePre-release. v0.1 is not out yet.

© 2026 Onbe