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.
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 changed | What 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.
| Option | Type · default | Meaning |
|---|---|---|
url required | string | The 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. |
apiKey | string | Sent as Authorization: Bearer to url. A discovered PDP never receives it. |
discovery | ClientDiscoveryOptions | PdpResolver | PDP discovery, below, or a resolver of your own ({ resolve(resource), resolvePdp(pdp) }). Absent means off: no HTTP beyond the evaluation itself. |
layers | Array<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. |
timeoutMs | number · 1500 | Per-request timeout. A PEP sits in the request path. |
headers | Record<string,string> | Extra headers on every PDP call: tracing, tenant routing. |
fetch | typeof fetch · global fetch | Swapped out in tests. |
onTrace | (trace: PdpTrace) => void | Called 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.
| Option | Type · default | Meaning |
|---|---|---|
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 PdpDiscovery | string | The configured PDP: decides for a resource that publishes nothing, and is always permitted. The client fills it from url. |
apiKeys | Record<identifier, bearer> | Which bearer goes to which PDP. The client seeds { [url]: apiKey }. |
pdpAllowlist required in resource mode | string[] | 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 mode | string[] | The resources whose metadata is read, as prefixes. |
allowInsecure | boolean · false | The 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. |
ttlMs | number · 300000 | Cache TTL for resource and PDP metadata. |
minRefreshMs | number · 30000 | How 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. |
maxEntries | number · 1024 | Cache bound. |
timeoutMs | number · 3000 | Per metadata fetch. A document over 1 MiB, a redirect or a 4xx other than 404 is refused rather than taken for an outage. |
sources | MetadataSource[] | Overrides the mode-derived sources. Each is { name, lookup(resource) }. |
onWarning | (message) => void · console.warn | One line per degraded step, once per retry window. |
fetch, now | Swapped out in tests. |
Per-call options
EvaluateOptions, the second argument of evaluate and evaluateAll. The middleware and the guard fill these from the request.
| Option | Type | Meaning |
|---|---|---|
resource | string | The protected resource's identifier (RFC 8707), the key discovery starts from. |
accessToken | string | The 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. |
layers | Array<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.
| Outcome | When | Fail-open layer |
|---|---|---|
| permit | The decision is the JSON boolean true; for a boxcar, exactly one per evaluation sent, every one true. | — |
| deny | The decision is false. | Never skipped: a deny is a decision. |
| unavailable | A 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. |
| refusal | A 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.
| Option | Type · default | Meaning |
|---|---|---|
client required | AuthzenClient | AuthzenClientOptions | An existing client, or the options to build one. |
map required | (req, claims) => EvaluationRequest | null | Map 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. |
allowInsecure | boolean · false | Build without verifyToken and decode tokens without checking a signature. For demos; logged once at construction. |
pep | string | Label for this PEP in challenges and logs. |
requireToken | boolean · true | Deny when no token is present. |
getToken | (req) => string | Pull the compact token out of the request. Defaults to the Authorization header. |
forwardHeaders | boolean · false | Assert 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 }) => void | Observe every decision, once per request; verdict.detail carries what the response does not. Must not throw. |
onWarning | (message) => void · console.warn | Construction-time warnings. |
resource | string | (req) => string | The resource identifier discovery starts from; a function of the request for a multi-tenant API. Absent means the client's static PDP. |
forwardAccessToken | boolean · false | Forward 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.
| Option | Type · default | Meaning |
|---|---|---|
client required | AuthzenClient | AuthzenClientOptions | As above. |
tools | ToolDefinition[] | () => … | The tool definitions, when this process is the MCP server. No discovery round trip. Ignored in delegate mode. |
upstreamUrl | string | The 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. |
discoveryTtlMs | number · 60000 | How long a discovered tools/list is reused. |
discoveryTimeoutMs | number · 10000 | The 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. |
discoveryHeaders | Record<string,string> | Headers for the discovery call. |
pep | string | Label for this PEP. |
applyDefaultMappings | boolean · true | Govern 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.warn | Conditions 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. |
allowInsecure | boolean · false | Lets delegate mode start without delegate.apiKey, for development. Logged once. |
fetch | typeof fetch | Swapped out in tests. |
onDecision | ({ tool, verdict }) => void | Observe every decision, refusals included. |
resource | string · upstreamUrl | The 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. |
forwardAccessToken | boolean · false | Forward 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 field | When | What |
|---|---|---|
allow | always | Only true means go. |
response | deny | The HTTP response to send as is: status, headers (X-PDP-Decision: DENY) and body. In delegate mode, what coaz-pep rendered. |
jsonRpcError | deny | The JSON-RPC error, which is what wrap() returns. |
upstreamHeaders | permit | The X-Auth-* identity to set on what a gateway forwards; an empty value means remove the client's copy. |
responseHeaders | permit | Headers to add to the client's response, X-PDP-Fail-Open among them. |
message | once read | The JSON-RPC message that was judged; wrap() hands it to the handler with the verdict. |
| Code | HTTP | When |
|---|---|---|
-32001 | 200 | The PDP denied, with error.data.authz_challenge carrying the remedy. -32401 for a tool still declared against v1. |
-32602 | 200 | The mapping could not be evaluated. |
-32603 | 200 | The PDP, or coaz-pep, could not be reached. Generic on the wire; the detail is in the verdict. |
-32600 | 400 · 415 | The message is not one the PEP will read; 415 for a Content-Encoding. |
-32700 | 400 | The 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.
| Option | Type · default | Meaning |
|---|---|---|
entityId required | string | The resource identifier: an https URL without query or fragment. A trailing slash is trimmed. |
key required | KeyObject | JsonWebKey | The private key: EC P-256/384/521 (ES256/384/512) or RSA of at least 2048 bits (RS256). A public key is refused. |
authorityHints required | string[] | The superiors a trust controller may be reached through: https URLs, at least one. |
asserted | Record<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. |
lifetimeSeconds | number · 86400 | Lifetime of each entity configuration; re-minted after half of it. Must be positive. |
metadataLifetimeSeconds | number · 3600 | The exp of signed_metadata; the document is re-signed after half of it rather than on every GET. Must be positive. |
allowInsecure | boolean · false | Accept http identifiers, for development. Logged once. |
onWarning | (message) => void · console.warn | Construction-time warnings. |
now | () => seconds | The 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 kind | HTTP | WWW-Authenticate | authz_challenge.type |
|---|---|---|---|
identity_proofing_required | 401 | identity_verification_required | identity_proofing |
step_up_required | 401 | insufficient_scope (RFC 9470) | resource_authorisation |
unauthenticated | 401 | login_required | authn |
denied | 403 | — | — |
mapping_error | 400 | — | — |
pdp_error | 502 | — | — |
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.