Re: [PR] feat(plugin): add dpop plugin for RFC 9449 proof-of-possession [apisix]

2026-06-10 Thread via GitHub


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]

2026-05-27 Thread via GitHub


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]

2026-05-22 Thread via GitHub


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]

2026-04-30 Thread via GitHub


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]

2026-04-30 Thread via GitHub


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]

2026-04-30 Thread via GitHub


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]

2026-04-07 Thread via GitHub


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]

2026-04-07 Thread via GitHub


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]

2026-04-06 Thread via GitHub


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