IDPartnersidp-auth-peps · reference
sdk/node/ · TypeScript

The Node SDK

@id-partners/authzen-pep is the enforcement point for when there is no gateway: an AuthZEN client, Express middleware, an MCP guard, and a federation entity, in one package with no runtime dependencies. Every setting is an option in code; this page lists all of them with their defaults, then gives complete programs.

There is no screen for this component. Configuration is the option objects below. What they produce is the verdict, the challenge on the wire, and the context handed to the PDP, all shown in the samples.

Install

npm install @id-partners/authzen-pep

import { AuthzenClient, FederationEntity } from '@id-partners/authzen-pep';
import { authzenMiddleware, pathMapper } from '@id-partners/authzen-pep/express';
import { McpGuard } from '@id-partners/authzen-pep/mcp';

Node 22 or later. ESM only: there is no CommonJS build. Types ship with the package, and so does the source its maps point at.

Until a version is on npm, install the package attached to its GitHub release: npm install ./id-partners-authzen-pep-<version>.tgz.

Upgrading to 0.4.0

0.4.0 closes every way the SDK had of letting a request through that no PDP judged. A configuration that would trust something nobody checked now refuses to start; allowInsecure: true is the escape hatch everywhere, for demos and development, and it says so once at startup.

What changedWhat to do
Node 22 or later; ESM only.Move off Node 20, which is end of life.
authzenMiddleware throws without verifyToken. A verifier that throws is a 401, not a 502.Pass a real verifier. allowInsecure: true decodes without verifying, for demos only.
map() returning undefined is a mapping error (400).Return exactly null to let a request through without the PDP.
forwardHeaders sets X-Auth-* on req.headers, not the response. Client copies are stripped from every request let through.Read the identity from the request in your handler; stop reading it from the response.
applyDefaultMappings defaults to true: every MCP method is governed.Make sure your PDP answers for tools/list, initialize and undeclared tools, or pass false to keep the old pass-through.
Delegate mode needs upstreamUrl and delegate.apiKey, and sends every MCP request to coaz-pep.Add both. Before, coaz-pep was never told which server to read tools/list from, and permitted every tools/call.
Resource-mode discovery needs pdpAllowlist and resourceAllowlist.List the PDPs a resource may name and the resources whose documents are read.
Only a resource that publishes nothing (404) falls back to the static PDP; an outage or an invalid document fails its layer by its own mode.Nothing, unless you relied on a broken document quietly meaning "use the static PDP".
A PDP whose metadata cannot be read is unavailable, not called at the default paths. The last good copy of either document is served for up to one more TTL.Nothing, unless a PDP's metadata endpoint is down while its evaluation endpoint is up: fix the metadata endpoint, or mark that layer fail-open.
Fail-open covers an unavailable PDP only. A 3xx, a 4xx, an answer that is not a decision and a request that cannot be encoded never open.Nothing: a refusal used to be skipped as an outage.
A permit is the JSON boolean true; a boxcar answer needs one decision per evaluation sent.Nothing, unless your PDP answers "true".
A PDP failure's reason is a fixed phrase; the specifics are in verdict.detail. failedOpen and X-PDP-Fail-Open carry layer identifiers only.Log detail from onDecision if you logged reason for diagnosis.
pathMapper matches decoded, case-insensitively, and answers HEAD with a GET rule.Pass caseSensitive: true if your router is.
The PEP-owned context keys (access_token, resource_metadata, resource_metadata_source, request) cannot be supplied by a mapping or caller.Pass them as per-call options.
McpVerdict gains response, upstreamHeaders, responseHeaders and message; wrap() hands its handler (rpc, verdict).Send response on a deny; apply upstreamHeaders to what a gateway forwards.
FederationEntity refuses http identifiers, RSA under 2048 bits and non-positive lifetimes; signed_metadata carries an exp.Use https and a 2048-bit or EC key.

AuthzenClient

new AuthzenClient(options). The client is the one thing every other piece is built on; the middleware and the guard accept either a client or these options.

