Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]
Alnyli07 commented on PR #13165: URL: https://github.com/apache/apisix/pull/13165#issuecomment-4668140724 Thanks for the review again @Baoyuantop. All three points addressed: 1. Malformed proof / missing alg: Added header/payload structure validation that runs before signature verification and validate_proof, rejecting a missing/non-string alg (and malformed header/payload/jwk) with invalid_dpop_proof. The nil-concat and h.alg:sub() paths are now unreachable. Added negative tests for missing alg, non-string alg, and non-object header/payload (each expects 401, no error log). 2. **require_nonce**: Removed from schema and docs, since the nonce semantics aren't implemented (no longer a no-op option). 3. **strict_htu** default Kept false because strict_htu=true requires public_base_url, so defaulting to true would break existing routes. The path-only behavior and its weakening of RFC 9449 §4.3 URI binding is now documented explicitly, with tests for full-URL match (200), host/scheme mismatch (401), and path-only default (200). Happy to flip the default if you'd prefer secure-by-default. Ready for another look. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]
Baoyuantop commented on PR #13165: URL: https://github.com/apache/apisix/pull/13165#issuecomment-4560404893 Thanks for continuing to improve this DPoP plugin. The current revision does address the two earlier review points: `ssl_verify` is now configurable and propagated to outbound discovery/JWKS calls, and proof signature verification now covers the ES/RS/PS algorithm families with additional tests. However, I do not think this is mergeable yet. There are a few blocking issues that should be fixed first: 1. A malformed proof without `alg` can hit a Lua runtime error. `verify_dpop_proof_signature` eventually concatenates the nil `alg` value when no algorithm branch matches, and `validate_proof` also calls `h.alg:sub(...)` directly. This kind of input should consistently return `invalid_dpop_proof`, not fall into a 500/error path. Please add explicit header structure validation and negative tests for missing/non-string `alg`. 2. `require_nonce` is accepted by the schema, while the documentation says it is “not yet implemented”, and the implementation does not validate a DPoP nonce. A security option should not be accepted as a no-op. Please either implement the nonce semantics with tests, or remove the option for now. 3. With the default `strict_htu=false`, the plugin only compares the path and ignores scheme/host/port. This compatibility mode can weaken the URI binding semantics from RFC 9449. Please re-evaluate the default behavior, or at least document it clearly and add tests for strict/full-URL and path-only boundaries. Please address these points and request another review. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]
Alnyli07 commented on PR #13165: URL: https://github.com/apache/apisix/pull/13165#issuecomment-4517535724 Hi everyone, Just bumping this PR. All feedback from @Baoyuantop has been fully addressed, and the 35 E2E test cases are passing cleanly. Could we get additional reviews to help meet the 3-approval requirement? Thanks! 🙏 -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]
Alnyli07 commented on PR #13165: URL: https://github.com/apache/apisix/pull/13165#issuecomment-4356684128 @Baoyuantop both threads above are now addressed. Brief summary of what landed on top of the initial PR: - `ssl_verify` config option (default `true`), propagated to every outbound HTTP call the plugin issues. - Verification path now covers all nine RFC 7518 asymmetric algorithms declared in the schema (ES256/384/512, RS256/384/512, PS256/384/512), with the long-form DER and OpenSSL 3 PSS adjustments noted in the thread. - Test::Nginx suite covering schema validation, route enforcement, the full crypto flow per algorithm, and proof-claim negatives. 35 cases / 105 assertions, all passing. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]
Alnyli07 commented on code in PR #13165:
URL: https://github.com/apache/apisix/pull/13165#discussion_r3171214319
##
apisix/plugins/dpop.lua:
##
@@ -0,0 +1,1384 @@
+local core = require("apisix.core")
+local cjson = require("cjson.safe")
+local resty_sha256 = require("resty.sha256")
+local http = require("resty.http")
+local openssl_pkey = require("resty.openssl.pkey")
+local lrucache = require("resty.lrucache")
+local ngx = ngx
+
+local plugin_name = "dpop"
+
+-- Module-level local JTI cache — fallback when ngx.shared.dpop_jti_cache
+-- is not configured. Per-worker only; ensures fail-closed per RFC 9449 §11.1.
+local _jti_local_cache = {}
+local _jti_local_count = 0
+
+local function jti_local_cleanup()
+local now = ngx.time()
+local new_count = 0
+for k, expiry in pairs(_jti_local_cache) do
+if expiry <= now then
+_jti_local_cache[k] = nil
+else
+new_count = new_count + 1
+end
+end
+_jti_local_count = new_count
+end
+
+-- PKey LRU cache: JWK JSON → openssl pkey object
+-- 128 entries is generous — typical deployment has 1-3 signing keys
+local _pkey_cache, pkey_cache_err = lrucache.new(128)
+if not _pkey_cache then
+error("failed to create pkey LRU cache: " .. (pkey_cache_err or "unknown"))
+end
+
+-- Module-level JWKS caches
+local _jwks_cache = {} -- jwks_uri -> { keys_by_kid = {kid ->
jwk_table}, fetched_at }
+local _discovery_cache = {} -- discovery_url -> { jwks_uri, fetched_at }
+local _jwks_last_refetch = 0 -- rate limit: max 1 refetch per 60s
+
+-- Introspection cache: module-level local fallback when
ngx.shared.dpop_intro_cache
+-- is not configured. Per-worker only; shared dict preferred for cross-worker
consistency.
+local _introspection_cache = {}
+local _introspection_cache_count = 0
+
+-- Infinispan digest auth nonce cache (per-worker)
+local _ispn_digest_cache = {} -- endpoint -> { nonce, realm, qop, nc }
+
+-- HTTP Digest Auth helper: parse WWW-Authenticate header and compute
Authorization
+local function ispn_digest_auth(www_auth, method, uri, username, password)
+if not www_auth then return nil end
+local realm = www_auth:match('realm="([^"]+)"')
+local nonce = www_auth:match('nonce="([^"]+)"')
+local qop = www_auth:match('qop="([^"]*)"') or www_auth:match('qop=([^,%
]+)')
+if not realm or not nonce then return nil end
+local nc = "0001"
+local cnonce = ngx.md5(tostring(ngx.now()) .. tostring(math.random(1,
99)))
+local ha1 = ngx.md5(username .. ":" .. realm .. ":" .. password)
+local ha2 = ngx.md5(method .. ":" .. uri)
+local response
+if qop and qop:find("auth") then
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. nc
+.. ":" .. cnonce .. ":" .. "auth" .. ":" .. ha2)
+else
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. ha2)
+end
+return 'Digest username="' .. username
+.. '", realm="' .. realm
+.. '", nonce="' .. nonce
+.. '", uri="' .. uri
+.. '", qop=auth, nc=' .. nc
+.. ', cnonce="' .. cnonce
+.. '", response="' .. response .. '"',
+nonce, realm, qop
+end
+
+local schema = {
+type = "object",
+properties = {
+allowed_algs = {
+type = "array",
+items = {
+type = "string",
+enum = {
+"ES256", "ES384", "ES512",
+"RS256", "RS384", "RS512",
+"PS256", "PS384", "PS512",
+},
Review Comment:
**75f2d87** : the verification path now dispatches all nine asymmetric algs
declared
in the schema: ES256/384/512, RS256/384/512, PS256/384/512.
Two implementation notes:
1. ECDSA proofs carry signatures in raw R||S (JWS Compact). Converting
to the DER form OpenSSL expects needs long-form SEQUENCE length once
R+S exceeds 127 bytes (every ES512 signature). The `raw_ecdsa_to_der`
helper now emits long-form (`0x81`/`0x82`).
2. RSA-PSS verification under OpenSSL 3's provider API requires
`RSA_PKCS1_PSS_PADDING` to be set before any saltlen/MGF1 parameter.
The plugin now passes padding via the dedicated argument and uses
the named `pss_saltlen` setter (digest length per RFC 7518 §3.5),
so parameters are applied in the order the provider enforces.
**0028e83** : TEST 31 (ES512) and TEST 32-35 (RS384/RS512/PS384/PS512) cover
the
newly-added algorithms. The full suite has 35 cases / 105 assertions
and passes against the standard upstream test runner.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]
Alnyli07 commented on code in PR #13165:
URL: https://github.com/apache/apisix/pull/13165#discussion_r3171208553
##
apisix/plugins/dpop.lua:
##
@@ -0,0 +1,1384 @@
+local core = require("apisix.core")
+local cjson = require("cjson.safe")
+local resty_sha256 = require("resty.sha256")
+local http = require("resty.http")
+local openssl_pkey = require("resty.openssl.pkey")
+local lrucache = require("resty.lrucache")
+local ngx = ngx
+
+local plugin_name = "dpop"
+
+-- Module-level local JTI cache — fallback when ngx.shared.dpop_jti_cache
+-- is not configured. Per-worker only; ensures fail-closed per RFC 9449 §11.1.
+local _jti_local_cache = {}
+local _jti_local_count = 0
+
+local function jti_local_cleanup()
+local now = ngx.time()
+local new_count = 0
+for k, expiry in pairs(_jti_local_cache) do
+if expiry <= now then
+_jti_local_cache[k] = nil
+else
+new_count = new_count + 1
+end
+end
+_jti_local_count = new_count
+end
+
+-- PKey LRU cache: JWK JSON → openssl pkey object
+-- 128 entries is generous — typical deployment has 1-3 signing keys
+local _pkey_cache, pkey_cache_err = lrucache.new(128)
+if not _pkey_cache then
+error("failed to create pkey LRU cache: " .. (pkey_cache_err or "unknown"))
+end
+
+-- Module-level JWKS caches
+local _jwks_cache = {} -- jwks_uri -> { keys_by_kid = {kid ->
jwk_table}, fetched_at }
+local _discovery_cache = {} -- discovery_url -> { jwks_uri, fetched_at }
+local _jwks_last_refetch = 0 -- rate limit: max 1 refetch per 60s
+
+-- Introspection cache: module-level local fallback when
ngx.shared.dpop_intro_cache
+-- is not configured. Per-worker only; shared dict preferred for cross-worker
consistency.
+local _introspection_cache = {}
+local _introspection_cache_count = 0
+
+-- Infinispan digest auth nonce cache (per-worker)
+local _ispn_digest_cache = {} -- endpoint -> { nonce, realm, qop, nc }
+
+-- HTTP Digest Auth helper: parse WWW-Authenticate header and compute
Authorization
+local function ispn_digest_auth(www_auth, method, uri, username, password)
+if not www_auth then return nil end
+local realm = www_auth:match('realm="([^"]+)"')
+local nonce = www_auth:match('nonce="([^"]+)"')
+local qop = www_auth:match('qop="([^"]*)"') or www_auth:match('qop=([^,%
]+)')
+if not realm or not nonce then return nil end
+local nc = "0001"
+local cnonce = ngx.md5(tostring(ngx.now()) .. tostring(math.random(1,
99)))
+local ha1 = ngx.md5(username .. ":" .. realm .. ":" .. password)
+local ha2 = ngx.md5(method .. ":" .. uri)
+local response
+if qop and qop:find("auth") then
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. nc
+.. ":" .. cnonce .. ":" .. "auth" .. ":" .. ha2)
+else
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. ha2)
+end
+return 'Digest username="' .. username
+.. '", realm="' .. realm
+.. '", nonce="' .. nonce
+.. '", uri="' .. uri
+.. '", qop=auth, nc=' .. nc
+.. ', cnonce="' .. cnonce
+.. '", response="' .. response .. '"',
+nonce, realm, qop
+end
+
+local schema = {
+type = "object",
+properties = {
+allowed_algs = {
+type = "array",
+items = {
+type = "string",
+enum = {
+"ES256", "ES384", "ES512",
+"RS256", "RS384", "RS512",
+"PS256", "PS384", "PS512",
+},
+},
+default = {"ES256"},
+minItems = 1,
+uniqueItems = true,
+},
+proof_max_age = {
+type = "integer",
+default = 120,
+minimum = 1,
+},
+clock_skew_seconds = {
+type = "integer",
+default = 5,
+minimum = 0,
+},
+replay_cache = {
+type = "object",
+properties = {
+type = {
+type = "string",
+enum = {"memory", "redis", "ispn"},
+default = "memory",
+},
+fallback = {
+type = "string",
+enum = {"memory", "bypass", "reject"},
+default = "memory",
+},
+ttl = {
+type = "integer",
+minimum = 10,
+-- When omitted, falls back to proof_max_age at runtime
+},
+redis = {
+type = "object",
+properties = {
+host = { type = "string", minLength = 1 },
+port = { type = "integer", default = 6379, minimum =
1, maximum = 65535 },
+password = { type = "string" },
+timeout = { type = "integer", default = 2000, minimum
Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]
Alnyli07 commented on code in PR #13165:
URL: https://github.com/apache/apisix/pull/13165#discussion_r3044804510
##
apisix/plugins/dpop.lua:
##
@@ -0,0 +1,1384 @@
+local core = require("apisix.core")
+local cjson = require("cjson.safe")
+local resty_sha256 = require("resty.sha256")
+local http = require("resty.http")
+local openssl_pkey = require("resty.openssl.pkey")
+local lrucache = require("resty.lrucache")
+local ngx = ngx
+
+local plugin_name = "dpop"
+
+-- Module-level local JTI cache — fallback when ngx.shared.dpop_jti_cache
+-- is not configured. Per-worker only; ensures fail-closed per RFC 9449 §11.1.
+local _jti_local_cache = {}
+local _jti_local_count = 0
+
+local function jti_local_cleanup()
+local now = ngx.time()
+local new_count = 0
+for k, expiry in pairs(_jti_local_cache) do
+if expiry <= now then
+_jti_local_cache[k] = nil
+else
+new_count = new_count + 1
+end
+end
+_jti_local_count = new_count
+end
+
+-- PKey LRU cache: JWK JSON → openssl pkey object
+-- 128 entries is generous — typical deployment has 1-3 signing keys
+local _pkey_cache, pkey_cache_err = lrucache.new(128)
+if not _pkey_cache then
+error("failed to create pkey LRU cache: " .. (pkey_cache_err or "unknown"))
+end
+
+-- Module-level JWKS caches
+local _jwks_cache = {} -- jwks_uri -> { keys_by_kid = {kid ->
jwk_table}, fetched_at }
+local _discovery_cache = {} -- discovery_url -> { jwks_uri, fetched_at }
+local _jwks_last_refetch = 0 -- rate limit: max 1 refetch per 60s
+
+-- Introspection cache: module-level local fallback when
ngx.shared.dpop_intro_cache
+-- is not configured. Per-worker only; shared dict preferred for cross-worker
consistency.
+local _introspection_cache = {}
+local _introspection_cache_count = 0
+
+-- Infinispan digest auth nonce cache (per-worker)
+local _ispn_digest_cache = {} -- endpoint -> { nonce, realm, qop, nc }
+
+-- HTTP Digest Auth helper: parse WWW-Authenticate header and compute
Authorization
+local function ispn_digest_auth(www_auth, method, uri, username, password)
+if not www_auth then return nil end
+local realm = www_auth:match('realm="([^"]+)"')
+local nonce = www_auth:match('nonce="([^"]+)"')
+local qop = www_auth:match('qop="([^"]*)"') or www_auth:match('qop=([^,%
]+)')
+if not realm or not nonce then return nil end
+local nc = "0001"
+local cnonce = ngx.md5(tostring(ngx.now()) .. tostring(math.random(1,
99)))
+local ha1 = ngx.md5(username .. ":" .. realm .. ":" .. password)
+local ha2 = ngx.md5(method .. ":" .. uri)
+local response
+if qop and qop:find("auth") then
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. nc
+.. ":" .. cnonce .. ":" .. "auth" .. ":" .. ha2)
+else
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. ha2)
+end
+return 'Digest username="' .. username
+.. '", realm="' .. realm
+.. '", nonce="' .. nonce
+.. '", uri="' .. uri
+.. '", qop=auth, nc=' .. nc
+.. ', cnonce="' .. cnonce
+.. '", response="' .. response .. '"',
+nonce, realm, qop
+end
+
+local schema = {
+type = "object",
+properties = {
+allowed_algs = {
+type = "array",
+items = {
+type = "string",
+enum = {
+"ES256", "ES384", "ES512",
+"RS256", "RS384", "RS512",
+"PS256", "PS384", "PS512",
+},
+},
+default = {"ES256"},
+minItems = 1,
+uniqueItems = true,
+},
+proof_max_age = {
+type = "integer",
+default = 120,
+minimum = 1,
+},
+clock_skew_seconds = {
+type = "integer",
+default = 5,
+minimum = 0,
+},
+replay_cache = {
+type = "object",
+properties = {
+type = {
+type = "string",
+enum = {"memory", "redis", "ispn"},
+default = "memory",
+},
+fallback = {
+type = "string",
+enum = {"memory", "bypass", "reject"},
+default = "memory",
+},
+ttl = {
+type = "integer",
+minimum = 10,
+-- When omitted, falls back to proof_max_age at runtime
+},
+redis = {
+type = "object",
+properties = {
+host = { type = "string", minLength = 1 },
+port = { type = "integer", default = 6379, minimum =
1, maximum = 65535 },
+password = { type = "string" },
+timeout = { type = "integer", default = 2000, minimum
Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]
Alnyli07 commented on code in PR #13165:
URL: https://github.com/apache/apisix/pull/13165#discussion_r3044791664
##
apisix/plugins/dpop.lua:
##
@@ -0,0 +1,1384 @@
+local core = require("apisix.core")
+local cjson = require("cjson.safe")
+local resty_sha256 = require("resty.sha256")
+local http = require("resty.http")
+local openssl_pkey = require("resty.openssl.pkey")
+local lrucache = require("resty.lrucache")
+local ngx = ngx
+
+local plugin_name = "dpop"
+
+-- Module-level local JTI cache — fallback when ngx.shared.dpop_jti_cache
+-- is not configured. Per-worker only; ensures fail-closed per RFC 9449 §11.1.
+local _jti_local_cache = {}
+local _jti_local_count = 0
+
+local function jti_local_cleanup()
+local now = ngx.time()
+local new_count = 0
+for k, expiry in pairs(_jti_local_cache) do
+if expiry <= now then
+_jti_local_cache[k] = nil
+else
+new_count = new_count + 1
+end
+end
+_jti_local_count = new_count
+end
+
+-- PKey LRU cache: JWK JSON → openssl pkey object
+-- 128 entries is generous — typical deployment has 1-3 signing keys
+local _pkey_cache, pkey_cache_err = lrucache.new(128)
+if not _pkey_cache then
+error("failed to create pkey LRU cache: " .. (pkey_cache_err or "unknown"))
+end
+
+-- Module-level JWKS caches
+local _jwks_cache = {} -- jwks_uri -> { keys_by_kid = {kid ->
jwk_table}, fetched_at }
+local _discovery_cache = {} -- discovery_url -> { jwks_uri, fetched_at }
+local _jwks_last_refetch = 0 -- rate limit: max 1 refetch per 60s
+
+-- Introspection cache: module-level local fallback when
ngx.shared.dpop_intro_cache
+-- is not configured. Per-worker only; shared dict preferred for cross-worker
consistency.
+local _introspection_cache = {}
+local _introspection_cache_count = 0
+
+-- Infinispan digest auth nonce cache (per-worker)
+local _ispn_digest_cache = {} -- endpoint -> { nonce, realm, qop, nc }
+
+-- HTTP Digest Auth helper: parse WWW-Authenticate header and compute
Authorization
+local function ispn_digest_auth(www_auth, method, uri, username, password)
+if not www_auth then return nil end
+local realm = www_auth:match('realm="([^"]+)"')
+local nonce = www_auth:match('nonce="([^"]+)"')
+local qop = www_auth:match('qop="([^"]*)"') or www_auth:match('qop=([^,%
]+)')
+if not realm or not nonce then return nil end
+local nc = "0001"
+local cnonce = ngx.md5(tostring(ngx.now()) .. tostring(math.random(1,
99)))
+local ha1 = ngx.md5(username .. ":" .. realm .. ":" .. password)
+local ha2 = ngx.md5(method .. ":" .. uri)
+local response
+if qop and qop:find("auth") then
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. nc
+.. ":" .. cnonce .. ":" .. "auth" .. ":" .. ha2)
+else
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. ha2)
+end
+return 'Digest username="' .. username
+.. '", realm="' .. realm
+.. '", nonce="' .. nonce
+.. '", uri="' .. uri
+.. '", qop=auth, nc=' .. nc
+.. ', cnonce="' .. cnonce
+.. '", response="' .. response .. '"',
+nonce, realm, qop
+end
+
+local schema = {
+type = "object",
+properties = {
+allowed_algs = {
+type = "array",
+items = {
+type = "string",
+enum = {
+"ES256", "ES384", "ES512",
+"RS256", "RS384", "RS512",
+"PS256", "PS384", "PS512",
+},
Review Comment:
Thanks for flagging this.
Our primary test environment uses Keycloak which defaults to ES256 for DPoP
proofs, and RS256/384/512 for access token signing — so those were the
algorithms we had full E2E coverage for. The schema was populated from the
full RFC 9449 recommended algorithm list for completeness, but the
verification implementation didn't keep pace with the schema. That's on us.
We'll add ES384, ES512, PS256, PS384, and PS512 support —
`resty.openssl.pkey`
already handles these, it's mainly a matter of mapping the correct hash
algorithm and padding parameters.
Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]
Baoyuantop commented on code in PR #13165:
URL: https://github.com/apache/apisix/pull/13165#discussion_r3043015632
##
apisix/plugins/dpop.lua:
##
@@ -0,0 +1,1384 @@
+local core = require("apisix.core")
+local cjson = require("cjson.safe")
+local resty_sha256 = require("resty.sha256")
+local http = require("resty.http")
+local openssl_pkey = require("resty.openssl.pkey")
+local lrucache = require("resty.lrucache")
+local ngx = ngx
+
+local plugin_name = "dpop"
+
+-- Module-level local JTI cache — fallback when ngx.shared.dpop_jti_cache
+-- is not configured. Per-worker only; ensures fail-closed per RFC 9449 §11.1.
+local _jti_local_cache = {}
+local _jti_local_count = 0
+
+local function jti_local_cleanup()
+local now = ngx.time()
+local new_count = 0
+for k, expiry in pairs(_jti_local_cache) do
+if expiry <= now then
+_jti_local_cache[k] = nil
+else
+new_count = new_count + 1
+end
+end
+_jti_local_count = new_count
+end
+
+-- PKey LRU cache: JWK JSON → openssl pkey object
+-- 128 entries is generous — typical deployment has 1-3 signing keys
+local _pkey_cache, pkey_cache_err = lrucache.new(128)
+if not _pkey_cache then
+error("failed to create pkey LRU cache: " .. (pkey_cache_err or "unknown"))
+end
+
+-- Module-level JWKS caches
+local _jwks_cache = {} -- jwks_uri -> { keys_by_kid = {kid ->
jwk_table}, fetched_at }
+local _discovery_cache = {} -- discovery_url -> { jwks_uri, fetched_at }
+local _jwks_last_refetch = 0 -- rate limit: max 1 refetch per 60s
+
+-- Introspection cache: module-level local fallback when
ngx.shared.dpop_intro_cache
+-- is not configured. Per-worker only; shared dict preferred for cross-worker
consistency.
+local _introspection_cache = {}
+local _introspection_cache_count = 0
+
+-- Infinispan digest auth nonce cache (per-worker)
+local _ispn_digest_cache = {} -- endpoint -> { nonce, realm, qop, nc }
+
+-- HTTP Digest Auth helper: parse WWW-Authenticate header and compute
Authorization
+local function ispn_digest_auth(www_auth, method, uri, username, password)
+if not www_auth then return nil end
+local realm = www_auth:match('realm="([^"]+)"')
+local nonce = www_auth:match('nonce="([^"]+)"')
+local qop = www_auth:match('qop="([^"]*)"') or www_auth:match('qop=([^,%
]+)')
+if not realm or not nonce then return nil end
+local nc = "0001"
+local cnonce = ngx.md5(tostring(ngx.now()) .. tostring(math.random(1,
99)))
+local ha1 = ngx.md5(username .. ":" .. realm .. ":" .. password)
+local ha2 = ngx.md5(method .. ":" .. uri)
+local response
+if qop and qop:find("auth") then
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. nc
+.. ":" .. cnonce .. ":" .. "auth" .. ":" .. ha2)
+else
+response = ngx.md5(ha1 .. ":" .. nonce .. ":" .. ha2)
+end
+return 'Digest username="' .. username
+.. '", realm="' .. realm
+.. '", nonce="' .. nonce
+.. '", uri="' .. uri
+.. '", qop=auth, nc=' .. nc
+.. ', cnonce="' .. cnonce
+.. '", response="' .. response .. '"',
+nonce, realm, qop
+end
+
+local schema = {
+type = "object",
+properties = {
+allowed_algs = {
+type = "array",
+items = {
+type = "string",
+enum = {
+"ES256", "ES384", "ES512",
+"RS256", "RS384", "RS512",
+"PS256", "PS384", "PS512",
+},
+},
+default = {"ES256"},
+minItems = 1,
+uniqueItems = true,
+},
+proof_max_age = {
+type = "integer",
+default = 120,
+minimum = 1,
+},
+clock_skew_seconds = {
+type = "integer",
+default = 5,
+minimum = 0,
+},
+replay_cache = {
+type = "object",
+properties = {
+type = {
+type = "string",
+enum = {"memory", "redis", "ispn"},
+default = "memory",
+},
+fallback = {
+type = "string",
+enum = {"memory", "bypass", "reject"},
+default = "memory",
+},
+ttl = {
+type = "integer",
+minimum = 10,
+-- When omitted, falls back to proof_max_age at runtime
+},
+redis = {
+type = "object",
+properties = {
+host = { type = "string", minLength = 1 },
+port = { type = "integer", default = 6379, minimum =
1, maximum = 65535 },
+password = { type = "string" },
+timeout = { type = "integer", default = 2000, minimu
