The PingAccess rule
AuthZEN PDP is a PingAccess Add-on SDK rule that sends requests to an AuthZEN PDP. It maps REST requests and supports resource discovery and ordered policy layers. It delegates DPoP verification and MCP requests to coaz-pep. Configure the rule in the PingAccess console or through the admin API.
This rule uses AuthZEN 1.0 and supports PDP discovery. The built-in PingAuthorize Policy Decision Access Control and PingAuthorize Access Control rules use PingAuthorize's own APIs.
Upgrading to 0.4.0
Version 0.4.0 adds configuration validation. A rule saved under 0.3 can fail validation after upgrade. Requests through an invalid rule receive a 503 temporarily_unavailable response. Check each rule against the requirements below before deploying the JAR.
style: mcpneedscoaz_url, and every request on an MCP route goes to coaz-pep, not onlytools/call. Nothing passes on a token alone.coaz_defaultsis on by default: every MCP method is governed by the COAZ binding's default table.- An MCP body the rule cannot read one way only is refused with a JSON-RPC error: a batch, invalid UTF-8, a BOM, trailing data, a case-variant member, a compressed or partial body.
- An access token PingAccess did not validate is a 401
invalid_token: protect the application, or tickallow_insecurefor development. X-User-Tokenneeds a JWKS and an audience. Without a JWKS it is ignored, andrequire_user_loginwithout one is refused. The token must carryexp,suband the audience.X-User-Tokencounts only as the principal's own login: itssubis the access token's subject, and it is neither the access token itself nor a delegated token. A route where staff approve for a customer setsuser_token_subject: pdp, and the PDP judges whose login it is.- The PDP gets coaz-pep's user context:
user_scope,token_aud,user_acr,user_sub,user_iss, andauthorization_detailswith the consented amount and creditor. - Missing security settings are refused, not warned about: an API key or forwarded token over http,
coaz_urlwithout its key, discovery withoutpdp_allowlist,pdp_discovery_insecure,pdp_ssl_verifyoff.allow_insecurelets a development rule start with them, and logs each one. - Every URL must be one the rule can call: absolute http or https with a host.
http://authzen_pdp:8080is refused - an underscore is not legal in a host name. - A fail-open layer opens on an outage only. A 4xx, a redirect or an answer that is not a decision is a refusal, and closed.
- A resource whose metadata cannot be read is not the static PDP's. Only a resource that publishes nothing (a 404, or a document naming no PDP) falls back to
authzen_url; an outage, a refusal or a document that does not validate serves the last good copy for up to one more TTL, then fails the resource layer by its mode. - A payment needs a readable body with a
from_accountand an amount that is a JSON number greater than zero, or it is a 400invalid_requestbefore any PDP is asked. So is a payment with any field a policy weighs in the wrong shape: an account identifier that is not 1-64 letters, digits,_,.,:or-, a numeric string for the amount, a currency that is not three upper-case letters, a non-string description or a non-booleaninternal_transfer. A read (list_accounts,get_balance) is only ever a GET or HEAD, and any POST with apaymentssegment is a payment. - A path that could route two ways is refused: a path parameter (
;jsessionid=…), a backslash, an empty or dot segment, an escaped reserved character,%,.or\, or a control character once decoded is a 400invalid_request, as on coaz-pep and Kong. So is a request carryingX-HTTP-Method-Override,X-HTTP-MethodorX-Method-Override, or a body that names a member twice in one object, in any case.
Build and install
The Add-on SDK is on no public Maven repository. Take it from a PingAccess install or, without one, from the public Docker image, which needs no licence to pull.
cd gateways/pingaccess/authzen-pdp
id=$(docker create pingidentity/pingaccess:9.1.0-latest)
mkdir -p .pa/lib && docker cp "$id:/opt/server/lib/pingaccess-sdk-9.1.0.1.jar" .pa/lib/ && docker rm "$id"
mvn verify # tests, the coverage ratchet, the jar
# or, against an install:
mvn -Dpa.server.root=/opt/pingaccess -Dpa.sdk.version=9.1.0.1 verify
The jar is target/authzen-pdp-pingaccess-<version>.jar; PingAccess supplies Jackson, jose4j and slf4j, so nothing is bundled. PingAccess 9.1.0 and 9.1.1 ship Jackson 2.21.1, which has published advisories; none is reachable through the rule, which uses Jackson's tree model only, and the fix is a PingAccess release that ships Jackson 2.21.7 or later (the PingAccess guide's Jackson advisories section has the operator action). The bytecode is Java 17, so it needs a Java 17 or later runtime; it has been tested on PingAccess 9.1.0.1 (Java 21) and nothing earlier. Copy it to <PA_HOME>/deploy/ and restart; the DevOps image's local server profile is /opt/in/instance/deploy/. The rule then appears as AuthZEN PDP (class com.idpartners.pa.authzen.AuthZenRule) for Site and Agent destinations.
The rule in the console
The console draws one form from the rule's descriptor: each field with a widget, a label, a default, a help note, and whether it is required or behind Show Advanced Settings. The same descriptor is what the admin API serves at GET /pa-admin-api/v3/rules/descriptors/AuthZenPdpRule. Captured from the demo's PingAccess (docker compose --profile pingaccess) as configured by its start-up hook.