OptionType · defaultMeaning
url requiredstringThe static PDP's base URL, absolute http(s). Without discovery the AuthZEN default paths are appended; with it, the PDP a resource that publishes nothing falls back to.
apiKeystringSent as Authorization: Bearer to url. A discovered PDP never receives it.
discoveryClientDiscoveryOptions | PdpResolverPDP discovery, below, or a resolver of your own ({ resolve(resource), resolvePdp(pdp) }). Absent means off: no HTTP beyond the evaluation itself.
layersArray<string | LayerSpec> · ['resource']The ordered PDPs every call asks unless it overrides them: 'static' (this client's url, regardless of discovery), 'resource' (what discovery finds for the call's resource), or a PDP identifier, each optionally suffixed fail-open or fail-closed, or as { name, failOpen } with a boolean failOpen. Every layer must permit; the first that does not is the verdict. An entry that cannot be read throws, fail-open or not.
failMode'open' | 'closed' · 'closed'What a layer does when its PDP is unavailable, unless the layer says for itself. closed makes the verdict a pdp_error. open skips the layer; if every layer was skipped the verdict is a permit with failedOpen naming what was skipped. A refusal never opens; a deny is a decision.
timeoutMsnumber · 1500Per-request timeout. A PEP sits in the request path.
headersRecord<string,string>Extra headers on every PDP call: tracing, tenant routing.
fetchtypeof fetch · global fetchSwapped out in tests.
onTrace(trace: PdpTrace) => voidCalled with every PDP exchange, including its outcome — permit, deny, refusal or unavailable — so a refusal and an outage are logged apart. Never throws into the request path.

Methods: evaluate(request, options), evaluateAll(batch, options) (a boxcar, folded to one verdict, first deny wins so its advice survives; an empty batch is refused before it is sent), evaluations, searchSubject, searchResource. The PDP POST never follows a redirect and refuses an answer over 1 MiB.

Discovery options

discovery: { … } on the client, or new PdpDiscovery({ staticPdp, … }) directly. The rules are the Go PEP's: the resource's echoed resource must be byte-identical, the PDP's policy_decision_point must equal the identifier it was fetched from, a PDP without metadata gets the default paths, a batch is never sent to a PDP that advertises no access_evaluations_endpoint, and a URL outside an allowlist never falls through to a weaker source.

OptionType · defaultMeaning
mode'off' | 'authzen' | 'resource' · 'off'authzen reads the static PDP's /.well-known/authzen-configuration; resource follows a call's resource to its RFC 9728 document for the PDP that decides for it, then that PDP's metadata. A resource that publishes nothing (404, or a document naming no PDP) gets the static PDP; one whose document cannot be read — an outage, or an invalid document — fails its layer by its own mode. No federation mode in the SDK; sources is the seam for one.
staticPdp required on PdpDiscoverystringThe configured PDP: decides for a resource that publishes nothing, and is always permitted. The client fills it from url.
apiKeysRecord<identifier, bearer>Which bearer goes to which PDP. The client seeds { [url]: apiKey }.
pdpAllowlist required in resource modestring[]The PDPs a resource may name, and the layers a route may, as prefixes matched at a path boundary. staticPdp is always permitted. An entry with a path permits its own well-known.
resourceAllowlist required in resource modestring[]The resources whose metadata is read, as prefixes.
allowInsecureboolean · falseThe escape hatch, for development: allows http for discovered URLs (same-origin http as staticPdp always is) and lets resource mode start without its allowlists. Logged once at construction.
ttlMsnumber · 300000Cache TTL for resource and PDP metadata.
minRefreshMsnumber · 30000How long a failure is remembered before it is retried. The last good document is served through it, for up to one more TTL; a PDP whose metadata has never been read is unavailable meanwhile, never guessed at with the default paths.
maxEntriesnumber · 1024Cache bound.
timeoutMsnumber · 3000Per metadata fetch. A document over 1 MiB, a redirect or a 4xx other than 404 is refused rather than taken for an outage.
sourcesMetadataSource[]Overrides the mode-derived sources. Each is { name, lookup(resource) }.
onWarning(message) => void · console.warnOne line per degraded step, once per retry window.
fetch, nowSwapped out in tests.

Per-call options

EvaluateOptions, the second argument of evaluate and evaluateAll. The middleware and the guard fill these from the request.

