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 = "00000001"
+ local cnonce = ngx.md5(tostring(ngx.now()) .. tostring(math.random(1,
999999)))
+ 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
= 100 },
+ },
+ },
+ ispn = {
+ type = "object",
+ properties = {
+ endpoint = { type = "string", minLength = 1 },
+ cache_name = { type = "string", default = "dpop-jti",
minLength = 1 },
+ username = { type = "string" },
+ password = { type = "string" },
+ },
+ },
+ },
+ },
+ strict_htu = {
+ type = "boolean",
+ default = false
+ },
+ public_base_url = {
+ type = "string",
+ default = "",
+ pattern = "^$|^https?://",
+ },
+ require_nonce = {
+ type = "boolean",
+ default = false
+ },
+ send_thumbprint_header = {
+ type = "boolean",
+ default = true
+ },
+ discovery = {
+ type = "string",
+ pattern = "^https?://",
+ },
+ jwks_uri = {
+ type = "string",
+ pattern = "^https?://",
+ },
+ token_signing_algorithm = {
+ type = "string",
+ default = "RS256",
+ enum = {"RS256", "RS384", "RS512"},
+ },
+ jwks_cache_ttl = {
+ type = "integer",
+ default = 86400,
+ minimum = 60,
+ maximum = 604800,
+ },
+ verify_access_token = {
+ type = "boolean",
+ default = true,
+ },
+ introspection_endpoint = {
+ type = "string",
+ pattern = "^https?://",
+ },
+ introspection_client_id = {
+ type = "string",
+ minLength = 1,
+ },
+ introspection_client_secret = {
+ type = "string",
+ },
+ introspection_cache_ttl = {
+ type = "integer",
+ default = 0,
+ minimum = 0,
+ maximum = 3600,
+ },
+ enforce_introspection = {
+ type = "boolean",
+ default = false,
+ },
+ uri_allow = {
+ type = "array",
+ items = { type = "string", minLength = 1 },
+ default = {},
+ uniqueItems = true,
+ },
+ token_issuer = {
+ type = "string",
+ default = "",
+ },
+ },
+ additionalProperties = false,
+}
+
+local _M = {
+ version = 0.1,
+ priority = 2601,
+ name = plugin_name,
+ schema = schema,
+ description = "RFC 9449 DPoP (Demonstrating Proof of Possession) "
+ .. "proof validation for sender-constrained access tokens.",
+}
+
+function _M.check_schema(conf, schema_type)
+ local ok, err = core.schema.check(schema, conf)
+ if not ok then
+ return false, err
+ end
+
+ -- Security Guardrail: Prevent Replay Vulnerability
+ -- replay_cache.ttl must be >= proof_max_age + clock_skew_seconds
+ local max_age = conf.proof_max_age or 120
+ local skew = conf.clock_skew_seconds or 5
+ local required_ttl = max_age + skew
+
+ if conf.replay_cache and conf.replay_cache.ttl and conf.replay_cache.ttl >
0 then
+ if conf.replay_cache.ttl < required_ttl then
+ return false, "SECURITY ERROR: replay_cache.ttl (" ..
conf.replay_cache.ttl
+ .. ") must be >= proof_max_age + clock_skew_seconds (" ..
required_ttl
+ .. ") to prevent replay attacks."
+ end
+ end
+
+ -- Dependency: enforce_introspection requires introspection_endpoint
+ if conf.enforce_introspection then
+ if not conf.introspection_endpoint or conf.introspection_endpoint ==
"" then
+ return false, "enforce_introspection=true requires
introspection_endpoint"
+ end
+ end
+
+ -- Dependency: strict_htu requires public_base_url
+ if conf.strict_htu then
+ if not conf.public_base_url or conf.public_base_url == "" then
+ return false, "strict_htu=true requires public_base_url"
+ end
+ end
+
+ -- Dependency: replay_cache.type=redis requires redis.host
+ if conf.replay_cache and conf.replay_cache.type == "redis" then
+ if not conf.replay_cache.redis
+ or not conf.replay_cache.redis.host
+ or conf.replay_cache.redis.host == "" then
+ return false, "replay_cache.type=redis requires
replay_cache.redis.host"
+ end
+ end
+
+ -- Dependency: replay_cache.type=ispn requires ispn.endpoint
+ if conf.replay_cache and conf.replay_cache.type == "ispn" then
+ if not conf.replay_cache.ispn
+ or not conf.replay_cache.ispn.endpoint
+ or conf.replay_cache.ispn.endpoint == "" then
+ return false, "replay_cache.type=ispn requires
replay_cache.ispn.endpoint"
+ end
+ end
+
+ return true
+end
+
+-- ===========================================================================
+-- Helpers
+-- ===========================================================================
+
+local function base64url_decode(input)
+ local s = input:gsub("-", "+"):gsub("_", "/")
+ local pad = #s % 4
+ if pad == 2 then
+ s = s .. "=="
+ elseif pad == 3 then
+ s = s .. "="
+ end
+ return ngx.decode_base64(s)
+end
+
+
+local function base64url_encode(input)
+ local b64 = ngx.encode_base64(input)
+ return b64:gsub("+", "-"):gsub("/", "_"):gsub("=", "")
+end
+
+
+local function sha256_b64url(data)
+ local sha = resty_sha256:new()
+ sha:update(data)
+ local digest = sha:final()
+ return base64url_encode(digest)
+end
+
+-- Structured audit log — one line per DPoP decision (success or failure)
+local function dpop_audit(result, err_code, desc, method, uri, jti, issuer,
client_id)
+ local trace_id = ngx.var.opentelemetry_trace_id or ""
+
+ core.log.warn("[DPoP-Audit] ",
+ cjson.encode({
+ result = result,
+ error = err_code,
+ desc = desc,
+ method = method,
+ uri = uri,
+ jti = jti,
+ issuer = issuer or "",
+ client_id = client_id or "",
+ trace_id = trace_id,
+ }))
+end
+
+
+local function dpop_error(err_code, desc)
+ ngx.header["WWW-Authenticate"] = 'DPoP error="' .. err_code .. '"'
+ .. (desc and (', error_description="' .. desc .. '"') or "")
+ return 401, { error = err_code, error_description = desc }
+end
+
+
+local function parse_jwt(token)
+ local parts = {}
+ for part in token:gmatch("[^%.]+") do
+ parts[#parts + 1] = part
+ end
+
+ if #parts ~= 3 then
+ return nil, "invalid JWT: expected 3 parts, got " .. #parts
+ end
+
+ local header_json = base64url_decode(parts[1])
+ if not header_json then
+ return nil, "invalid JWT: failed to decode header"
+ end
+
+ local payload_json = base64url_decode(parts[2])
+ if not payload_json then
+ return nil, "invalid JWT: failed to decode payload"
+ end
+
+ local header, h_err = cjson.decode(header_json)
+ if not header then
+ return nil, "invalid JWT: failed to parse header JSON: " .. (h_err or
"unknown")
+ end
+
+ local payload, p_err = cjson.decode(payload_json)
+ if not payload then
+ return nil, "invalid JWT: failed to parse payload JSON: " .. (p_err or
"unknown")
+ end
+
+ return {
+ header = header,
+ payload = payload,
+ raw_parts = parts,
+ }
+end
+
+
+local function extract_path(url)
+ -- Extract path from full URL, stripping query/fragment
+ local path = url:match("^https?://[^/]+(/.*)$") or url
+ path = path:match("^([^%?#]*)") or path
+ return path
+end
+
+-- RFC 7638 JWK Thumbprint
+local function compute_jwk_thumbprint(jwk)
+ if not jwk or not jwk.kty then
+ return nil, "jwk missing or no kty"
+ end
+
+ local canonical
+ if jwk.kty == "EC" then
+ if not jwk.crv or not jwk.x or not jwk.y then
+ return nil, "EC key missing crv, x, or y"
+ end
+ -- RFC 7638: members in lexicographic order
+ canonical = '{"crv":"' .. jwk.crv .. '","kty":"' .. jwk.kty
+ .. '","x":"' .. jwk.x .. '","y":"' .. jwk.y .. '"}'
+ elseif jwk.kty == "RSA" then
+ if not jwk.e or not jwk.n then
+ return nil, "RSA key missing e or n"
+ end
+ canonical = '{"e":"' .. jwk.e .. '","kty":"' .. jwk.kty
+ .. '","n":"' .. jwk.n .. '"}'
+ elseif jwk.kty == "OKP" then
+ if not jwk.crv or not jwk.x then
+ return nil, "OKP key missing crv or x"
+ end
+ canonical = '{"crv":"' .. jwk.crv .. '","kty":"' .. jwk.kty
+ .. '","x":"' .. jwk.x .. '"}'
+ else
+ return nil, "unsupported key type: " .. jwk.kty
+ end
+
+ local sha = resty_sha256:new()
+ sha:update(canonical)
+ local digest = sha:final()
+
+ return base64url_encode(digest)
+end
+
+-- Get or create openssl pkey from JWK, cached by JSON representation
+local function get_or_create_pkey(jwk)
+ local jwk_json = cjson.encode(jwk)
+ local pkey = _pkey_cache:get(jwk_json)
+ if pkey then
+ return pkey
+ end
+ local new_pkey, err = openssl_pkey.new(jwk_json, { format = "JWK" })
+ if not new_pkey then
+ return nil, err
+ end
+ _pkey_cache:set(jwk_json, new_pkey, 3600) -- 1 hour TTL
+ return new_pkey
+end
+
+-- ===========================================================================
+-- JWKS fetching + Access Token Signature Verification
+-- ===========================================================================
+
+local function resolve_jwks_uri(conf)
+ -- Direct jwks_uri takes precedence
+ if conf.jwks_uri and conf.jwks_uri ~= "" then
+ return conf.jwks_uri
+ end
+
+ -- Resolve from discovery document
+ if not conf.discovery or conf.discovery == "" then
+ return nil, "no discovery or jwks_uri configured"
+ end
+
+ local now = ngx.time()
+ local cached = _discovery_cache[conf.discovery]
+ local ttl = conf.jwks_cache_ttl or 86400
+ if cached and (now - cached.fetched_at) < ttl then
+ return cached.jwks_uri
+ end
+
+ local httpc = http.new()
+ httpc:set_timeout(5000)
+ local res, err = httpc:request_uri(conf.discovery, { method = "GET",
ssl_verify = false })
Review Comment:
Landed in **7b6c419**. The hardcoded `false` was a leftover from local
development.
The schema gains an `ssl_verify` boolean (default `true`), propagated
to every outbound HTTP call the plugin issues:
- `resolve_jwks_uri` (OIDC discovery fetch)
- `fetch_jwks` (JWKS fetch; the function signature now takes
`ssl_verify` so the call site can pass `conf.ssl_verify` through)
- `jti_check` (Infinispan replay-cache backend, including the
digest-auth retry path)
- `call_introspection` (RFC 7662 introspection endpoint)
Default `true` keeps existing deployments behaving the same as before;
the opt-out is only available when operators set it explicitly. Same
shape as `openid-connect`'s `ssl_verify`.
--
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]