The Node SDK
@id-partners/authzen-pep adds policy enforcement to a Node.js service. It includes an AuthZEN client and Express middleware. McpGuard handles MCP requests. FederationEntity publishes signed resource metadata. The package has no runtime dependencies. Configure it through options in code.
Install
npm install ./id-partners-authzen-pep-<version>.tgz
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';
Requires Node.js 22 or later. The package uses ECMAScript modules (ESM). It includes TypeScript declarations and source maps with their source files.
Download the package from its GitHub release before running the install command. Public npm publishing is optional in the release workflow.
Upgrading past 0.4.2: one refusal contract
Every refusal now follows the contract all four PEPs share, so a client gets the same answer from this SDK as from the gateways. This changes what a client gets back and some public types.
| What changed | What to do |
|---|---|
Bodies are {status, error, error_description}, plus scope, acr_values, max_age or doctype where they apply and authz_challenge when the client can resolve the refusal. The top-level pep and reason are gone. | Read error_description; the PEP label is authz_challenge.pep. |
A step-up is 403 insufficient_scope, not 401. | Start a step-up on error, not on the status. |
A PDP failure is 503 temporarily_unavailable, not 502 authorization_unavailable. | Move alerts and retries keyed on 502. |
The PEP's own token refusals are invalid_token, not login_required. No token gets WWW-Authenticate: Bearer with no error (RFC 6750 §3.1), plus resource_metadata when resource is set. | Treat invalid_token as "get a token"; login_required now comes from the policy. |
WWW-Authenticate carries no error_description, and parameter values keep printable ASCII only. | Read the description from the body. |
Obligations apply to a permit, when they are JSON true. A layered or boxcar permit carries every permitting layer's obligations. | Make the PDP send true, not "true" or 1. |
A PDP can name the refusal in context.error. | Nothing, unless your PDP already sends an error it did not mean as one. |
MCP: a PDP refusal's JSON-RPC message is <error> <param>=<value> … :: <description> and its data is the body above. The guard's own errors keep their message and carry the body of invalid_request, temporarily_unavailable or access_denied. | data.authz_challenge is where it was. |
Verdict gains refusal; AuthzChallenge gains max_age. toChallenge and toHttpChallenge take a RefusalConfig or the PEP label as before. The middleware and the guard take stepUpScope. | Pass stepUpScope where a policy's step-up names no scope. |
Upgrading to 0.4.0
Version 0.4.0 requires explicit token verification and discovery security settings. Missing required settings prevent startup. allowInsecure: true bypasses these checks for development and demos. The SDK logs the override 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
Create the client with new AuthzenClient(options). The middleware and MCP guard accept a client instance or the same 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) }, and for a policy set the optional lookup, endpointOf and checkPdp). 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' (the call's resource's authzen_policy_set, or the deprecated pair read as one, combined deny-overrides behind the layers in front of it), 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. |
honourPublishedFailOpen | boolean · false | Act on a fail_open a member or set carries in the resource's own policy set. The document is self-asserted, so it is ignored by default; never honoured when it says authzen_fail_open_permitted: false. On 'resource' the layer's rule is the default its members inherit. |
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: the first deny wins so its advice survives, and a permit carries every evaluation's obligations; 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. The SDK has no built-in federation resolver. Use sources to supply custom metadata lookups. |
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 | Development override. It 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 PDP evaluates the resource's requirements.
PDP call outcomes
A fail-open setting skips only an unavailable PDP. The table defines each outcome.
| 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 invalid_request); 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 invalid_token, as is a token with no sub. 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. |
verifyDpop | (check) => Promise<{ valid, reason? }> | Verifies the DPoP proof (RFC 9449) of a token that is bound (cnf.jkt, in the verified claims or the token's own payload) or sent with the DPoP scheme. Such a token is never a bearer token: sent as Bearer or without exactly one proof it is a 401 invalid_dpop_proof, an invalid proof is a 401 with the verifier's reason, and with no verifier, or one that throws, it is a 503. coazDpopVerifier({ url, apiKey }) delegates to coaz-pep's /v1/dpop/verify. |
pep | string | Label for this PEP in challenges (authz_challenge.pep) and logs. |
stepUpScope | string | The scope a step-up (403 insufficient_scope) names when the policy names none. Absent, the challenge names no scope. |
requireToken | boolean · true | Deny when no token is present: 401 invalid_token with WWW-Authenticate: Bearer naming no error (RFC 6750 §3.1), plus resource_metadata when resource is set. |
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. Its RFC 9728 URL is the resource_metadata on invalid_token and insufficient_scope challenges. |
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. |
stepUpScope | string | As on the middleware: the scope a step-up names when the policy names none. |
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 settings. 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. Its RFC 9728 URL is the resource_metadata on the guard's invalid_token and insufficient_scope refusals. |
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 refused. The message is <error> <param>=<value> … :: <description> and error.data is the refusal body every PEP sends, with authz_challenge carrying the remedy when there is one. A method with no mapping keeps its plain message and carries the access_denied body. -32401 for a tool still declared against v1. |
-32602 | 200 | The mapping could not be evaluated; data is the invalid_request body. |
-32603 | 200 | The PDP, or coaz-pep, could not be reached; data is the temporarily_unavailable body. 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. Use coaz-pep to publish resolved federation metadata. |
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?, refusal?, 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. A deny carries refusal, rendered by the decision contract every PEP in this repository shares (core/authzen/refusal) and held to the same vectors (core/authzen/refusal/testdata/challenges/cases.json): the same status, JSON body and WWW-Authenticate as coaz-pep, the Kong plugins and the PingAccess rule. toHttpChallenge(verdict, conf) and toChallenge(verdict, conf) render it; conf is a RefusalConfig (pep, stepUpScope, resourceMetadata) or the PEP label alone, and the middleware and the guard fill it from their own options. kind is the coarse class, for code that switches on it:
| Verdict kind | HTTP | error | WWW-Authenticate | authz_challenge.type |
|---|---|---|---|---|
identity_proofing_required | 401 | identity_verification_required, with doctype (default org.iso.18013.5.1.mDL) | Bearer error="identity_verification_required", doctype="…" | identity_proofing |
step_up_required | 403 | insufficient_scope (RFC 6750), with scope | Bearer error="insufficient_scope", scope="…", plus resource_metadata | resource_authorisation |
unauthenticated | 401 | login_required, with acr_values; insufficient_user_authentication (RFC 9470), with acr_values and max_age | Bearer error="…" and those parameters | authn |
| 401 | invalid_token; invalid_dpop_proof | Bearer error="invalid_token" plus resource_metadata, or a bare Bearer when no token was presented; DPoP error="invalid_dpop_proof" | — | |
denied | 403 · 500 · 502 | access_denied, risk_too_high, operation_not_published (403); response_policy_unsupported (500); resource_metadata_unavailable, response_withheld, response_too_large (502) | — | — |
mapping_error | 400 · 413 | invalid_request; request_too_large | — | — |
pdp_error | 503 | temporarily_unavailable | — | — |
A challenge flag that is JSON true refuses a permit as well as a deny ("true" and 1 do not count): identity_proofing_required first, then step_up_required, then authn_required, each with the parameters it owns (doctype or identity_proofing_doctype; scope or step_up_scope, else stepUpScope; acr_values). Otherwise a deny's context.error names the refusal, from the contract's list less the PEP's own (invalid_dpop_proof, invalid_request, request_too_large, temporarily_unavailable); any other value is access_denied. context.error_description, else reason, is the description, and the PDP never chooses the status. max_age is read only for insufficient_user_authentication, and only as a whole number of seconds from 0 to 231-1; anything else is left out. Permitting layers and boxcar permits keep each other's obligations: the first to require something owns its parameters and its description. resource_metadata is the route's own RFC 9728 URL, built from resource, never a PDP's.
A PDP that names a missing scope, from the shared vectors:
// context: { "error": "insufficient_scope", "error_description": "Missing scope: accounts:read.", "scope": "accounts:read" }
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="accounts:read", resource_metadata="https://api.bank.example/.well-known/oauth-protected-resource"
{"status":403,"error":"insufficient_scope","error_description":"Missing scope: accounts:read.",
"scope":"accounts:read",
"authz_challenge":{"type":"resource_authorisation","scope":"accounts:read",
"reason":"Missing scope: accounts:read.","pep":"test-pep"}}
pdp_error is a 503 and fails closed: the PDP being down is our problem, and a 403 would send the caller chasing permissions they already have. WWW-Authenticate carries no error_description. A parameter value built from PDP data is quoted by the rule every PEP applies byte for byte: CR, LF and tab become a space, anything else outside printable ASCII is dropped, and backslash and double quote are escaped, so a policy value can neither add a parameter, forge a header nor make Node refuse one.
Complete samples
A REST API: verified tokens, discovery, layers, the metadata publisher
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_set: { combining: 'deny-overrides', members: [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 denial by the same contract, challenge included. The SDK relays it unchanged.
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_set": { "combining": "deny-overrides", "members": ["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.