OptionTypeMeaning
resourcestringThe protected resource's identifier (RFC 8707), the key discovery starts from.
accessTokenstringThe raw token, forwarded as context.access_token so the PDP can examine it itself. Only over a TLS, authenticated PDP connection.
request{ method, path }The endpoint actually hit, forwarded as context.request.
layersArray<string | LayerSpec>Overrides the client's layers for this call.
failMode'open' | 'closed'Overrides the client's fail mode for this call.

Whatever document named the PDP is forwarded verbatim as context.resource_metadata with context.resource_metadata_source. Those two, request and access_token are the PEP's: the same keys in a mapped or supplied context, at the top level or in a boxcar entry, are removed, and only what the PEP forwards is sent. The SDK enforces none of it.

What a PDP call can end in

Four outcomes, and a fail-open layer may skip only one of them.

OutcomeWhenFail-open layer
permitThe decision is the JSON boolean true; for a boxcar, exactly one per evaluation sent, every one true.—
denyThe decision is false.Never skipped: a deny is a decision.
unavailableA network error, a timeout, a 5xx, a 429; a PDP with no batch endpoint asked a boxcar; resource metadata that cannot be read.Skipped, and named in failedOpen and X-PDP-Fail-Open.
refusalA 3xx or 4xx, a 2xx that is not a decision ("true", 1, {}, a short boxcar), an answer over 1 MiB, a request that cannot be encoded (Infinity, NaN, a cycle), an unusable URL, an allowlist miss.Never skipped.

Express middleware

authzenMiddleware(options). On permit the request continues and req.authz carries { claims, verdict, request }. On deny nothing downstream runs and the response is the challenge. An error thrown downstream after a permit is the router's, not a PEP failure.

OptionType · defaultMeaning
client requiredAuthzenClient | AuthzenClientOptionsAn existing client, or the options to build one.
map required(req, claims) => EvaluationRequest | nullMap a request to an evaluation. Return exactly null to let it through without asking the PDP, for health checks only. undefined, or anything that is not an evaluation, is a mapping error (400); so is a throw. Async, so it may look things up.
verifyToken required(token, req) => Promise<claims | null>Verify the token and return its claims. null, a throw or anything that is not a claims object is a 401. Plug in jose's jwtVerify. The middleware will not build without it.
allowInsecureboolean · falseBuild without verifyToken and decode tokens without checking a signature. For demos; logged once at construction.
pepstringLabel for this PEP in challenges and logs.
requireTokenboolean · trueDeny when no token is present.
getToken(req) => stringPull the compact token out of the request. Defaults to the Authorization header.
forwardHeadersboolean · falseAssert the decided identity as X-Auth-Principal, X-Auth-Agent, X-Auth-Scope, X-Auth-Acr on req.headers, as the gateways do upstream. An empty claim leaves its header absent. Client copies are stripped from every request the middleware lets through either way.
onDecision({ req, verdict, claims }) => voidObserve every decision, once per request; verdict.detail carries what the response does not. Must not throw.
onWarning(message) => void · console.warnConstruction-time warnings.
resourcestring | (req) => stringThe resource identifier discovery starts from; a function of the request for a multi-tenant API. Absent means the client's static PDP.
forwardAccessTokenboolean · falseForward the raw token as context.access_token.
failMode'open' | 'closed'Overrides the client's fail mode on this route. A fail-open permit sets X-PDP-Fail-Open on the response, naming the skipped layers.

pathMapper(rules, { fallthrough, caseSensitive, subjectType }) builds a map from route rules: { method, pattern, action, resourceType, resourceId?, resourceProperties?, context? }. Patterns take :name segments and * for any number of segments. It matches the way Express routes: segment by segment, each percent-decoded after the split, case-insensitively unless caseSensitive, one trailing slash tolerated, the query ignored, and a GET rule answers HEAD. A path that cannot be decoded is a mapping error. An unmatched route is a deny; pass fallthrough: 'allow' only for paths that carry no policy.

McpGuard

new McpGuard(options), then checkToolCall({ rpc?, claims?, raw?, accessToken?, extraContext? }) or guard.wrap(handler). Every MCP method is governed; unknown methods are denied so future ones fail closed. checkToolCall never throws.