Every field
Names are as saved under configuration; the console shows a label with the name in brackets. advanced marks a field behind Show Advanced Settings.
The PDP
| Field | Widget · default | Meaning |
|---|---|---|
authzen_url required | text | The static PDP: decides for a resource that publishes nothing, and is always permitted. |
authzen_api_key | concealed | Bearer key for authzen_url, sent over https only. A discovered PDP never receives it. Stored encrypted; the API returns {"encryptedValue": "OBF:JWE:…"}. |
pdp_timeout_ms advanced | text · 10000 | Deadline on each PDP call, the whole exchange included. |
pdp_ssl_verify advanced | checkbox · true | Certificate verification on every outbound call. Off trusts any chain but still checks the host name, and needs allow_insecure: dev only. |
The route
| Field | Widget · default | Meaning |
|---|---|---|
pep_label | text · pingaccess-pep | Names this PEP in challenges and the X-PDP-PEP header. |
style | select · rest | rest (resource server) or mcp (MCP edge: every request goes to coaz-pep, and coaz_url is required). |
require_token | checkbox · true | Deny without a readable access token. A token PingAccess did not validate is a 401 either way, unless allow_insecure. |
require_dpop | checkbox · false | Check the RFC 9449 sender constraint on every request, delegated to coaz-pep; needs coaz_url, and is refused at configuration time without it. Off, a DPoP-bound token (cnf.jkt, or presented as DPoP) is still checked, and refused when there is no coaz_url. |
require_user_login | checkbox · false | Deny without a verified X-User-Token, with a 401 login_required challenge naming acr_values="urn:pingidentity:loa:password". Needs user_token_jwks_url. |
stepup_scope | text | Scope a step-up (403 insufficient_scope) names when the PDP's context has neither scope nor step_up_scope. Unset, the challenge names no scope. |
stepup_action advanced | text · make_payment | Accepted for parity with the Kong plugin's schema; this rule does not read it. |
resource | text | The protected resource's identifier (RFC 8707), the key discovery starts from. |
forward_access_token | checkbox · false | Send the raw token to the PDP as context.access_token; refused over plain http. |
coaz-pep
| Field | Widget · default | Meaning |
|---|---|---|
coaz_url | text | coaz-pep's HTTP check API. Required for style: mcp and for require_dpop, and to accept a DPoP-bound token on any route. |
coaz_api_key | concealed | Its CHECK_API_TOKEN; required with coaz_url, and sent over https only. |
mcp_upstream_url | text | Where tools/list lives; coaz-pep reads each tool's mapping from it. |
federation_entity_url advanced | text | Relay the resource's two well-known documents from coaz-pep at this URL. |
coaz_defaults advanced | checkbox · true | Govern every MCP method by the COAZ-MCP binding's default table; false keeps the old pass-through for methods other than tools/call. |
coaz_timeout_ms advanced | text · 15000 | Deadline on each coaz-pep call (/v1/mcp/check and /v1/dpop/verify), the whole exchange included. |
Discovery
| Field | Widget · default | Meaning |
|---|---|---|
pdp_discovery | select · off | off, authzen or resource. No federation mode; the same reason as Kong, and the same remedy: behind coaz-pep. |
pdp_metadata_ttl advanced | text · 300 | Cache TTL, seconds, for resource and PDP metadata. A fetch that fails, or a document that does not validate, serves the last good copy for up to one more TTL; after that, or with none, the layer is unavailable and its failure mode decides. |
pdp_allowlist advanced | list | Permitted discovered-PDP prefixes; required when pdp_discovery is on. authzen_url is always permitted. |
resource_metadata_allowlist advanced | list · any | Permitted resource prefixes for metadata fetches. |
pdp_discovery_insecure advanced | checkbox · false | Allow http for discovered URLs; needs allow_insecure (dev only). |
Layers
| Field | Widget · default | Meaning |
|---|---|---|
pdp_layers | list · ["resource"] | Ordered PDPs, every one of which must permit; fail-open / fail-closed per entry. An empty list means resource: the authzen_policy_set the resource's document publishes, which these layers wrap in front. |
fail_mode | select · closed | What a layer does when its PDP is down (no connection, a timeout, a 5xx, a 429), unless it says for itself. A refusal - a 4xx, a redirect, an answer that is not a decision - never opens. |
pdp_allow_published_fail_open | checkbox · false | Lets a resource's own RFC 9728 document mark members of its authzen_policy_set fail_open. Off, a published fail_open: true is ignored and the member takes the resource entry's mode; a published false always counts. |
The user token
| Field | Widget · default | Meaning |
|---|---|---|
user_token_jwks_url | text | Verify X-User-Token against this JWKS, over https (jose4j is on PingAccess's classpath); exp and sub are required. Unset, the token is ignored, or decoded under allow_insecure. |
user_token_issuer advanced | text | Expected iss when verifying. |
user_token_audience | text | Expected aud; required with the JWKS. |
user_token_subject advanced | select · principal | Whose login counts. principal: only a token whose sub is the access token's subject. pdp: another person's verified login too - a staff member approving for a customer - and the PDP, told user_sub and user_iss, decides. Either way, not the access token itself and not a delegated (act) token. |
Transport and compatibility
| Field | Widget · default | Meaning |
|---|---|---|
legacy_subject_identity advanced | checkbox · true | Also send the non-standard subject.identity beside subject.id. |
allow_insecure advanced | checkbox · false | Development only: start with what the rule otherwise refuses (unvalidated access tokens, an unverified X-User-Token, keys over http, no pdp_allowlist, TLS verification off), each relaxation logged at configure time. |
Saving validates twice: field constraints first, reported on the field under a Save Failed banner (the PDP URL must not be blank, the drop-downs must hold one of their options, the numbers must be at least 1), then the rule's own configure. That refuses a URL the rule could not call (not absolute http or https, no host, user info), style: mcp or require_dpop without coaz_url, a pdp_layers entry it cannot read, a JSON null for a drop-down, and - unless allow_insecure is ticked - every missing or weakened security setting, listed in one banner. The descriptor sets agentCachingDisabled: true: an agent never caches this rule's verdict, because it depends on the token, the body and the PDP's current view.
The admin API
The same configuration as JSON, keyed by the names above. This is the repository's reference sample; demo/pingaccess/hooks/81-after-start-process.sh is the whole sequence, site and application included, as a script.
curl -sk -u administrator:$PA_PASSWORD -H 'X-Xsrf-Header: PingAccess' -H 'Content-Type: application/json' \
https://pa-admin:9000/pa-admin-api/v3/rules -d '{
"name": "authzen-pdp",
"className": "com.idpartners.pa.authzen.AuthZenRule",
"supportedDestinations": ["Site", "Agent"],
"configuration": {
"authzen_url": "https://pdp.bank.example",
"authzen_api_key": "...",
"pep_label": "PEP#2 (Bank API edge)",
"style": "rest",
"require_token": true,
"pdp_discovery": "resource",
"resource": "https://api.bank.example",
"resource_metadata_allowlist": ["https://api.bank.example"],
"pdp_allowlist": ["https://pdp.bank.example", "https://estate-pdp.example"],
"pdp_layers": ["https://estate-pdp.example fail-open", "resource"],
"user_token_jwks_url": "https://as.bank.example/jwks",
"user_token_audience": "https://api.bank.example"
}
}'
A saved rule reads back as GET /pa-admin-api/v3/rules/{id} with the form's values under configuration; concealed fields come back encrypted.
Attaching it to an application
Then POST /applications with the rule in the API policy. The demo's hook creates the site, the rule, the virtual host (when *:3000 is missing) and the application in that order; this is the shape of the application.
{
"name": "bank",
"contextRoot": "/",
"applicationType": "API",
"defaultAuthType": "API",
"destination": "Site",
"siteId": 1,
"virtualHostIds": [1],
"accessValidatorId": 0, # unprotected: the demo's tokens are unsigned, so its rule sets allow_insecure
"enabled": true,
"policy": { "API": [ { "type": "Rule", "id": 1 } ] }
}
In the console: Applications → Applications, edit the application, the API Policy tab, drag authzen-pdp from Available Rules onto the policy. Applications are created disabled unless enabled is set. Unprotected applications still run rules, but the rule refuses a token PingAccess did not validate unless it is configured with allow_insecure, which is how the demo uses unsigned tokens. A production application has an access token validator, and then PingAccess validates the bearer before the rule runs.
What PingAccess does, and what the rule does
- The access token. When the application is protected, PingAccess validates the bearer token before any rule runs: signature or introspection, expiry, audience. The rule reads what PingAccess established from the exchange's identity and nothing else: the token's own payload is never read beside it, so a claim the identity lacks (
act,cnf,acr) is absent until the validator's attribute mapping puts it there. A web session's identity carries no OAuth token metadata and validates noAuthorizationheader, so a request carrying both is a 401invalid_token, whateverallow_insecuresays. Unprotected, PingAccess establishes no identity, and a request carrying a token is a 401 unless the rule hasallow_insecure. - DPoP. A DPoP-bound token is never a bearer token.
require_dpopchecks every request; without it, a token that is bound (cnf.jkt, or a DPoP token type) or presented as DPoP is checked anyway, as coaz-pep does on every route (RFC 9449 §7.2). A bound token sent asBeareror without a proof is a 401invalid_dpop_proof; the proof goes to coaz-pep's/v1/dpop/verify, and with nocoaz_urlthe request is a 503. Bound is judged from the identity and, as a stricter check only, from the token's own payload. coaz-pep readscnf.jktfrom the token itself, so a DPoP-bound opaque token is always a 401: sender-constrained tokens must be JWTs. PingAccess's own DPoP enforcement (the application'sdpopSettings) is worth keeping as well. - The user token. Unlike Kong, the rule verifies
X-User-Tokenitself, since jose4j is on the classpath: signature,exp,suband the configured audience. Withoutuser_token_jwks_urlthe token is ignored. A verified token counts only as the principal's own login, unlessuser_token_subjectispdp; whenever one counts, the PDP is told whose it is (user_sub,user_iss), as coaz-pep tells it. - MCP routes. Every request goes to coaz-pep's
/v1/mcp/check. A body that is not exactly one JSON-RPC message the rule read in full is refused first: 400 with-32700or-32600, 413 for a partial body, 415 for a compressed one. - Headers. A permit removes every
X-Auth-*header the client sent before setting the ones the PEP asserts. Values from a PDP's answer are cleaned for a header. InsideWWW-Authenticateevery PEP quotes a value the same way: CR, LF and tab become a space, anything else outside printable ASCII is dropped, and backslash and double quote are escaped, so a value cannot close its parameter and add another. - Refusals. The rule renders every refusal by the decision contract every PEP in this repository shares (
core/authzen/refusal), held to the same vectors: the same status, body andWWW-Authenticateas coaz-pep, the Kong plugins and the Node SDK. A body is{"status", "error", "error_description"}, plusscope,acr_values,max_ageordoctypewhere they apply andauthz_challengewhen the client can resolve it. A challenge flag (identity_proofing_required, thenstep_up_required, thenauthn_required) refuses a permit too; otherwise a deny'scontext.errornames the error, and one the contract lacks isaccess_denied. A step-up is 403insufficient_scope;login_required,insufficient_user_authentication(RFC 9470, withacr_valuesandmax_age) andidentity_verification_requiredare 401.invalid_tokenandinsufficient_scopechallenges name the route's RFC 9728 metadata asresource_metadatawhenresourceis set. No PDP answer, or a rule that is not configured, saturated or failed, is 503temporarily_unavailable. - The rule shares policy-layer behaviour with the Kong plugin. Fail-open applies only when a PDP is unavailable. See the Kong reference for the shared settings and
docs/pingaccess.mdfor the PingAccess implementation and every refusal the rule makes itself.
A PDP asking for stronger or more recent authentication, from the shared vectors:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="urn:bank:acr:strong", max_age="300"
Content-Type: application/json
{"status":401,"error":"insufficient_user_authentication",
"error_description":"This operation needs acr urn:bank:acr:strong.",
"acr_values":"urn:bank:acr:strong","max_age":300,
"authz_challenge":{"type":"authn","acr_values":"urn:bank:acr:strong","max_age":300,
"reason":"This operation needs acr urn:bank:acr:strong.","pep":"pingaccess-pep"}}