OptionType · defaultMeaning
client requiredAuthzenClient | AuthzenClientOptionsAs above.
toolsToolDefinition[] | () => …The tool definitions, when this process is the MCP server. No discovery round trip. Ignored in delegate mode.
upstreamUrlstringThe MCP streamable-HTTP endpoint to discover tools/list from, when guarding someone else's server. Required in delegate mode, where coaz-pep reads that server's list. Ignored for discovery when tools is set.
discoveryTtlMsnumber · 60000How long a discovered tools/list is reused.
discoveryTimeoutMsnumber · 10000The deadline for one whole discovery: the session handshake, if the server wants one, and every page, following nextCursor up to 32. Concurrent calls share one discovery; an answer over 4 MiB is refused; a list that cannot be read to the end fails closed.
discoveryHeadersRecord<string,string>Headers for the discovery call.
pepstringLabel for this PEP.
applyDefaultMappingsboolean · trueGovern tools and methods that declare no mapping with the binding's default mapping, as it requires. Only an explicit false restores the old pass-through, which is not conformant. In delegate mode it is sent to coaz-pep as coaz_defaults.
onWarning(message) => void · console.warnConditions worth surfacing but not failing on, chiefly a mapping that overrides subject.id.
delegate{ url, apiKey, config?, timeoutMs? · 2000 }Hand every MCP request to a running coaz-pep over its HTTP check API: url is its base, apiKey its CHECK_API_TOKEN (required), config the per-route knobs. The guard sets style, mcp_upstream_url and coaz_defaults itself; a config that contradicts them will not build. Requires raw on each check.
allowInsecureboolean · falseLets delegate mode start without delegate.apiKey, for development. Logged once.
fetchtypeof fetchSwapped out in tests.
onDecision({ tool, verdict }) => voidObserve every decision, refusals included.
resourcestring · upstreamUrlThe MCP server's identifier: where discovery starts, what is passed to coaz-pep in delegate mode, and how the guard picks this server out of a token whose aud names several.
forwardAccessTokenboolean · falseForward the raw token (passed per call as accessToken). In delegate mode the flag is passed to coaz-pep.
failMode'open' | 'closed'Overrides the client's fail mode for this guard. In delegate mode passed to coaz-pep.

A check takes the parsed message as rpc, the untouched request as raw: { method?, path?, headers, body } (body as a string or as bytes), or both, in which case they must agree. claims are yours to verify: the guard never decodes a token. The guard refuses, without asking the PDP, anything that is not one JSON-RPC request or response object: a batch, a non-string method, a tools/call without a string params.name, an object at any depth holding two member names a case-insensitive parser reads as one (Go's rule: Unicode simple case folding, so Amount beside amount in the arguments counts), and nesting deeper than 64 — exactly as coaz-pep refuses them. Given raw it also refuses a Content-Encoding other than identity (415), a byte-order mark, invalid UTF-8, trailing data, anything that is not JSON, and a member named twice at any depth. A JSON-RPC response — a client answering the server — passes through.

Verdict fieldWhenWhat
allowalwaysOnly true means go.
responsedenyThe HTTP response to send as is: status, headers (X-PDP-Decision: DENY) and body. In delegate mode, what coaz-pep rendered.
jsonRpcErrordenyThe JSON-RPC error, which is what wrap() returns.
upstreamHeaderspermitThe X-Auth-* identity to set on what a gateway forwards; an empty value means remove the client's copy.
responseHeaderspermitHeaders to add to the client's response, X-PDP-Fail-Open among them.
messageonce readThe JSON-RPC message that was judged; wrap() hands it to the handler with the verdict.
CodeHTTPWhen
-32001200The PDP denied, with error.data.authz_challenge carrying the remedy. -32401 for a tool still declared against v1.
-32602200The mapping could not be evaluated.
-32603200The PDP, or coaz-pep, could not be reached. Generic on the wire; the detail is in the verdict.
-32600400 · 415The message is not one the PEP will read; 415 for a Content-Encoding.
-32700400The body is not JSON.

The server-scoped defaults (tools/list, initialize and friends) name this server from the token's aud. An array aud is narrowed to this server's own identifier, or to its only entry; one the guard cannot choose from, or no aud at all, is a -32602. A declared subject.id or resource.id that resolves absent is a -32602 in both dialects.

FederationEntity

new FederationEntity(options) holds a private key and serves the two documents a federated resource publishes: a minimal entity configuration (keys, authorityHints, the entity type; no policy, the controller maintains that) for a trust controller to onboard, and RFC 9728 metadata with signed_metadata.

OptionType · defaultMeaning
entityId requiredstringThe resource identifier: an https URL without query or fragment. A trailing slash is trimmed.
key requiredKeyObject | JsonWebKeyThe private key: EC P-256/384/521 (ES256/384/512) or RSA of at least 2048 bits (RS256). A public key is refused.
authorityHints requiredstring[]The superiors a trust controller may be reached through: https URLs, at least one.
assertedRecord<string, unknown> · {}What the RFC 9728 document carries beyond resource, typically the PDP the service is configured with. JWT-registered claim names (iss, sub, aud, exp, nbf, iat, jti) are dropped: the entity sets the ones it needs. The SDK has no chain resolver, so the document is self-asserted; put coaz-pep in front when the public document must be the controller's word.
lifetimeSecondsnumber · 86400Lifetime of each entity configuration; re-minted after half of it. Must be positive.
metadataLifetimeSecondsnumber · 3600The exp of signed_metadata; the document is re-signed after half of it rather than on every GET. Must be positive.
allowInsecureboolean · falseAccept http identifiers, for development. Logged once.
onWarning(message) => void · console.warnConstruction-time warnings.
now() => secondsThe clock, for tests.

Methods: publicJwk() (with an RFC 7638 thumbprint as kid), paths() ({ federation, resource } for this identifier), configuration(), protectedResourceMetadata() (a copy each call), and handler(), an Express-style middleware that serves both paths and passes everything else to next.

Verdicts and challenges

A verdict is { allow, kind, reason, detail?, context?, request?, failedOpen? }. reason is safe to show the caller: a PDP's own reason for a policy decision, or a fixed phrase for a failure. detail is what happened — which PDP, which status, which error — for logs, and never goes in a response. failedOpen lists the identifiers of the skipped layers. When the PDP's decision context says how a deny can be resolved, the SDK renders it three consistent ways, the same as the gateways:

Verdict kindHTTPWWW-Authenticateauthz_challenge.type
identity_proofing_required401identity_verification_requiredidentity_proofing
step_up_required401insufficient_scope (RFC 9470)resource_authorisation
unauthenticated401login_requiredauthn
denied403——
mapping_error400——
pdp_error502——

pdp_error is 502 on purpose: the PDP being down is our problem, and a 403 would send the caller chasing permissions they already have. Header values built from PDP data lose CR, LF and anything outside printable Latin-1, and are quoted-string escaped inside WWW-Authenticate, so a policy reason can neither forge a header nor make Node refuse one.

Complete samples

A REST API: verified tokens, discovery, layers, the federation face

import express from 'express';
import { readFileSync } from 'node:fs';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { AuthzenClient, FederationEntity } from '@id-partners/authzen-pep';
import { authzenMiddleware, pathMapper } from '@id-partners/authzen-pep/express';

const app = express();
app.use(express.json());
const jwks = createRemoteJWKSet(new URL('https://as.bank.example/jwks'));

const client = new AuthzenClient({
  url: process.env.AUTHZEN_URL!,                 // the static PDP, always permitted
  apiKey: process.env.AUTHZEN_API_KEY,           // bound to url; a discovered PDP never receives it
  discovery: {
    mode: 'resource',
    pdpAllowlist: ['https://pdp.bank.example', 'https://estate-pdp.bank.example'],
    resourceAllowlist: ['https://api.bank.example'],
    ttlMs: 300_000,
  },
  layers: ['https://estate-pdp.bank.example fail-open', 'resource'],
  failMode: 'closed',
  timeoutMs: 1500,
  onTrace: (t) => log.debug(t),                // t.outcome: permit, deny, refusal or unavailable
});

// The resource's federation face: this service holds the key; the controller holds the policy.
const entity = new FederationEntity({
  entityId: 'https://api.bank.example',
  key: JSON.parse(readFileSync('/etc/bank-api/entity-key.json', 'utf8')),
  authorityHints: ['https://federation.example'],
  asserted: { authzen_policy_decision_points: [process.env.AUTHZEN_URL!] },
});
app.use(entity.handler());                       // /.well-known/openid-federation, /.well-known/oauth-protected-resource

app.use(authzenMiddleware({
  client,
  pep: 'api-edge',
  verifyToken: async (token) => (await jwtVerify(token, jwks, { audience: 'https://api.bank.example' })).payload,
  map: pathMapper([
    { method: 'GET',  pattern: '/accounts/:id/balance', action: 'get_balance',  resourceType: 'account', resourceId: (p) => p.id },
    { method: 'POST', pattern: '/payments',             action: 'make_payment', resourceType: 'payment',
      resourceProperties: (_p, req) => ({ amount: (req.body as { amount?: number })?.amount }) },
  ]),
  resource: 'https://api.bank.example',
  forwardAccessToken: true,                      // the PDP connection is TLS and authenticated
  forwardHeaders: true,                          // X-Auth-* on req.headers
  onDecision: ({ verdict }) => audit.log(verdict.kind, verdict.reason, verdict.detail),
}));

app.get('/accounts/:id/balance', (req, res) => res.json({ balance: 12.5, principal: req.get('x-auth-principal') }));
app.listen(8070);

An MCP server guarding its own tools

import { McpGuard } from '@id-partners/authzen-pep/mcp';

const guard = new McpGuard({
  client: { url: process.env.AUTHZEN_URL!, apiKey: process.env.AUTHZEN_API_KEY },
  tools,                                         // this process IS the MCP server
  pep: 'mcp-edge',                               // every method governed by default; unknown ones denied
  resource: 'https://mcp.bank.example',
  forwardAccessToken: true,
  onDecision: ({ tool, verdict }) => audit.log(tool, verdict),
});

app.post('/mcp', express.raw({ type: 'application/json', inflate: false }), async (req, res) => {
  const claims = await verify(req.get('authorization'));   // yours: the guard never decodes a token
  const v = await guard.checkToolCall({ raw: { headers: req.headers, body: req.body }, claims });
  if (!v.allow) return res.status(v.response!.status).set(v.response!.headers).send(v.response!.body);
  res.json(await runTool(v.message!));
});

An MCP gateway delegating to coaz-pep

const guard = new McpGuard({
  client: { url: process.env.AUTHZEN_URL! },
  upstreamUrl: 'https://mcp.bank.example/mcp',   // required: coaz-pep reads this server's tools/list
  delegate: {
    url: 'http://coaz-pep:9192',
    apiKey: process.env.CHECK_API_TOKEN,         // required: coaz-pep's CHECK_API_TOKEN
    config: { require_token: 'true', require_user_login: 'true' },
  },
  resource: 'https://mcp.bank.example',
  forwardAccessToken: true,
  failMode: 'closed',
});

// Every MCP request goes to coaz-pep, whatever its method.
const v = await guard.checkToolCall({ raw: { method: req.method, path: '/mcp', headers: req.headers, body: rawBody } });
if (!v.allow) return res.status(v.response!.status).set(v.response!.headers).send(v.response!.body);
forwardUpstream(req, v.upstreamHeaders);         // X-Auth-* as coaz-pep asserted them; '' removes
res.set(v.responseHeaders ?? {});                // X-PDP-Fail-Open survives

The engine renders the deny and the SDK relays it verbatim, a 401 challenge included; two renderings of one decision would drift.

What the PDP receives

{
  "subject":  { "type": "user", "id": "customer", "properties": { "client_id": "agent-1", "on_behalf_of": "customer" } },
  "action":   { "name": "make_payment" },
  "resource": { "type": "payment", "id": "…", "properties": { "amount": 50 } },
  "context": {
    "resource_metadata_source": "rfc9728",
    "resource_metadata": { "resource": "https://api.bank.example",
                           "scopes_supported": ["accounts:read", "payments:write"],
                           "acr_values_required": ["urn:idp:loa:mfa"],
                           "authzen_policy_decision_points": ["https://pdp.bank.example"] },
    "request":  { "method": "POST", "path": "/payments" },
    "access_token": "eyJ…"
  }
}

The SDK is tested with vitest (sdk/node/test/, 460 tests on Node 22 and 24) and holds coverage floors of 99% statements and lines, 95% branches and 100% functions.