This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 39b9e43019 feat(openid-connect): support PAR and DPoP client options 
(#13649)
39b9e43019 is described below

commit 39b9e43019222ee3f78bc185b6fa81ca5d327a97
Author: LIAN_XIAOYI <[email protected]>
AuthorDate: Tue Aug 11 09:58:11 2026 +0900

    feat(openid-connect): support PAR and DPoP client options (#13649)
---
 apisix-master-0.rockspec                 |    2 +-
 apisix/plugins/openid-connect.lua        |  425 ++++++++++-
 docs/en/latest/plugins/openid-connect.md |   63 +-
 docs/zh/latest/plugins/openid-connect.md |   63 +-
 t/plugin/openid-connect.t                | 1166 +++++++++++++++++++++++++++++-
 t/plugin/openid-connect11.t              |   84 ++-
 6 files changed, 1753 insertions(+), 50 deletions(-)

diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index 2b5b290e4d..1e2f1de345 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -51,7 +51,7 @@ dependencies = {
     "opentracing-openresty = 0.1-0",
     "lua-resty-radixtree = 2.9.2-0",
     "lua-protobuf = 0.5.3-1",
-    "lua-resty-openidc = 1.8.0-1",
+    "lua-resty-openidc = 1.9.0-1",
     "lua-resty-saml = 0.2.5",
     "luafilesystem = 1.8.0-1",
     "nginx-lua-prometheus-api7 = 1.0.0-1",
diff --git a/apisix/plugins/openid-connect.lua 
b/apisix/plugins/openid-connect.lua
index 3cf05e0fd4..054cc6f1b1 100644
--- a/apisix/plugins/openid-connect.lua
+++ b/apisix/plugins/openid-connect.lua
@@ -20,6 +20,8 @@ local secret            = require("apisix.secret")
 local ngx_re            = require("ngx.re")
 local openidc           = require("resty.openidc")
 local jsonschema        = require('jsonschema')
+local pkey              = require("resty.openssl.pkey")
+local dump_jwk          = require("resty.openssl.auxiliary.jwk").dump_jwk
 local string            = string
 local ngx               = ngx
 local ipairs            = ipairs
@@ -27,6 +29,7 @@ local type              = type
 local tostring          = tostring
 local pcall             = pcall
 local concat            = table.concat
+local unpack            = unpack
 
 local ngx_encode_base64 = ngx.encode_base64
 
@@ -58,6 +61,75 @@ local function build_session_opts(session_conf)
 end
 
 
+-- The nested par/dpop objects own these lua-resty-openidc option names. The
+-- root schema accepts unknown properties, so without an explicit rejection a
+-- config could set them directly and reach the library unvalidated: the flat
+-- DPoP key is not covered by encrypt_fields and would be stored in etcd in
+-- plaintext, and the flat PAR endpoint would skip the https check.
+local reserved_flat_opts = {
+    {name = "use_par", owner = "par.enabled"},
+    {name = "pushed_authorization_request_endpoint", owner = "par.endpoint"},
+    {name = "pushed_authorization_request_endpoint_auth_method",
+     owner = "par.endpoint_auth_method"},
+    {name = "use_dpop", owner = "dpop.enabled"},
+    {name = "dpop_signing_alg", owner = "dpop.signing_alg"},
+    {name = "dpop_private_key", owner = "dpop.private_key"},
+    {name = "dpop_public_jwk", owner = "dpop.public_jwk"},
+}
+
+-- lua-resty-openidc builds the DPoP proof with resty.openssl, so the JWK has
+-- to match the signing algorithm: ES256 signs with an EC key on P-256 per
+-- RFC 7518, RS256 and PS256 with an RSA one.
+local dpop_alg_key_type = {
+    ES256 = {kty = "EC", crv = "P-256"},
+    RS256 = {kty = "RSA"},
+    PS256 = {kty = "RSA"},
+}
+
+local dpop_jwk_required_members = {
+    EC = {"crv", "x", "y"},
+    RSA = {"e", "n"},
+}
+
+-- resty.jwt selects the signer from the algorithm and not from the key, so an
+-- EC algorithm handed an RSA key terminates the worker with a SIGSEGV; the
+-- curve matters too, since ES512 on a P-256 key emits a P-256 signature
+local client_assertion_key_type = {
+    RS256 = {kty = "RSA"},
+    RS512 = {kty = "RSA"},
+    ES256 = {kty = "EC", crv = "P-256"},
+    ES512 = {kty = "EC", crv = "P-521"},
+}
+
+-- the client authentication methods lua-resty-openidc can use, and the
+-- credential each one needs
+local token_auth_method_credential = {
+    client_secret_basic = false,
+    client_secret_post = false,
+    private_key_jwt = "client_rsa_private_key",
+    client_secret_jwt = "client_secret",
+}
+
+
+local function flatten_openidc_options(conf)
+    if conf.par then
+        conf.use_par = conf.par.enabled
+        conf.pushed_authorization_request_endpoint = conf.par.endpoint
+        conf.pushed_authorization_request_endpoint_auth_method =
+            conf.par.endpoint_auth_method
+        conf.par = nil
+    end
+
+    if conf.dpop then
+        conf.use_dpop = conf.dpop.enabled
+        conf.dpop_signing_alg = conf.dpop.signing_alg
+        conf.dpop_private_key = conf.dpop.private_key
+        conf.dpop_public_jwk = conf.dpop.public_jwk
+        conf.dpop = nil
+    end
+end
+
+
 local schema = {
     type = "object",
     properties = {
@@ -304,6 +376,72 @@ local schema = {
             type = "boolean",
             default = false
         },
+        par = {
+            description = "Pushed Authorization Requests (PAR) configuration.",
+            type = "object",
+            properties = {
+                enabled = {
+                    description = "When true, use Pushed Authorization 
Requests (PAR).",
+                    type = "boolean",
+                    default = false,
+                },
+                endpoint = {
+                    description = "URL of the Pushed Authorization Requests 
endpoint.",
+                    type = "string",
+                },
+                endpoint_auth_method = {
+                    description = "Authentication method for the PAR 
endpoint.",
+                    type = "string",
+                    enum = {"client_secret_basic", "client_secret_post",
+                            "private_key_jwt", "client_secret_jwt"},
+                },
+            },
+            additionalProperties = false,
+        },
+        dpop = {
+            description = "Demonstrating Proof-of-Possession (DPoP) 
configuration.",
+            type = "object",
+            properties = {
+                enabled = {
+                    description = "When true, use DPoP proof JWTs.",
+                    type = "boolean",
+                    default = false,
+                },
+                signing_alg = {
+                    description = "DPoP proof JWT signing algorithm.",
+                    type = "string",
+                    enum = {"ES256", "RS256", "PS256"},
+                    default = "ES256",
+                },
+                private_key = {
+                    description = "Private key used to sign DPoP proof JWTs.",
+                    type = "string",
+                },
+                public_jwk = {
+                    description = "Public JWK matching dpop.private_key.",
+                    type = "object",
+                    ["not"] = {anyOf = {
+                        {required = {"d"}},
+                        {required = {"p"}},
+                        {required = {"q"}},
+                        {required = {"dp"}},
+                        {required = {"dq"}},
+                        {required = {"qi"}},
+                        {required = {"oth"}},
+                        {required = {"k"}},
+                    }},
+                },
+            },
+            ["if"] = {
+                properties = {
+                    enabled = { const = true },
+                },
+            },
+            ["then"] = {
+                required = {"private_key", "public_jwk"},
+            },
+            additionalProperties = false,
+        },
         set_access_token_header = {
             description = "Whether the access token should be added as a 
header to the request " ..
                 "for downstream",
@@ -383,6 +521,22 @@ local schema = {
             type = "integer",
             default = 60
         },
+        -- resty.jwt signs the client assertion and raises an uncaught Lua
+        -- error for an algorithm it cannot handle, so an unconstrained value
+        -- would surface as a 500 per request instead of a rejected config.
+        -- Two rocks provide resty/jwt.lua here: the api7-lua-resty-jwt this
+        -- rockspec pins, and the lua-resty-jwt lua-resty-openidc depends on.
+        -- This enum is what the api7 fork signs, a subset of what the other
+        -- one signs, so it holds whichever of the two ends up installed.
+        client_jwt_assertion_alg = {
+            description = "Signing algorithm for the client assertion JWT.",
+            type = "string",
+            enum = {"HS256", "HS512", "RS256", "RS512", "ES256", "ES512"}
+        },
+        client_jwt_assertion_audience = {
+            description = "Audience for the client assertion JWT.",
+            type = "string"
+        },
         renew_access_token_on_expiry = {
             description = "Whether to attempt silently renewing the access 
token.",
             type = "boolean",
@@ -476,7 +630,7 @@ local schema = {
             default = nil,
         }
     },
-    encrypt_fields = {"client_secret", "client_rsa_private_key",
+    encrypt_fields = {"client_secret", "client_rsa_private_key", 
"dpop.private_key",
                       "session.secret", "session.redis.password"},
     required = {"client_id", "discovery"}
 }
@@ -488,8 +642,237 @@ local _M = {
     name = plugin_name,
     schema = schema,
     _build_session_opts = build_session_opts,
+    _flatten_openidc_options = flatten_openidc_options,
 }
 
+-- lua-resty-openidc rejects a JWK that lacks the members its key type needs,
+-- but only once the first authorization request builds the thumbprint, which
+-- surfaces as a 500 per request instead of a rejected configuration.
+-- The public JWK openssl derives from a private key, so a configured JWK can
+-- be checked against the key that will actually sign with it.
+local function private_key_jwk(pem, field)
+    local key, err = pkey.new(pem)
+    if not key then
+        return nil, "property \"" .. field .. "\" is not a valid key: " .. 
tostring(err)
+    end
+    if not key:is_private() then
+        return nil, "property \"" .. field .. "\" has no private key in it"
+    end
+
+    local ok, dumped = pcall(dump_jwk, key, false)
+    if not ok or not dumped then
+        return nil, "property \"" .. field .. "\" could not be read as a JWK"
+    end
+
+    local jwk
+    jwk, err = core.json.decode(dumped)
+    if not jwk then
+        return nil, "property \"" .. field .. "\" could not be read as a JWK: "
+                    .. tostring(err)
+    end
+    return jwk
+end
+
+
+local function check_dpop_key(dpop)
+    -- lua-resty-openidc reads none of this while use_dpop is false, so a
+    -- configuration that stages the key material before turning DPoP on is
+    -- valid and must not be rejected
+    if not (dpop and dpop.enabled) then
+        return true
+    end
+
+    local jwk = dpop.public_jwk
+    if not jwk then
+        return true
+    end
+
+    local required = dpop_jwk_required_members[jwk.kty]
+    if not required then
+        return false, "property \"dpop.public_jwk\" validation failed: kty \""
+                      .. tostring(jwk.kty) .. "\" is not supported"
+    end
+
+    for _, member in ipairs(required) do
+        if jwk[member] == nil then
+            return false, "property \"dpop.public_jwk\" validation failed: kty 
\""
+                          .. jwk.kty .. "\" requires " .. concat(required, ", 
")
+        end
+        -- the members go into the RFC 7638 thumbprint verbatim, so a 
non-string
+        -- would be encoded as itself and produce a thumbprint no OP can match
+        if type(jwk[member]) ~= "string" or jwk[member] == "" then
+            return false, "property \"dpop.public_jwk\" validation failed: \""
+                          .. member .. "\" must be a non-empty string"
+        end
+    end
+
+    local expected = dpop_alg_key_type[dpop.signing_alg]
+    if not expected then
+        return true
+    end
+
+    if expected.kty ~= jwk.kty then
+        return false, "property \"dpop.signing_alg\" \"" .. dpop.signing_alg
+                      .. "\" requires an " .. expected.kty .. " 
\"dpop.public_jwk\""
+    end
+    if expected.crv and jwk.crv ~= expected.crv then
+        return false, "property \"dpop.signing_alg\" \"" .. dpop.signing_alg
+                      .. "\" requires \"dpop.public_jwk\" crv \"" .. 
expected.crv
+                      .. "\", got \"" .. tostring(jwk.crv) .. "\""
+    end
+
+    -- The proof is signed with the private key and advertises the JWK, so the
+    -- two have to be the same key pair: otherwise the OP gets a proof it
+    -- cannot verify. Comparing the derived JWK covers that, and with it the
+    -- private key's own type and curve.
+    local private_key = dpop.private_key
+    if not private_key or secret.is_secret_ref(private_key) then
+        return true
+    end
+
+    local derived, err = private_key_jwk(private_key, "dpop.private_key")
+    if not derived then
+        return false, err
+    end
+
+    for _, member in ipairs({"kty", unpack(required)}) do
+        if derived[member] ~= jwk[member] then
+            return false, "property \"dpop.public_jwk\" is not the public key 
of "
+                          .. "\"dpop.private_key\": " .. member .. " is \""
+                          .. tostring(derived[member]) .. "\""
+        end
+    end
+
+    return true
+end
+
+
+-- Which endpoints a configuration can actually reach, and with which client
+-- authentication method. rewrite() only calls introspect() when one of
+-- bearer_only/introspection_endpoint/public_key/use_jwks is set, and inside it
+-- public_key and use_jwks take the local JWT verification branch instead. The
+-- token and PAR endpoints belong to the authorization code flow, which
+-- bearer_only never runs.
+local function reachable_jwt_auth(conf)
+    local reachable = {}
+
+    if not (conf.public_key or conf.use_jwks)
+       and (conf.bearer_only or conf.introspection_endpoint) then
+        core.table.insert(reachable, {name = 
"introspection_endpoint_auth_method",
+                                      method = 
conf.introspection_endpoint_auth_method})
+    end
+
+    if not conf.bearer_only then
+        -- ensure_config() replaces an unusable token_endpoint_auth_method
+        -- before any endpoint is called (openidc.lua:1209), so it only counts
+        -- while its credential is there
+        local method = conf.token_endpoint_auth_method
+        local credential = token_auth_method_credential[method]
+        if credential == nil or credential == false or conf[credential] then
+            core.table.insert(reachable, {name = "token_endpoint_auth_method",
+                                          method = method})
+        end
+
+        -- an explicit PAR method reaches the request unchanged; without one
+        -- PAR uses the resolved token method, which is already counted above
+        if conf.par and conf.par.enabled and conf.par.endpoint_auth_method then
+            core.table.insert(reachable, {name = "par.endpoint_auth_method",
+                                          method = 
conf.par.endpoint_auth_method})
+        end
+    end
+
+    return reachable
+end
+
+
+-- The token endpoint only logs and falls back when its auth method cannot be
+-- used, but the PAR request fails outright (openidc.lua:547), which surfaces
+-- as a 500. Only an explicit PAR method gets there unchanged: without one PAR
+-- uses whatever ensure_config() resolved, which is usable by construction.
+local function check_par_auth_method(conf)
+    if conf.bearer_only or not (conf.par and conf.par.enabled) then
+        return true
+    end
+
+    local method = conf.par.endpoint_auth_method
+    if not method then
+        return true
+    end
+
+    local credential = token_auth_method_credential[method]
+    if credential and not conf[credential] then
+        return false, "property \"par.endpoint_auth_method\" \"" .. method
+                      .. "\" requires \"" .. credential .. "\" when 
\"par.enabled\" is true"
+    end
+
+    return true
+end
+
+
+-- The client assertion is signed with a single algorithm, but each endpoint
+-- picks its own auth method. lua-resty-openidc rejects a symmetric algorithm
+-- with private_key_jwt and an asymmetric one with client_secret_jwt when the
+-- endpoint is called, which surfaces as a 500. With no algorithm configured
+-- the library defaults per auth method, so the families cannot conflict.
+local function check_client_jwt_assertion_alg(conf)
+    local alg = conf.client_jwt_assertion_alg
+    if not alg then
+        return true
+    end
+
+    local asymmetric_by, symmetric_by
+    for _, selection in ipairs(reachable_jwt_auth(conf)) do
+        if selection.method == "private_key_jwt" then
+            asymmetric_by = asymmetric_by or selection.name
+        elseif selection.method == "client_secret_jwt" then
+            symmetric_by = symmetric_by or selection.name
+        end
+    end
+
+    if asymmetric_by and symmetric_by then
+        return false, "property \"client_jwt_assertion_alg\" is a single 
algorithm, "
+                      .. "but \"" .. asymmetric_by .. "\" selects 
private_key_jwt and \""
+                      .. symmetric_by .. "\" selects client_secret_jwt"
+    end
+
+    local is_symmetric = alg:sub(1, 2) == "HS"
+    if asymmetric_by and is_symmetric then
+        return false, "property \"client_jwt_assertion_alg\" \"" .. alg
+                      .. "\" is symmetric and cannot be used with the 
private_key_jwt "
+                      .. "selected by \"" .. asymmetric_by .. "\""
+    end
+    if symmetric_by and not is_symmetric then
+        return false, "property \"client_jwt_assertion_alg\" \"" .. alg
+                      .. "\" is asymmetric and cannot be used with the 
client_secret_jwt "
+                      .. "selected by \"" .. symmetric_by .. "\""
+    end
+
+    -- resty.jwt picks the signer from the algorithm, not the key: handing an
+    -- EC algorithm an RSA key terminates the worker with a SIGSEGV in the
+    -- signature conversion, and an EC key on the wrong curve silently emits a
+    -- signature of the wrong size
+    local expected = asymmetric_by and client_assertion_key_type[alg]
+    local private_key = conf.client_rsa_private_key
+    if not expected or not private_key or secret.is_secret_ref(private_key) 
then
+        return true
+    end
+
+    local derived, err = private_key_jwk(private_key, "client_rsa_private_key")
+    if not derived then
+        return false, err
+    end
+    if derived.kty ~= expected.kty then
+        return false, "property \"client_jwt_assertion_alg\" \"" .. alg
+                      .. "\" requires an " .. expected.kty .. " 
\"client_rsa_private_key\""
+    end
+    if expected.crv and derived.crv ~= expected.crv then
+        return false, "property \"client_jwt_assertion_alg\" \"" .. alg
+                      .. "\" requires a \"client_rsa_private_key\" on curve \""
+                      .. expected.crv .. "\", got \"" .. tostring(derived.crv) 
.. "\""
+    end
+
+    return true
+end
 function _M.check_schema(conf)
     if conf.ssl_verify == "no" then
         -- we used to set 'ssl_verify' to "no"
@@ -521,7 +904,8 @@ function _M.check_schema(conf)
     end
 
     local check = {"discovery", "introspection_endpoint", "redirect_uri",
-                    "post_logout_redirect_uri", "proxy_opts.http_proxy", 
"proxy_opts.https_proxy"}
+                    "post_logout_redirect_uri", "par.endpoint", 
"proxy_opts.http_proxy",
+                    "proxy_opts.https_proxy"}
     core.utils.check_https(check, conf, plugin_name)
     core.utils.check_tls_bool({"ssl_verify"}, conf, plugin_name)
 
@@ -537,6 +921,28 @@ function _M.check_schema(conf)
         end
     end
 
+    for _, opt in ipairs(reserved_flat_opts) do
+        if conf[opt.name] ~= nil then
+            return false, "property \"" .. opt.name .. "\" is not allowed, use 
\""
+                          .. opt.owner .. "\" instead"
+        end
+    end
+
+    ok, err = check_dpop_key(conf.dpop)
+    if not ok then
+        return false, err
+    end
+
+    ok, err = check_client_jwt_assertion_alg(conf)
+    if not ok then
+        return false, err
+    end
+
+    ok, err = check_par_auth_method(conf)
+    if not ok then
+        return false, err
+    end
+
     return true
 end
 
@@ -713,6 +1119,7 @@ end
 
 function _M.rewrite(plugin_conf, ctx)
     local conf = core.table.clone(plugin_conf)
+    flatten_openidc_options(conf)
 
     -- Snapshot the client-supplied X-Access-Token (it doubles as a bearer
     -- input via get_bearer_access_token) and clear the five headers this
@@ -897,18 +1304,20 @@ function _M.rewrite(plugin_conf, ctx)
                 return 401
             end
 
-            -- Stale authorization callback: the state in the callback does not
-            -- match the one in the session, e.g. the same browser started
-            -- another login flow in a second tab and overwrote the state, or 
an
-            -- already completed callback was replayed. The client is a browser
+            -- Stale authorization callback: the session holds no authorization
+            -- state for the state in the callback, e.g. an already completed
+            -- callback was replayed, or the state was pruned after too many
+            -- concurrent flows. (Concurrent logins in several tabs are handled
+            -- by resty.openidc itself since 1.9.0, which keeps one
+            -- authorization state per in-flight flow.) The client is a browser
             -- mid-navigation, so instead of a dead-end 500, send it back to 
the
             -- original URL that resty.openidc returns alongside the error: a
             -- fresh flow starts from there and completes without any user
             -- interaction while the ID provider still holds an SSO session.
             if err == STATE_MISMATCH_ERR and target_url
                and ngx.req.get_method() == "GET" then
-                core.log.warn("OIDC state mismatch (concurrent login flows or 
",
-                              "replayed callback), restarting the 
authentication flow")
+                core.log.warn("OIDC state mismatch (replayed or pruned ",
+                              "callback), restarting the authentication flow")
                 core.response.set_header("Location", target_url)
                 return 302
             end
diff --git a/docs/en/latest/plugins/openid-connect.md 
b/docs/en/latest/plugins/openid-connect.md
index 99a89945fa..e474083c43 100644
--- a/docs/en/latest/plugins/openid-connect.md
+++ b/docs/en/latest/plugins/openid-connect.md
@@ -60,6 +60,15 @@ The `openid-connect` Plugin supports the integration with 
[OpenID Connect (OIDC)
 | public_key | string | False | | | Public key used to verify JWT signature if 
asymmetric algorithm is used. Providing this value to perform token 
verification will skip token introspection in client credentials flow. You can 
pass the public key in `-----BEGIN PUBLIC KEY-----\n……\n-----END PUBLIC 
KEY-----` format. |
 | use_jwks | boolean | False | false | | If true and if `public_key` is not 
set, use the JWKS to verify JWT signature and skip token introspection in 
client credentials flow. The JWKS endpoint is parsed from the discovery 
document. |
 | use_pkce | boolean | False | false | | If true, use the Proof Key for Code 
Exchange (PKCE) for Authorization Code Flow as defined in [RFC 
7636](https://datatracker.ietf.org/doc/html/rfc7636). |
+| par | object | False | | | Pushed Authorization Requests (PAR) 
configuration. |
+| par.enabled | boolean | False | false | | If true, use OAuth 2.0 Pushed 
Authorization Requests (PAR) as defined in [RFC 
9126](https://datatracker.ietf.org/doc/html/rfc9126). Authorization request 
parameters are sent to the PAR endpoint and the browser is redirected with the 
returned `request_uri`. |
+| par.endpoint | string | False | | | URL of the PAR endpoint. If unset, the 
endpoint from the well-known discovery document is used. |
+| par.endpoint_auth_method | string | False | | ["client_secret_basic", 
"client_secret_post", "private_key_jwt", "client_secret_jwt"] | Authentication 
method for the PAR endpoint. If unset, `token_endpoint_auth_method` is used. 
`private_key_jwt` requires `client_rsa_private_key` and `client_secret_jwt` 
requires `client_secret`; unlike the token endpoint, the PAR request fails 
outright when the method it is given cannot be used. |
+| dpop | object | False | | | Demonstrating Proof of Possession (DPoP) 
configuration. |
+| dpop.enabled | boolean | False | false | | If true, use OAuth 2.0 DPoP as 
defined in [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). The 
Plugin sends DPoP proof JWTs to the token endpoint and uses DPoP-bound access 
tokens for user info requests. |
+| dpop.signing_alg | string | False | ES256 | ["ES256", "RS256", "PS256"] | 
Signing algorithm for DPoP proof JWTs. |
+| dpop.private_key | string | False | | | PEM-encoded private key used to sign 
DPoP proof JWTs. Required when `dpop.enabled` is true. |
+| dpop.public_jwk | object | False | | | Public JWK that matches 
`dpop.private_key`. Required when `dpop.enabled` is true. The JWK must not 
contain private key material. |
 | token_signing_alg_values_expected | string | False | | | Algorithm used for 
signing JWT, such as `RS256`. |
 | set_access_token_header | boolean | False | true | | If true, set the access 
token in a request header. By default, the `X-Access-Token` header is used. |
 | access_token_in_authorization_header | boolean | False | false | | If true 
and if `set_access_token_header` is also true, set the access token in the 
`Authorization` header. |
@@ -107,9 +116,11 @@ The `openid-connect` Plugin supports the integration with 
[OpenID Connect (OIDC)
 | proxy_opts.https_proxy_authorization | string | False | | Basic [base64 
username:password] | Default `Proxy-Authorization` header value to be used with 
`https_proxy`. Cannot be overridden with custom `Proxy-Authorization` request 
header since with HTTPS, the authorization is completed when connecting. |
 | proxy_opts.no_proxy | string | False | | | Comma-separated list of hosts 
that should not be proxied. |
 | authorization_params | object | False | | | Additional parameters to send in 
the request to the authorization endpoint. |
-| client_rsa_private_key | string | False | | | Client RSA private key used to 
sign JWT for authentication to the OP. Required when 
`token_endpoint_auth_method` is `private_key_jwt`. |
-| client_rsa_private_key_id | string | False | | | Client RSA private key ID 
used to compute a signed JWT. Optional when `token_endpoint_auth_method` is 
`private_key_jwt`. |
-| client_jwt_assertion_expires_in | integer | False | 60 | | Life duration of 
the signed JWT for authentication to the OP, in seconds. Used when 
`token_endpoint_auth_method` is `private_key_jwt` or `client_secret_jwt`. |
+| client_rsa_private_key | string | False | | | Client private key used to 
sign the client assertion JWT for authentication to the OP. Required whenever 
`private_key_jwt` is selected, by `token_endpoint_auth_method`, 
`introspection_endpoint_auth_method` or `par.endpoint_auth_method`. The key 
type has to match `client_jwt_assertion_alg`: RSA for the `RS*` algorithms, and 
an EC key on the matching curve for the `ES*` ones. |
+| client_rsa_private_key_id | string | False | | | Client private key ID used 
to compute the signed client assertion JWT. Optional whenever `private_key_jwt` 
is selected. |
+| client_jwt_assertion_expires_in | integer | False | 60 | | Life duration of 
the signed JWT for authentication to the OP, in seconds. Used whenever 
`private_key_jwt` or `client_secret_jwt` is selected, for the token, 
introspection or PAR endpoint. |
+| client_jwt_assertion_alg | string | False | | ["HS256", "HS512", "RS256", 
"RS512", "ES256", "ES512"] | Signing algorithm for the client assertion JWT. 
Defaults to `RS256` for `private_key_jwt` and `HS256` for `client_secret_jwt`. 
Use a `HS*` algorithm with `client_secret_jwt` and an asymmetric one with 
`private_key_jwt`; an asymmetric algorithm also has to match 
`client_rsa_private_key`, which must be an EC key on P-256 for `ES256` and on 
P-521 for `ES512`. This is one algorithm for ev [...]
+| client_jwt_assertion_audience | string | False | | | Audience for the client 
assertion JWT. If unset, the endpoint URL being called is used. Configure this 
when APISIX reaches the token endpoint through an internal URL but the OP 
expects its external token endpoint URL as the audience. |
 | renew_access_token_on_expiry | boolean | False | true | | If true, attempt 
to silently renew the access token when it expires or if a refresh token is 
available. If the token fails to renew, redirect user for re-authentication. |
 | access_token_expires_in | integer | False | | | Lifetime of the access token 
in seconds if no `expires_in` attribute is present in the token endpoint 
response. |
 | refresh_session_interval | integer | False | | | Time interval in seconds to 
refresh user ID token without requiring re-authentication. When not set, it 
will not check the expiration time of the session issued to the client by the 
gateway. |
@@ -134,7 +145,11 @@ The `openid-connect` Plugin supports the integration with 
[OpenID Connect (OIDC)
 | claim_validator.audience.match_with_client_id | boolean | False | false | | 
If true, require the audience to match the client ID. If the audience is a 
string, it must exactly match the client ID. If the audience is an array of 
strings, at least one of the values must match the client ID. If no match is 
found, you will receive a `mismatched audience` error. This requirement is 
stated in the OpenID Connect specification to ensure that the token is intended 
for the specific client. |
 | claim_schema | object | False | | | JSON schema of OIDC response claim. 
Example: 
`{"type":"object","properties":{"access_token":{"type":"string"}},"required":["access_token"]}`
 - validates that the response contains a required string field `access_token`. 
|
 
-NOTE: `encrypt_fields = {"client_secret", "client_rsa_private_key"}` is also 
defined in the schema, which means that the fields will be stored encrypted in 
etcd. See [encrypted storage 
fields](../plugin-develop.md#encrypted-storage-fields).
+NOTE: The flat `lua-resty-openidc` option names that `par` and `dpop` own 
(`use_par`, `pushed_authorization_request_endpoint`, 
`pushed_authorization_request_endpoint_auth_method`, `use_dpop`, 
`dpop_signing_alg`, `dpop_private_key`, `dpop_public_jwk`) are rejected. 
Setting them directly would skip the validation and the `dpop.private_key` 
encryption the nested objects provide. Use the nested attributes instead.
+
+NOTE: Upgrading to this version changes how the client credentials are sent to 
the token introspection endpoint, even for Plugin configurations that are not 
modified. `lua-resty-openidc` 1.9.0 only places `client_id` and `client_secret` 
in the introspection request body when `introspection_endpoint_auth_method` is 
unset, and this Plugin defaults that attribute to `client_secret_basic`, so the 
credentials are now sent only in the `Authorization` header. If your OP 
authenticates the intros [...]
+
+NOTE: `encrypt_fields = {"client_secret", "client_rsa_private_key", 
"dpop.private_key"}` is also defined in the schema, which means that the fields 
will be stored encrypted in etcd. See [encrypted storage 
fields](../plugin-develop.md#encrypted-storage-fields).
 
 In addition, you can use Environment Variables or APISIX Secret to store and 
reference Plugin attributes. APISIX currently supports storing secrets in two 
ways — [Environment Variables and HashiCorp Vault](../terminology/secret.md).
 
@@ -339,6 +354,46 @@ spec:
 
 See [Implement Authorization Code 
Grant](../tutorials/keycloak-oidc.md#implement-authorization-code-grant) for a 
complete example to use the `openid-connect` Plugin to integrate with Keycloak 
using the authorization code flow.
 
+### Authorization Code Flow with PAR and DPoP
+
+To use Pushed Authorization Requests (PAR), set `par.enabled` to `true`. If 
`par.endpoint` is not configured, the Plugin uses the PAR endpoint from the 
well-known discovery document.
+
+To use DPoP-bound access tokens, set `dpop.enabled` to `true` and configure 
the DPoP signing key material. The `dpop.public_jwk` value should contain only 
the public JWK fields.
+
+The following example configures the authorization code flow with PAR, DPoP, 
PKCE, and `private_key_jwt` client authentication:
+
+```json
+{
+  "openid-connect": {
+    "client_id": "apisix",
+    "discovery": 
"https://idp.example.com/realms/master/.well-known/openid-configuration";,
+    "scope": "openid email profile",
+    "redirect_uri": "https://gateway.example.com/api/v1/redirect";,
+    "use_pkce": true,
+    "token_endpoint_auth_method": "private_key_jwt",
+    "client_rsa_private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END 
RSA PRIVATE KEY-----",
+    "client_jwt_assertion_alg": "RS512",
+    "par": {
+      "enabled": true,
+      "endpoint_auth_method": "private_key_jwt"
+    },
+    "dpop": {
+      "enabled": true,
+      "signing_alg": "PS256",
+      "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE 
KEY-----",
+      "public_jwk": {
+        "kty": "RSA",
+        "e": "AQAB",
+        "n": "..."
+      }
+    },
+    "session": {
+      "secret": "your-session-secret-min-16-chars"
+    }
+  }
+}
+```
+
 ### Proof Key for Code Exchange (PKCE)
 
 The Proof Key for Code Exchange (PKCE) is defined in [RFC 
7636](https://datatracker.ietf.org/doc/html/rfc7636). PKCE enhances the 
authorization code flow by adding a code challenge and verifier to prevent 
authorization code interception attacks.
diff --git a/docs/zh/latest/plugins/openid-connect.md 
b/docs/zh/latest/plugins/openid-connect.md
index 5671cfb8f1..672ec191d6 100644
--- a/docs/zh/latest/plugins/openid-connect.md
+++ b/docs/zh/latest/plugins/openid-connect.md
@@ -60,6 +60,15 @@ import TabItem from '@theme/TabItem';
 | public_key | string | 否 | | | 使用非对称算法时用于验证 JWT 
签名的公钥。提供此值进行令牌验证将跳过客户端凭证流中的令牌内省。可以以 `-----BEGIN PUBLIC KEY-----\n……\n-----END 
PUBLIC KEY-----` 格式传递公钥。 |
 | use_jwks | boolean | 否 | false | | 如果为 true 且未设置 `public_key`,则使用 JWKS 验证 
JWT 签名并跳过客户端凭证流中的令牌内省。JWKS 端点从发现文档中解析。 |
 | use_pkce | boolean | 否 | false | | 如果为 true,则按照 [RFC 
7636](https://datatracker.ietf.org/doc/html/rfc7636) 定义,在授权码流程中使用 PKCE(Proof 
Key for Code Exchange)。 |
+| par | object | 否 | | | Pushed Authorization Requests (PAR) 配置。 |
+| par.enabled | boolean | 否 | false | | 如果为 true,则按照 [RFC 
9126](https://datatracker.ietf.org/doc/html/rfc9126) 使用 OAuth 2.0 Pushed 
Authorization Requests (PAR)。授权请求参数会发送到 PAR 端点,浏览器将使用返回的 `request_uri` 重定向。 |
+| par.endpoint | string | 否 | | | PAR 端点 URL。未设置时使用发现文档中的端点。 |
+| par.endpoint_auth_method | string | 否 | | ["client_secret_basic", 
"client_secret_post", "private_key_jwt", "client_secret_jwt"] | PAR 
端点的认证方法。未设置时使用 `token_endpoint_auth_method`。`private_key_jwt` 需要 
`client_rsa_private_key`,`client_secret_jwt` 需要 `client_secret`;与令牌端点不同,PAR 
请求在认证方法不可用时会直接失败。 |
+| dpop | object | 否 | | | Demonstrating Proof of Possession (DPoP) 配置。 |
+| dpop.enabled | boolean | 否 | false | | 如果为 true,则按照 [RFC 
9449](https://datatracker.ietf.org/doc/html/rfc9449) 使用 OAuth 2.0 
DPoP。插件会向令牌端点发送 DPoP proof JWT,并在用户信息请求中使用 DPoP 绑定的访问令牌。 |
+| dpop.signing_alg | string | 否 | ES256 | ["ES256", "RS256", "PS256"] | DPoP 
proof JWT 的签名算法。 |
+| dpop.private_key | string | 否 | | | 用于签署 DPoP proof JWT 的 PEM 编码私钥。当 
`dpop.enabled` 为 true 时必填。 |
+| dpop.public_jwk | object | 否 | | | 与 `dpop.private_key` 匹配的公钥 JWK。当 
`dpop.enabled` 为 true 时必填。该 JWK 不得包含私钥材料。 |
 | token_signing_alg_values_expected | string | 否 | | | 用于签署 JWT 的算法,例如 
`RS256`。 |
 | set_access_token_header | boolean | 否 | true | | 如果为 
true,则在请求标头中设置访问令牌。默认情况下,使用 `X-Access-Token` 标头。|
 | access_token_in_authorization_header | boolean | 否 | false | | 如果为 true 并且 
`set_access_token_header` 也为 true,则在 `Authorization` 标头中设置访问令牌。 |
@@ -106,9 +115,11 @@ import TabItem from '@theme/TabItem';
 | proxy_opts.https_proxy_authorization | string | 否 | | Basic [base64 
username:password] | 与 `https_proxy` 一起使用的默认 `Proxy-Authorization` 头值。由于 HTTPS 
连接时已完成授权,不能用自定义 `Proxy-Authorization` 请求头覆盖。 |
 | proxy_opts.no_proxy | string | 否 | | | 不需要代理的主机列表,以逗号分隔。 |
 | authorization_params | object | 否 | | | 发送到授权端点请求中的额外参数。 |
-| client_rsa_private_key | string | 否 | | | 用于向 OP 签署 JWT 进行身份验证的客户端 RSA 私钥。当 
`token_endpoint_auth_method` 为 `private_key_jwt` 时必填。 |
-| client_rsa_private_key_id | string | 否 | | | 用于计算已签名 JWT 的客户端 RSA 私钥 ID。当 
`token_endpoint_auth_method` 为 `private_key_jwt` 时可选。 |
-| client_jwt_assertion_expires_in | integer | 否 | 60 | | 向 OP 进行身份验证的已签名 JWT 
的有效期,单位为秒。在 `token_endpoint_auth_method` 为 `private_key_jwt` 或 
`client_secret_jwt` 时使用。 |
+| client_rsa_private_key | string | 否 | | | 用于签署客户端断言 JWT 的客户端私钥。只要 
`token_endpoint_auth_method`、`introspection_endpoint_auth_method` 或 
`par.endpoint_auth_method` 中任一选择了 `private_key_jwt` 就必填。密钥类型必须与 
`client_jwt_assertion_alg` 匹配:`RS*` 算法用 RSA 密钥,`ES*` 算法用对应曲线的 EC 密钥。 |
+| client_rsa_private_key_id | string | 否 | | | 用于计算已签名客户端断言 JWT 的客户端私钥 
ID。任一端点选择 `private_key_jwt` 时可选。 |
+| client_jwt_assertion_expires_in | integer | 否 | 60 | | 向 OP 进行身份验证的已签名 JWT 
的有效期,单位为秒。令牌、内省或 PAR 端点中任一选择 `private_key_jwt` 或 `client_secret_jwt` 时使用。 |
+| client_jwt_assertion_alg | string | 否 | | ["HS256", "HS512", "RS256", 
"RS512", "ES256", "ES512"] | 客户端断言 JWT 的签名算法。`private_key_jwt` 默认使用 
`RS256`,`client_secret_jwt` 默认使用 `HS256`。`client_secret_jwt` 需使用 `HS*` 
算法,`private_key_jwt` 需使用非对称算法。当发现文档声明 
`token_endpoint_auth_signing_alg_values_supported` 时,配置值还必须受 OP 支持。 |
+| client_jwt_assertion_audience | string | 否 | | | 客户端断言 JWT 的 
audience。未设置时使用被调用的端点 URL。当 APISIX 通过内部 URL 访问令牌端点,但 OP 期望外部令牌端点 URL 作为 
audience 时,请配置此项。 |
 | renew_access_token_on_expiry | boolean | 否 | true | | 如果为 
true,则在访问令牌过期或刷新令牌可用时尝试静默续期。如果令牌续期失败,则重定向用户重新认证。 |
 | access_token_expires_in | integer | 否 | | | 当令牌端点响应中没有 `expires_in` 
属性时,访问令牌的有效期,单位为秒。 |
 | refresh_session_interval | integer | 否 | | | 无需重新认证即可刷新用户 ID 
令牌的时间间隔,单位为秒。未设置时不检查网关向客户端签发的会话的过期时间。 |
@@ -133,7 +144,11 @@ import TabItem from '@theme/TabItem';
 | claim_validator.audience.match_with_client_id | boolean | 否 | false | | 如果为 
true,则要求受众与客户端 ID 匹配。如果受众是字符串,则必须与客户端 ID 完全匹配。如果受众是字符串数组,则至少一个值必须与客户端 ID 
匹配。如果未找到匹配,将收到 `mismatched audience` 错误。OpenID Connect 
规范规定了此要求,以确保令牌是为特定客户端颁发的。 |
 | claim_schema | object | 否 | | | OIDC 响应 claim 的 JSON 
schema。示例:`{"type":"object","properties":{"access_token":{"type":"string"}},"required":["access_token"]}`
 - 验证响应包含必填的字符串字段 `access_token`。 |
 
-注意:schema 中还定义了 `encrypt_fields = {"client_secret", 
"client_rsa_private_key"}`,这意味着这些字段将在 etcd 
中加密存储。详见[加密存储字段](../plugin-develop.md#加密存储字段)。
+注意:`par` 和 `dpop` 所对应的 `lua-resty-openidc` 
扁平选项名(`use_par`、`pushed_authorization_request_endpoint`、`pushed_authorization_request_endpoint_auth_method`、`use_dpop`、`dpop_signing_alg`、`dpop_private_key`、`dpop_public_jwk`)会被拒绝。直接设置它们会绕过嵌套对象提供的校验以及
 `dpop.private_key` 的加密存储,请改用嵌套属性。
+
+注意:升级到此版本会改变向令牌内省端点发送客户端凭证的方式,即使插件配置没有变更。`lua-resty-openidc` 1.9.0 仅在 
`introspection_endpoint_auth_method` 未设置时才将 `client_id` 和 `client_secret` 
放入内省请求体,而本插件将该属性默认设置为 `client_secret_basic`,因此凭证现在只通过 `Authorization` 标头发送。如果你的 
OP 从请求体中验证内省调用,请将 `introspection_endpoint_auth_method` 设置为 `client_secret_post`。
+
+注意:schema 中还定义了 `encrypt_fields = {"client_secret", "client_rsa_private_key", 
"dpop.private_key"}`,这意味着这些字段将在 etcd 
中加密存储。详见[加密存储字段](../plugin-develop.md#加密存储字段)。
 
 此外,你可以使用环境变量或 APISIX Secret 来存储和引用插件属性。APISIX 目前支持两种存储密钥的方式——[环境变量和 HashiCorp 
Vault](../terminology/secret.md)。
 
@@ -338,6 +353,46 @@ spec:
 
 详见[实现授权码授权](../tutorials/keycloak-oidc.md#实现-authorization-code-grant),获取使用 
`openid-connect` 插件与 Keycloak 集成并使用授权码流程的完整示例。
 
+### 使用 PAR 和 DPoP 的授权码流程
+
+如需使用 Pushed Authorization Requests (PAR),请将 `par.enabled` 设置为 `true`。如果未配置 
`par.endpoint`,插件将使用 well-known 发现文档中的 PAR 端点。
+
+如需使用 DPoP 绑定的访问令牌,请将 `dpop.enabled` 设置为 `true`,并配置 DPoP 
签名密钥材料。`dpop.public_jwk` 应只包含公钥 JWK 字段。
+
+以下示例配置了使用 PAR、DPoP、PKCE 和 `private_key_jwt` 客户端认证的授权码流程:
+
+```json
+{
+  "openid-connect": {
+    "client_id": "apisix",
+    "discovery": 
"https://idp.example.com/realms/master/.well-known/openid-configuration";,
+    "scope": "openid email profile",
+    "redirect_uri": "https://gateway.example.com/api/v1/redirect";,
+    "use_pkce": true,
+    "token_endpoint_auth_method": "private_key_jwt",
+    "client_rsa_private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END 
RSA PRIVATE KEY-----",
+    "client_jwt_assertion_alg": "RS512",
+    "par": {
+      "enabled": true,
+      "endpoint_auth_method": "private_key_jwt"
+    },
+    "dpop": {
+      "enabled": true,
+      "signing_alg": "PS256",
+      "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE 
KEY-----",
+      "public_jwk": {
+        "kty": "RSA",
+        "e": "AQAB",
+        "n": "..."
+      }
+    },
+    "session": {
+      "secret": "your-session-secret-min-16-chars"
+    }
+  }
+}
+```
+
 ### PKCE (Proof Key for Code Exchange)
 
 PKCE 在 [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) 中定义。PKCE 
通过添加代码挑战和验证器来增强授权码流程,防止授权码截取攻击。
diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t
index 6241884a19..d68c6a63a6 100644
--- a/t/plugin/openid-connect.t
+++ b/t/plugin/openid-connect.t
@@ -126,6 +126,9 @@ done
                                 "timeout": 10,
                                 "scope": "apisix",
                                 "use_pkce": false,
+                                "dpop": {
+                                    "private_key": "dpop-private-key"
+                                },
                                 "session": {
                                     "secret": 
"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK"
                                 }
@@ -152,23 +155,24 @@ passed
 
 
 
-=== TEST 5: verify encrypted field
+=== TEST 5: verify encrypted fields
 --- config
     location /t {
         content_by_lua_block {
-            local json = require("toolkit.json")
-            local t = require("lib.test_admin").test
-
-
-            -- get plugin conf from etcd, client_rsa_private_key is encrypted
+            -- get plugin conf from etcd, private key fields are encrypted
             local etcd = require("apisix.core.etcd")
             local res = assert(etcd.get('/routes/1'))
-            
ngx.say(res.body.node.value.plugins["openid-connect"].client_rsa_private_key)
+            local conf = res.body.node.value.plugins["openid-connect"]
+            ngx.say(type(conf.client_rsa_private_key) == "string"
+                    and conf.client_rsa_private_key ~= 
"89ae4c8edadf1cd1c9f034335f136f87ad84b625c8f1")
+            ngx.say(type(conf.dpop.private_key) == "string"
+                    and conf.dpop.private_key ~= "dpop-private-key")
 
         }
     }
 --- response_body
-qO8TJbXcxCUnkkaTs3PxWDk5a54lv7FmngKQaxuXV4cL+7Kp1R4D8NS4w88so4e+
+true
+true
 
 
 
@@ -1845,7 +1849,302 @@ done
 
 
 
-=== TEST 51: Configure plugin with a custom session.cookie_name.
+=== TEST 51a: Accept PAR, DPoP, and client assertion algorithm options.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                discovery = 
"https://example.com/.well-known/openid-configuration";,
+                bearer_only = false,
+                use_pkce = true,
+                par = {
+                    enabled = true,
+                    endpoint = "https://example.com/par";,
+                    endpoint_auth_method = "private_key_jwt",
+                },
+                dpop = {
+                    enabled = true,
+                    signing_alg = "ES256",
+                    -- a real key pair: the private key has to load and match
+                    -- the algorithm, and the JWK is derived from it
+                    private_key = "-----BEGIN PRIVATE KEY-----\n"
+                        .. 
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgzVW+Se78iBpOnKwj\n"
+                        .. 
"D0Gqp/ZpmFSVJPRSTI7ZU50g3s2hRANCAARJ6hd/fMq/ZLvdEu1ZKHWFmiTjL1LD\n"
+                        .. 
"U4q5hU/UxozQRW7+Gr5bcSvgHJWK/PlNCN/NGISpRs3K3l3K0BUr7plo\n"
+                        .. "-----END PRIVATE KEY-----",
+                    public_jwk = {
+                        kty = "EC",
+                        crv = "P-256",
+                        x = "SeoXf3zKv2S73RLtWSh1hZok4y9Sw1OKuYVP1MaM0EU",
+                        y = "bv4avltxK-AclYr8-U0I380YhKlGzcreXcrQFSvumWg",
+                    },
+                },
+                token_endpoint_auth_method = "private_key_jwt",
+                -- ES256 signs with an EC key, so this reuses the pair above
+                client_rsa_private_key = "-----BEGIN PRIVATE KEY-----\n"
+                    .. 
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgzVW+Se78iBpOnKwj\n"
+                    .. 
"D0Gqp/ZpmFSVJPRSTI7ZU50g3s2hRANCAARJ6hd/fMq/ZLvdEu1ZKHWFmiTjL1LD\n"
+                    .. 
"U4q5hU/UxozQRW7+Gr5bcSvgHJWK/PlNCN/NGISpRs3K3l3K0BUr7plo\n"
+                    .. "-----END PRIVATE KEY-----",
+                client_jwt_assertion_alg = "ES256",
+                client_jwt_assertion_audience = 
"https://issuer.example.com/token";,
+                session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+            })
+            if not ok then
+                ngx.say(err)
+            end
+            ngx.say("done")
+        }
+    }
+--- response_body
+done
+
+
+
+=== TEST 52b: Reject unsupported DPoP signing algorithm in schema.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                client_secret = "b",
+                discovery = 
"https://example.com/.well-known/openid-configuration";,
+                dpop = {
+                    signing_alg = "HS256",
+                },
+                session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+            })
+            if not ok then
+                ngx.say(err)
+            end
+            ngx.say("done")
+        }
+    }
+--- response_body
+property "dpop" validation failed: property "signing_alg" validation failed: 
matches none of the enum values
+done
+
+
+
+=== TEST 53c: Accept PAR enabled without endpoint in schema.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                client_secret = "b",
+                discovery = 
"https://example.com/.well-known/openid-configuration";,
+                par = {
+                    enabled = true,
+                },
+                session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+            })
+            if not ok then
+                ngx.say(err)
+            end
+            ngx.say("done")
+        }
+    }
+--- response_body
+done
+
+
+
+=== TEST 54d: Reject DPoP enabled without key material in schema.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                client_secret = "b",
+                discovery = 
"https://example.com/.well-known/openid-configuration";,
+                dpop = {
+                    enabled = true,
+                },
+                session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+            })
+            if not ok then
+                ngx.say(err)
+            end
+            ngx.say("done")
+        }
+    }
+--- response_body
+property "dpop" validation failed: then clause did not match
+done
+
+
+
+=== TEST 55e: Reject private key material in DPoP public JWK.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                client_secret = "b",
+                discovery = 
"https://example.com/.well-known/openid-configuration";,
+                dpop = {
+                    enabled = true,
+                    private_key = "-----BEGIN PRIVATE 
KEY-----\nMIIEowIBAAK\n-----END PRIVATE KEY-----",
+                    public_jwk = {
+                        kty = "RSA",
+                        e = "AQAB",
+                        n = "abc",
+                        d = "private-exponent",
+                    },
+                },
+                session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+            })
+            if not ok then
+                ngx.say(err)
+            end
+            ngx.say("done")
+        }
+    }
+--- response_body_like
+property "dpop" validation failed: property "public_jwk" validation failed:.*
+done
+
+
+
+=== TEST 56: PAR runtime mapping sends authorization parameters through PAR.
+--- http_config
+    server {
+        listen 16969;
+        server_name localhost;
+
+        location /.well-known/openid-configuration {
+            content_by_lua_block {
+                ngx.header.content_type = "application/json"
+                ngx.say([[{
+                    "issuer": "http://127.0.0.1:16969";,
+                    "authorization_endpoint": 
"http://127.0.0.1:16969/authorize";,
+                    "token_endpoint": "http://127.0.0.1:16969/token";,
+                    "userinfo_endpoint": "http://127.0.0.1:16969/userinfo";,
+                    "jwks_uri": "http://127.0.0.1:16969/jwks";
+                }]])
+            }
+        }
+
+        location /par {
+            content_by_lua_block {
+                ngx.req.read_body()
+                local args = ngx.req.get_post_args()
+                if args.scope ~= "openid email" or not args.state then
+                    ngx.status = 400
+                    ngx.say([[{"error":"invalid_request"}]])
+                    return
+                end
+
+                -- only client_secret_post sends the credentials in the body;
+                -- without this the par.endpoint_auth_method mapping is not
+                -- really tested, because dropping it falls back to
+                -- token_endpoint_auth_method, which defaults to
+                -- client_secret_basic and would be accepted here too
+                if args.client_id ~= "test_client"
+                   or args.client_secret ~= "test_secret" then
+                    ngx.status = 400
+                    ngx.say([[{"error":"invalid_client"}]])
+                    return
+                end
+
+                ngx.header.content_type = "application/json"
+                ngx.say([[{
+                    "request_uri": 
"urn:ietf:params:oauth:request_uri:par-runtime",
+                    "expires_in": 60
+                }]])
+            }
+        }
+    }
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local http = require("resty.http")
+
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [=[{
+                    "plugins": {
+                        "openid-connect": {
+                            "client_id": "test_client",
+                            "client_secret": "test_secret",
+                            "discovery": 
"http://127.0.0.1:16969/.well-known/openid-configuration";,
+                            "redirect_uri": "http://127.0.0.1:]=] .. 
ngx.var.server_port .. [=[/callback",
+                            "ssl_verify": false,
+                            "timeout": 10,
+                            "scope": "openid email",
+                            "par": {
+                                "enabled": true,
+                                "endpoint": "http://127.0.0.1:16969/par";,
+                                "endpoint_auth_method": "client_secret_post"
+                            },
+                            "session": {
+                                "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK"
+                            }
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/par-runtime"
+                }]=])
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            local httpc = http.new()
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. 
"/par-runtime"
+            local res, err = httpc:request_uri(uri, {method = "GET"})
+            if not res then
+                ngx.status = 500
+                ngx.say(err)
+                return
+            end
+
+            ngx.status = res.status
+            local location = res.headers["Location"] or ""
+            local query = string.match(location, 
"^http://127%.0%.0%.1:16969/authorize%?(.*)$")
+            local args = query and ngx.decode_args(query) or {}
+            local core = require("apisix.core")
+
+            ngx.say(query ~= nil)
+            ngx.say(core.table.nkeys(args) == 2)
+            ngx.say(args.client_id == "test_client")
+            ngx.say(args.request_uri == 
"urn:ietf:params:oauth:request_uri:par-runtime")
+            ngx.say(args.scope == nil)
+            ngx.say(args.state == nil)
+            ngx.say(args.response_type == nil)
+            ngx.say(args.redirect_uri == nil)
+        }
+    }
+--- timeout: 10s
+--- response_body
+true
+true
+true
+true
+true
+true
+true
+true
+--- error_code: 302
+
+
+
+=== TEST 57: Configure plugin with a custom session.cookie_name.
 --- config
     location /t {
         content_by_lua_block {
@@ -1889,7 +2188,7 @@ passed
 
 
 
-=== TEST 52: Full OIDC login issues the session cookie under the configured 
cookie_name.
+=== TEST 58: Full OIDC login issues the session cookie under the configured 
cookie_name.
 --- config
     location /t {
         content_by_lua_block {
@@ -1941,7 +2240,7 @@ passed
 
 
 
-=== TEST 53: Configure plugin with a short session.absolute_timeout.
+=== TEST 59: Configure plugin with a short session.absolute_timeout.
 --- config
     location /t {
         content_by_lua_block {
@@ -1985,7 +2284,7 @@ passed
 
 
 
-=== TEST 54: Session is rejected once absolute_timeout elapses, re-initiating 
authentication.
+=== TEST 60: Session is rejected once absolute_timeout elapses, re-initiating 
authentication.
 --- config
     location /t {
         content_by_lua_block {
@@ -2052,7 +2351,7 @@ passed
 
 
 
-=== TEST 55: Configure plugin with set_raw_id_token_header enabled.
+=== TEST 61: Configure plugin with set_raw_id_token_header enabled.
 --- config
     location /t {
         content_by_lua_block {
@@ -2099,7 +2398,7 @@ passed
 
 
 
-=== TEST 56: Full OIDC login sets X-Raw-ID-Token with the raw signed JWT; 
other auth headers are absent.
+=== TEST 62: Full OIDC login sets X-Raw-ID-Token with the raw signed JWT; 
other auth headers are absent.
 --- config
     location /t {
         content_by_lua_block {
@@ -2157,3 +2456,842 @@ passed
     }
 --- response_body
 passed
+
+
+
+=== TEST 63: Reject a client assertion algorithm resty.jwt cannot sign.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                discovery = 
"https://example.com/.well-known/openid-configuration";,
+                token_endpoint_auth_method = "private_key_jwt",
+                client_rsa_private_key = "-----BEGIN RSA PRIVATE 
KEY-----\nMIIEowIBAAK\n-----END RSA PRIVATE KEY-----",
+                client_jwt_assertion_alg = "PS256",
+                session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+            })
+            if not ok then
+                ngx.say(err)
+            end
+            ngx.say("done")
+        }
+    }
+--- response_body
+property "client_jwt_assertion_alg" validation failed: matches none of the 
enum values
+done
+
+
+
+=== TEST 64: Every nested option is flattened to the name lua-resty-openidc 
reads.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local jwk = {kty = "RSA", e = "AQAB", n = "abc"}
+            local conf = {
+                par = {
+                    enabled = true,
+                    endpoint = "https://example.com/par";,
+                    endpoint_auth_method = "private_key_jwt",
+                },
+                dpop = {
+                    enabled = true,
+                    signing_alg = "PS256",
+                    private_key = "dpop-private-key",
+                    public_jwk = jwk,
+                },
+            }
+            plugin._flatten_openidc_options(conf)
+
+            ngx.say(conf.use_par == true)
+            ngx.say(conf.pushed_authorization_request_endpoint
+                    == "https://example.com/par";)
+            ngx.say(conf.pushed_authorization_request_endpoint_auth_method
+                    == "private_key_jwt")
+            ngx.say(conf.use_dpop == true)
+            ngx.say(conf.dpop_signing_alg == "PS256")
+            ngx.say(conf.dpop_private_key == "dpop-private-key")
+            ngx.say(conf.dpop_public_jwk == jwk)
+            -- the nested tables must not survive into the opts handed to
+            -- lua-resty-openidc
+            ngx.say(conf.par == nil)
+            ngx.say(conf.dpop == nil)
+        }
+    }
+--- response_body
+true
+true
+true
+true
+true
+true
+true
+true
+true
+
+
+
+=== TEST 65: A conf without par or dpop is left alone by the flattening.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local conf = {client_id = "a"}
+            plugin._flatten_openidc_options(conf)
+
+            ngx.say(conf.use_par == nil)
+            ngx.say(conf.use_dpop == nil)
+            ngx.say(conf.dpop_public_jwk == nil)
+            ngx.say(conf.client_id == "a")
+        }
+    }
+--- response_body
+true
+true
+true
+true
+
+
+
+=== TEST 66: Introspection sends the client credentials per 
introspection_endpoint_auth_method.
+--- http_config
+    server {
+        listen 16969;
+        server_name localhost;
+
+        location /.well-known/openid-configuration {
+            content_by_lua_block {
+                ngx.header.content_type = "application/json"
+                ngx.say([[{
+                    "issuer": "http://127.0.0.1:16969";,
+                    "authorization_endpoint": 
"http://127.0.0.1:16969/authorize";,
+                    "token_endpoint": "http://127.0.0.1:16969/token";,
+                    "userinfo_endpoint": "http://127.0.0.1:16969/userinfo";,
+                    "jwks_uri": "http://127.0.0.1:16969/jwks";
+                }]])
+            }
+        }
+
+        # lua-resty-openidc 1.9.0 only puts the credentials in the POST body
+        # when introspection_endpoint_auth_method is nil, and this Plugin
+        # defaults it to client_secret_basic. Report where they actually
+        # arrived so both halves of that behavior are pinned.
+        location /introspect {
+            content_by_lua_block {
+                ngx.req.read_body()
+                local args = ngx.req.get_post_args()
+                local in_body = args.client_id == "test_client"
+                                and args.client_secret == "test_secret"
+                local expected = "Basic " .. ngx.encode_base64(
+                    ngx.escape_uri("test_client") .. ":"
+                    .. ngx.escape_uri("test_secret"))
+                local in_header = ngx.var.http_authorization == expected
+
+                ngx.header.content_type = "application/json"
+                ngx.say([[{"active":true,"body":]] .. tostring(in_body)
+                        .. [[,"header":]] .. tostring(in_header) .. [[}]])
+            }
+        }
+    }
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local http = require("resty.http")
+
+            local function probe(auth_method, token)
+                local conf = [=[{
+                    "plugins": {
+                        "openid-connect": {
+                            "client_id": "test_client",
+                            "client_secret": "test_secret",
+                            "discovery": 
"http://127.0.0.1:16969/.well-known/openid-configuration";,
+                            "introspection_endpoint": 
"http://127.0.0.1:16969/introspect";,
+                            "bearer_only": true,
+                            "ssl_verify": false,
+                            "timeout": 10,
+                            "set_userinfo_header": true]=]
+                if auth_method then
+                    conf = conf .. [=[,
+                            "introspection_endpoint_auth_method": "]=]
+                           .. auth_method .. [=["]=]
+                end
+                conf = conf .. [=[
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1980": 1},
+                        "type": "roundrobin"
+                    },
+                    "uri": "/uri"
+                }]=]
+
+                local code = t('/apisix/admin/routes/1', ngx.HTTP_PUT, conf)
+                if code >= 300 then
+                    return "route failed: " .. code
+                end
+
+                local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. 
"/uri"
+                local res, err = http.new():request_uri(uri, {
+                    method = "GET",
+                    headers = {["Authorization"] = "Bearer " .. token},
+                })
+                if not res then
+                    return "request failed: " .. err
+                end
+                -- the introspection result reaches the upstream base64-encoded
+                -- in X-Userinfo; /uri echoes the request headers back
+                local encoded = res.body:match("x%-userinfo: ([%w+/=]+)")
+                if not encoded then
+                    return "no x-userinfo, status " .. res.status
+                end
+                local core = require("apisix.core")
+                local seen = core.json.decode(ngx.decode_base64(encoded))
+                -- assert on the decoded fields, not on the key order the
+                -- library happens to re-encode them in
+                return "body=" .. tostring(seen.body)
+                       .. " header=" .. tostring(seen.header)
+            end
+
+            -- the default is client_secret_basic, so the body carries nothing
+            ngx.say(probe(nil, "tok-default"))
+            ngx.say(probe("client_secret_basic", "tok-basic"))
+            ngx.say(probe("client_secret_post", "tok-post"))
+        }
+    }
+--- timeout: 10s
+--- response_body
+body=false header=true
+body=false header=true
+body=true header=false
+
+
+
+=== TEST 67: Reject the flat lua-resty-openidc option names the par and dpop 
objects own.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local flat = {
+                {use_par = true},
+                {pushed_authorization_request_endpoint = 
"https://example.com/par"},
+                {pushed_authorization_request_endpoint_auth_method = 
"private_key_jwt"},
+                {use_dpop = true},
+                {dpop_signing_alg = "RS256"},
+                {dpop_private_key = "plaintext-key"},
+                {dpop_public_jwk = {kty = "RSA", e = "AQAB", n = "abc"}},
+            }
+            for _, extra in ipairs(flat) do
+                local conf = {
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                }
+                for k, v in pairs(extra) do conf[k] = v end
+                local ok, err = plugin.check_schema(conf)
+                ngx.say(ok and "ACCEPTED" or err)
+            end
+        }
+    }
+--- response_body
+property "use_par" is not allowed, use "par.enabled" instead
+property "pushed_authorization_request_endpoint" is not allowed, use 
"par.endpoint" instead
+property "pushed_authorization_request_endpoint_auth_method" is not allowed, 
use "par.endpoint_auth_method" instead
+property "use_dpop" is not allowed, use "dpop.enabled" instead
+property "dpop_signing_alg" is not allowed, use "dpop.signing_alg" instead
+property "dpop_private_key" is not allowed, use "dpop.private_key" instead
+property "dpop_public_jwk" is not allowed, use "dpop.public_jwk" instead
+
+
+
+=== TEST 68: The Admin API rejects a flat DPoP private key instead of storing 
it in plaintext.
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "openid-connect": {
+                            "client_id": "a",
+                            "client_secret": "b",
+                            "discovery": 
"https://example.com/.well-known/openid-configuration";,
+                            "use_dpop": true,
+                            "dpop_private_key": "plaintext-key",
+                            "dpop_public_jwk": {"kty": "RSA", "e": "AQAB", 
"n": "abc"},
+                            "session": {"secret": 
"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK"}
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1980": 1},
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]])
+
+            ngx.status = code
+            ngx.say(body)
+        }
+    }
+--- error_code: 400
+--- response_body eval
+qr/property \\"use_dpop\\" is not allowed, use \\"dpop.enabled\\" instead/
+
+
+
+=== TEST 69: Reject a DPoP public JWK that lua-resty-openidc cannot build a 
thumbprint from.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local jwks = {
+                {kty = "RSA"},
+                {kty = "EC", crv = "P-256"},
+                {kty = "OKP", x = "abc"},
+            }
+            for _, jwk in ipairs(jwks) do
+                local ok, err = plugin.check_schema({
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    dpop = {
+                        enabled = true,
+                        signing_alg = jwk.kty == "RSA" and "RS256" or "ES256",
+                        private_key = "-----BEGIN PRIVATE 
KEY-----\nMIIEowIBAAK\n-----END PRIVATE KEY-----",
+                        public_jwk = jwk,
+                    },
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                })
+                ngx.say(ok and "ACCEPTED" or err)
+            end
+        }
+    }
+--- response_body
+property "dpop.public_jwk" validation failed: kty "RSA" requires e, n
+property "dpop.public_jwk" validation failed: kty "EC" requires crv, x, y
+property "dpop.public_jwk" validation failed: kty "OKP" is not supported
+
+
+
+=== TEST 70: Reject a DPoP signing algorithm that does not match the public 
JWK key type.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local cases = {
+                {alg = "ES256", jwk = {kty = "RSA", e = "AQAB", n = "abc"}},
+                {alg = "RS256", jwk = {kty = "EC", crv = "P-256", x = "a", y = 
"b"}},
+                {alg = "PS256", jwk = {kty = "EC", crv = "P-256", x = "a", y = 
"b"}},
+            }
+            for _, case in ipairs(cases) do
+                local ok, err = plugin.check_schema({
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    dpop = {
+                        enabled = true,
+                        signing_alg = case.alg,
+                        private_key = "-----BEGIN PRIVATE 
KEY-----\nMIIEowIBAAK\n-----END PRIVATE KEY-----",
+                        public_jwk = case.jwk,
+                    },
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                })
+                ngx.say(ok and "ACCEPTED" or err)
+            end
+        }
+    }
+--- response_body
+property "dpop.signing_alg" "ES256" requires an EC "dpop.public_jwk"
+property "dpop.signing_alg" "RS256" requires an RSA "dpop.public_jwk"
+property "dpop.signing_alg" "PS256" requires an RSA "dpop.public_jwk"
+
+
+
+=== TEST 71: Reject a client assertion algorithm whose family the endpoint 
auth method rejects.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local cases = {
+                {token_endpoint_auth_method = "private_key_jwt",
+                 client_rsa_private_key = "k", client_jwt_assertion_alg = 
"HS256"},
+                {introspection_endpoint_auth_method = "private_key_jwt",
+                 bearer_only = true,
+                 introspection_endpoint = "https://example.com/introspect";,
+                 client_rsa_private_key = "k", client_jwt_assertion_alg = 
"HS512"},
+                {token_endpoint_auth_method = "client_secret_jwt",
+                 client_jwt_assertion_alg = "RS256"},
+                {par = {enabled = true, endpoint_auth_method = 
"client_secret_jwt"},
+                 client_jwt_assertion_alg = "ES256"},
+                {token_endpoint_auth_method = "private_key_jwt",
+                 client_rsa_private_key = "k",
+                 introspection_endpoint = "https://example.com/introspect";,
+                 introspection_endpoint_auth_method = "client_secret_jwt",
+                 client_jwt_assertion_alg = "RS256"},
+            }
+            for _, extra in ipairs(cases) do
+                local conf = {
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                }
+                for k, v in pairs(extra) do conf[k] = v end
+                local ok, err = plugin.check_schema(conf)
+                ngx.say(ok and "ACCEPTED" or err)
+            end
+        }
+    }
+--- response_body
+property "client_jwt_assertion_alg" "HS256" is symmetric and cannot be used 
with the private_key_jwt selected by "token_endpoint_auth_method"
+property "client_jwt_assertion_alg" "HS512" is symmetric and cannot be used 
with the private_key_jwt selected by "introspection_endpoint_auth_method"
+property "client_jwt_assertion_alg" "RS256" is asymmetric and cannot be used 
with the client_secret_jwt selected by "token_endpoint_auth_method"
+property "client_jwt_assertion_alg" "ES256" is asymmetric and cannot be used 
with the client_secret_jwt selected by "par.endpoint_auth_method"
+property "client_jwt_assertion_alg" is a single algorithm, but 
"token_endpoint_auth_method" selects private_key_jwt and 
"introspection_endpoint_auth_method" selects client_secret_jwt
+
+
+
+=== TEST 72: Accept both JWT auth families when no client assertion algorithm 
is configured.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            -- lua-resty-openidc then picks RS256 or HS256 per auth method,
+            -- so the families cannot conflict
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                client_secret = "b",
+                discovery = 
"https://example.com/.well-known/openid-configuration";,
+                token_endpoint_auth_method = "private_key_jwt",
+                client_rsa_private_key = "k",
+                introspection_endpoint_auth_method = "client_secret_jwt",
+                session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+            })
+            if not ok then
+                ngx.say(err)
+            end
+            ngx.say("done")
+        }
+    }
+--- response_body
+done
+
+
+
+=== TEST 73: Reject a DPoP public JWK whose members are not usable in a 
thumbprint.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local cases = {
+                -- ES256 is P-256 only per RFC 7518; another curve produces a
+                -- proof the OP cannot verify
+                {alg = "ES256", jwk = {kty = "EC", crv = "P-384", x = "x", y = 
"y"}},
+                {alg = "ES256", jwk = {kty = "EC", crv = "P-256", x = 1, y = 
"y"}},
+                {alg = "ES256", jwk = {kty = "EC", crv = "P-256", x = "x", y = 
""}},
+                {alg = "RS256", jwk = {kty = "RSA", e = "AQAB", n = ""}},
+                {alg = "RS256", jwk = {kty = "RSA", e = true, n = "abc"}},
+            }
+            for _, case in ipairs(cases) do
+                local ok, err = plugin.check_schema({
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    dpop = {
+                        enabled = true,
+                        signing_alg = case.alg,
+                        private_key = "-----BEGIN PRIVATE 
KEY-----\nMIIEowIBAAK\n-----END PRIVATE KEY-----",
+                        public_jwk = case.jwk,
+                    },
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                })
+                ngx.say(ok and "ACCEPTED" or err)
+            end
+        }
+    }
+--- response_body
+property "dpop.signing_alg" "ES256" requires "dpop.public_jwk" crv "P-256", 
got "P-384"
+property "dpop.public_jwk" validation failed: "x" must be a non-empty string
+property "dpop.public_jwk" validation failed: "y" must be a non-empty string
+property "dpop.public_jwk" validation failed: "n" must be a non-empty string
+property "dpop.public_jwk" validation failed: "e" must be a non-empty string
+
+
+
+=== TEST 74: Reject a PAR authentication method lua-resty-openidc cannot use.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local base = {
+                client_id = "a",
+                discovery = 
"https://example.com/.well-known/openid-configuration";,
+                use_pkce = true,
+                session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+            }
+            local function check(extra)
+                local conf = {}
+                for k, v in pairs(base) do conf[k] = v end
+                for k, v in pairs(extra) do conf[k] = v end
+                local ok, err = plugin.check_schema(conf)
+                ngx.say(ok and "ACCEPTED" or err)
+            end
+
+            -- unknown method
+            check({par = {enabled = true, endpoint_auth_method = "bogus"}})
+            -- supported methods missing the credential they need
+            check({par = {enabled = true, endpoint_auth_method = 
"private_key_jwt"}})
+            check({par = {enabled = true, endpoint_auth_method = 
"client_secret_jwt"}})
+        }
+    }
+--- response_body
+property "par" validation failed: property "endpoint_auth_method" validation 
failed: matches none of the enum values
+property "par.endpoint_auth_method" "private_key_jwt" requires 
"client_rsa_private_key" when "par.enabled" is true
+property "par.endpoint_auth_method" "client_secret_jwt" requires 
"client_secret" when "par.enabled" is true
+
+
+
+=== TEST 75: Accept PAR authentication methods that have their credential.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local cases = {
+                -- the schema default for token_endpoint_auth_method is
+                -- client_secret_basic, which needs no extra credential
+                {client_secret = "s", par = {enabled = true}},
+                {client_secret = "s",
+                 par = {enabled = true, endpoint_auth_method = 
"client_secret_post"}},
+                {client_secret = "s",
+                 par = {enabled = true, endpoint_auth_method = 
"client_secret_jwt"}},
+                {client_secret = "s", client_rsa_private_key = "k",
+                 par = {enabled = true, endpoint_auth_method = 
"private_key_jwt"}},
+                -- an unusable method only breaks PAR, so it stays valid while
+                -- PAR is off: the token endpoint falls back on its own
+                {client_secret = "s", token_endpoint_auth_method = 
"private_key_jwt",
+                 par = {enabled = false}},
+                -- and with PAR on but no method of its own, PAR uses whatever
+                -- ensure_config resolved, which is usable by construction
+                {client_secret = "s", token_endpoint_auth_method = 
"private_key_jwt",
+                 par = {enabled = true}},
+                -- bearer_only never runs the authorization code flow, so PAR
+                -- is unreachable however it is configured
+                {client_secret = "s", bearer_only = true, public_key = "k",
+                 par = {enabled = true, endpoint_auth_method = 
"private_key_jwt"}},
+            }
+            for _, extra in ipairs(cases) do
+                local conf = {
+                    client_id = "a",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    use_pkce = true,
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                }
+                for k, v in pairs(extra) do conf[k] = v end
+                local ok, err = plugin.check_schema(conf)
+                ngx.say(ok and "accepted" or err)
+            end
+        }
+    }
+--- response_body
+accepted
+accepted
+accepted
+accepted
+accepted
+accepted
+accepted
+
+
+
+=== TEST 76: Reject a DPoP private key that cannot be loaded or does not match 
the algorithm.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local ec_key = "-----BEGIN PRIVATE KEY-----\n"
+                .. 
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgzVW+Se78iBpOnKwj\n"
+                .. 
"D0Gqp/ZpmFSVJPRSTI7ZU50g3s2hRANCAARJ6hd/fMq/ZLvdEu1ZKHWFmiTjL1LD\n"
+                .. "U4q5hU/UxozQRW7+Gr5bcSvgHJWK/PlNCN/NGISpRs3K3l3K0BUr7plo\n"
+                .. "-----END PRIVATE KEY-----"
+            local ec_jwk = {kty = "EC", crv = "P-256",
+                            x = "SeoXf3zKv2S73RLtWSh1hZok4y9Sw1OKuYVP1MaM0EU",
+                            y = "bv4avltxK-AclYr8-U0I380YhKlGzcreXcrQFSvumWg"}
+            local cases = {
+                -- not a key at all
+                {alg = "ES256", key = "-----BEGIN PRIVATE 
KEY-----\nnope\n-----END PRIVATE KEY-----",
+                 jwk = ec_jwk},
+                -- a real key, but RS256 signs with an RSA one
+                {alg = "RS256", key = ec_key, jwk = {kty = "RSA", e = "AQAB", 
n = "abc"}},
+            }
+            for _, case in ipairs(cases) do
+                local ok, err = plugin.check_schema({
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    dpop = {enabled = true, signing_alg = case.alg,
+                            private_key = case.key, public_jwk = case.jwk},
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                })
+                if ok then
+                    ngx.say("ACCEPTED")
+                else
+                    -- the openssl error text varies by version
+                    ngx.say((err:gsub("key: .*", "key")))
+                end
+            end
+        }
+    }
+--- response_body
+property "dpop.private_key" is not a valid key
+property "dpop.public_jwk" is not the public key of "dpop.private_key": kty is 
"EC"
+
+
+
+=== TEST 77: Key material staged before DPoP is enabled stays valid.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            -- lua-resty-openidc reads none of it while use_dpop is false, so
+            -- none of the DPoP checks may fire yet
+            local cases = {
+                {public_jwk = {kty = "RSA", e = "AQAB", n = "abc"}},
+                {enabled = false, signing_alg = "ES256",
+                 public_jwk = {kty = "RSA", e = "AQAB", n = "abc"}},
+                {enabled = false, private_key = "not-a-key",
+                 public_jwk = {kty = "EC", crv = "P-384", x = "x", y = "y"}},
+            }
+            for _, dpop in ipairs(cases) do
+                local ok, err = plugin.check_schema({
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    dpop = dpop,
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                })
+                ngx.say(ok and "accepted" or err)
+            end
+        }
+    }
+--- response_body
+accepted
+accepted
+accepted
+
+
+
+=== TEST 78: bearer_only only ever calls the introspection endpoint.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            -- the token and PAR endpoints belong to the authorization code
+            -- flow, which bearer_only never runs, so their auth method cannot
+            -- conflict with the introspection one
+            local ok, err = plugin.check_schema({
+                client_id = "a",
+                bearer_only = true,
+                public_key = "k",
+                discovery = 
"https://example.com/.well-known/openid-configuration";,
+                introspection_endpoint_auth_method = "private_key_jwt",
+                client_rsa_private_key = "k",
+                token_endpoint_auth_method = "client_secret_jwt",
+                client_jwt_assertion_alg = "RS256",
+                session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+            })
+            if not ok then
+                ngx.say(err)
+            end
+            ngx.say("done")
+        }
+    }
+--- response_body
+done
+
+
+
+=== TEST 79: Reject a DPoP key pair whose halves do not match.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local jwk = {kty = "EC", crv = "P-256",
+                         x = "SeoXf3zKv2S73RLtWSh1hZok4y9Sw1OKuYVP1MaM0EU",
+                         y = "bv4avltxK-AclYr8-U0I380YhKlGzcreXcrQFSvumWg"}
+            -- carries no private half, so it cannot sign a proof
+            local public_only = "-----BEGIN PUBLIC KEY-----\n"
+                .. 
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESeoXf3zKv2S73RLtWSh1hZok4y9S\n"
+                .. 
"w1OKuYVP1MaM0EVu/hq+W3Er4ByVivz5TQjfzRiEqUbNyt5dytAVK+6ZaA==\n"
+                .. "-----END PUBLIC KEY-----"
+            -- a different P-256 key: the proof would advertise a JWK the
+            -- signature does not belong to
+            local other_key = "-----BEGIN PRIVATE KEY-----\n"
+                .. 
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQge5Rl9pweBnZ1WxHc\n"
+                .. 
"r4U8KwHh4m1qvOTPxWcPJ1zlpKuhRANCAATD9wxHo/JutVZx87WBAG7MEJ9KuNwd\n"
+                .. "rYw4LcUN2D/1tfqaTg94vWmN3o+4Z5gTW5PHNA+I760YsJnVHmNg+l4v\n"
+                .. "-----END PRIVATE KEY-----"
+            -- right kind of key, wrong curve
+            local p384_key = "-----BEGIN PRIVATE KEY-----\n"
+                .. 
"MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBkw8FnyuXW3AXb4ucM\n"
+                .. 
"0z1m2SdxLpunow06uZQHMcGA/Udu+xbAEAevgha+JVlGsJWhZANiAAQAmA7SYPIo\n"
+                .. 
"gRAJdCBDS3rYJOZgPDVezGgsVFneBI47JeuLF2jAeGN6PNO/zP65kC3ywWuJBFck\n"
+                .. "qpp43wav41a8jlmo8f5/3jWnv+5WV01p6TsF8xSDOfv2X/L3Y7p2Bkc=\n"
+                .. "-----END PRIVATE KEY-----"
+            for _, key in ipairs({public_only, other_key, p384_key}) do
+                local ok, err = plugin.check_schema({
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    dpop = {enabled = true, private_key = key, public_jwk = 
jwk},
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                })
+                ngx.say(ok and "ACCEPTED" or (err:gsub(" is \"[^\"]*\"$", "")))
+            end
+        }
+    }
+--- response_body
+property "dpop.private_key" has no private key in it
+property "dpop.public_jwk" is not the public key of "dpop.private_key": x
+property "dpop.public_jwk" is not the public key of "dpop.private_key": crv
+
+
+
+=== TEST 80: Reject a client assertion algorithm the configured key cannot 
sign.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            -- resty.jwt picks the signer from the algorithm, not the key: an
+            -- EC algorithm handed an RSA key takes the worker down with a
+            -- SIGSEGV, and an EC key on the wrong curve emits a signature of
+            -- the wrong size
+            local rsa_key = "-----BEGIN PRIVATE KEY-----\n"
+                .. 
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCR+XDXtbspeYTI\n"
+                .. 
"NSgogElb7gAeDEZOgF/t7RO3azpyPSqj+/5SguFaLQ2oANgY6FIhcQ0+wsVpNTE7\n"
+                .. 
"EkX5Z8pNGC+VoMWVrtllFpbhgLKBbbXLpY+oi7h6Iv9jB5BvaUaZuVe6vqWKPIYj\n"
+                .. 
"qLguSwKYkIt6bxxDPQcGh+9Mdvyvo/jm8AQQcczfRcv916UamEDygzUB65ibMt9R\n"
+                .. 
"oBrN0JI4tQGh7K53vnWnu0FH0j0iP6Nlh24Fdul6cCV/1PlGLUFY5+RfUJBZ+/q6\n"
+                .. 
"bi/8jWc5/1qNafFhePNlsMaj0VbDtw3TLFgmyiVQplzDAQp/8P6WTWrlyF0YSNW2\n"
+                .. 
"CBst8ryxAgMBAAECggEAAv9+g8+lsmpegcYltv87gnnW4scZwo78aWSPHRtErgf3\n"
+                .. 
"kjqgtI0fl7yJJUQvLAPJfApYXUuexlRjWHU9nqu1CfRPNeGBbVuT93GJU8RS5jmc\n"
+                .. 
"nDwgQTPtbAS//gavvroIyyt1U86Kk9Y+YwkaD0lXGk8NrkwN5ougU1ADaCyhb/IE\n"
+                .. 
"PzY9b+wF7ykmGxc+17JybvSvGmY3Fs5PlSRThV+uVIMvuw/EfvWfqUUkEzWvdh7/\n"
+                .. 
"0d57eWzluPAaQZLCxzirBClrw5GUS7WUJ/0OrLpoLwC/D1JOmothbShJKKh3sKQZ\n"
+                .. 
"k5xMRVN+QN65a7Xhw16+5nAgTk7P9Xkuj7ogywzzyQKBgQC8dI8/MylqVxtYmod4\n"
+                .. 
"D2DM9ZfplPjYy5RGxaHU6CHx5hZZUZUASmSR/LKBpZ0BUgBD9aGTrlnZq717qBNM\n"
+                .. 
"1mSrb1FZ7oA4OXeor9t5rd4hNoaNcbjW4OajbN7QQjvUKz8j4rqLbM7nN0Cshu6m\n"
+                .. 
"eQCsJYxwpzs36TCPbxuJr9hEHwKBgQDGSxsI7DhvnshzV6UDyGdbYF4o/BpoIMGE\n"
+                .. 
"fY5QOYTXD8OCOmAiDoRf4msDRpSVPbYQiC9kcCX5A/w7NkeAshN5/IYyw3uLO+22\n"
+                .. 
"i6xzx9J527weHhgz03+t0zd3JxNaEFR/GxJYZldgQ9CFjHdLEFoEndMYNlm+C4B+\n"
+                .. 
"Zr7SseJlLwKBgA67/kcutNo/nT+8NUNJ0IO13/6/SwWIRTuTUCfZTm4fUzgAjOnM\n"
+                .. 
"5zgSzdIdJL1pr+OgXNWzGAtQxivY5Elpqc1Nksq5PwUmWRizRzGoSmnGXZbJgW4r\n"
+                .. 
"f1zfsjwOMadRCkq/+13TUAn74+6ZTidt5oOPG//i01p3vPg586k8Omh5AoGBAK/s\n"
+                .. 
"sm+YI/njxbOPbreMdSZ8uQ1jnYoEhawmOLy0S0cClVJUuDV+67KmDos5c1l1BrJk\n"
+                .. 
"IKfbV9U10/I0lft4Ag+YGveut00wPhZWlQmjnvi+Gogd6xsP6ZcubWcpI+Ij2tNq\n"
+                .. 
"ETycj6i4gaf6l1vhhfvSihZRIg2Z5sY+Ic6MQ2/BAoGARAUxjo1S11xIwXcpunBX\n"
+                .. 
"UJrklxKeV0YAgRvKBhBUD/emRt8kIn6CDJyUwYMfQUVQdOYWxI8LdfirA2574/RJ\n"
+                .. 
"Z9WVu21q+5Ehgsyj8WC9wBpjaMMnefPhGUIVDTLyjYd0em8o3nsmv1BT22EVD99l\n"
+                .. "VqsWmAVmCe8jO3o35s1nUqs=\n"
+                .. "-----END PRIVATE KEY-----"
+            local ec256 = "-----BEGIN PRIVATE KEY-----\n"
+                .. 
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgzVW+Se78iBpOnKwj\n"
+                .. 
"D0Gqp/ZpmFSVJPRSTI7ZU50g3s2hRANCAARJ6hd/fMq/ZLvdEu1ZKHWFmiTjL1LD\n"
+                .. "U4q5hU/UxozQRW7+Gr5bcSvgHJWK/PlNCN/NGISpRs3K3l3K0BUr7plo\n"
+                .. "-----END PRIVATE KEY-----"
+            local cases = {
+                {alg = "ES256", key = rsa_key},
+                {alg = "ES512", key = ec256},
+                {alg = "RS256", key = ec256},
+            }
+            for _, case in ipairs(cases) do
+                local ok, err = plugin.check_schema({
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    token_endpoint_auth_method = "private_key_jwt",
+                    client_rsa_private_key = case.key,
+                    client_jwt_assertion_alg = case.alg,
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                })
+                ngx.say(ok and "ACCEPTED" or err)
+            end
+        }
+    }
+--- response_body
+property "client_jwt_assertion_alg" "ES256" requires an EC 
"client_rsa_private_key"
+property "client_jwt_assertion_alg" "ES512" requires a 
"client_rsa_private_key" on curve "P-521", got "P-256"
+property "client_jwt_assertion_alg" "RS256" requires an RSA 
"client_rsa_private_key"
+
+
+
+=== TEST 81: An endpoint the configuration never calls cannot conflict.
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local rsa_key = "-----BEGIN PRIVATE KEY-----\n"
+                .. 
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCR+XDXtbspeYTI\n"
+                .. 
"NSgogElb7gAeDEZOgF/t7RO3azpyPSqj+/5SguFaLQ2oANgY6FIhcQ0+wsVpNTE7\n"
+                .. 
"EkX5Z8pNGC+VoMWVrtllFpbhgLKBbbXLpY+oi7h6Iv9jB5BvaUaZuVe6vqWKPIYj\n"
+                .. 
"qLguSwKYkIt6bxxDPQcGh+9Mdvyvo/jm8AQQcczfRcv916UamEDygzUB65ibMt9R\n"
+                .. 
"oBrN0JI4tQGh7K53vnWnu0FH0j0iP6Nlh24Fdul6cCV/1PlGLUFY5+RfUJBZ+/q6\n"
+                .. 
"bi/8jWc5/1qNafFhePNlsMaj0VbDtw3TLFgmyiVQplzDAQp/8P6WTWrlyF0YSNW2\n"
+                .. 
"CBst8ryxAgMBAAECggEAAv9+g8+lsmpegcYltv87gnnW4scZwo78aWSPHRtErgf3\n"
+                .. 
"kjqgtI0fl7yJJUQvLAPJfApYXUuexlRjWHU9nqu1CfRPNeGBbVuT93GJU8RS5jmc\n"
+                .. 
"nDwgQTPtbAS//gavvroIyyt1U86Kk9Y+YwkaD0lXGk8NrkwN5ougU1ADaCyhb/IE\n"
+                .. 
"PzY9b+wF7ykmGxc+17JybvSvGmY3Fs5PlSRThV+uVIMvuw/EfvWfqUUkEzWvdh7/\n"
+                .. 
"0d57eWzluPAaQZLCxzirBClrw5GUS7WUJ/0OrLpoLwC/D1JOmothbShJKKh3sKQZ\n"
+                .. 
"k5xMRVN+QN65a7Xhw16+5nAgTk7P9Xkuj7ogywzzyQKBgQC8dI8/MylqVxtYmod4\n"
+                .. 
"D2DM9ZfplPjYy5RGxaHU6CHx5hZZUZUASmSR/LKBpZ0BUgBD9aGTrlnZq717qBNM\n"
+                .. 
"1mSrb1FZ7oA4OXeor9t5rd4hNoaNcbjW4OajbN7QQjvUKz8j4rqLbM7nN0Cshu6m\n"
+                .. 
"eQCsJYxwpzs36TCPbxuJr9hEHwKBgQDGSxsI7DhvnshzV6UDyGdbYF4o/BpoIMGE\n"
+                .. 
"fY5QOYTXD8OCOmAiDoRf4msDRpSVPbYQiC9kcCX5A/w7NkeAshN5/IYyw3uLO+22\n"
+                .. 
"i6xzx9J527weHhgz03+t0zd3JxNaEFR/GxJYZldgQ9CFjHdLEFoEndMYNlm+C4B+\n"
+                .. 
"Zr7SseJlLwKBgA67/kcutNo/nT+8NUNJ0IO13/6/SwWIRTuTUCfZTm4fUzgAjOnM\n"
+                .. 
"5zgSzdIdJL1pr+OgXNWzGAtQxivY5Elpqc1Nksq5PwUmWRizRzGoSmnGXZbJgW4r\n"
+                .. 
"f1zfsjwOMadRCkq/+13TUAn74+6ZTidt5oOPG//i01p3vPg586k8Omh5AoGBAK/s\n"
+                .. 
"sm+YI/njxbOPbreMdSZ8uQ1jnYoEhawmOLy0S0cClVJUuDV+67KmDos5c1l1BrJk\n"
+                .. 
"IKfbV9U10/I0lft4Ag+YGveut00wPhZWlQmjnvi+Gogd6xsP6ZcubWcpI+Ij2tNq\n"
+                .. 
"ETycj6i4gaf6l1vhhfvSihZRIg2Z5sY+Ic6MQ2/BAoGARAUxjo1S11xIwXcpunBX\n"
+                .. 
"UJrklxKeV0YAgRvKBhBUD/emRt8kIn6CDJyUwYMfQUVQdOYWxI8LdfirA2574/RJ\n"
+                .. 
"Z9WVu21q+5Ehgsyj8WC9wBpjaMMnefPhGUIVDTLyjYd0em8o3nsmv1BT22EVD99l\n"
+                .. "VqsWmAVmCe8jO3o35s1nUqs=\n"
+                .. "-----END PRIVATE KEY-----"
+            -- introspect() takes the local verification branch under
+            -- public_key/use_jwks, and rewrite() does not call it at all in
+            -- non-bearer mode without an introspection endpoint; PAR belongs
+            -- to the authorization code flow, which bearer_only never runs
+            local cases = {
+                {bearer_only = true, use_jwks = true,
+                 introspection_endpoint_auth_method = "client_secret_jwt",
+                 token_endpoint_auth_method = "private_key_jwt",
+                 client_rsa_private_key = "k", client_jwt_assertion_alg = 
"RS256"},
+                {token_endpoint_auth_method = "private_key_jwt",
+                 client_rsa_private_key = rsa_key,
+                 introspection_endpoint_auth_method = "client_secret_jwt",
+                 client_jwt_assertion_alg = "RS256"},
+                {use_pkce = true, par = {enabled = false,
+                                         endpoint_auth_method = 
"client_secret_jwt"},
+                 client_jwt_assertion_alg = "RS256"},
+            }
+            for _, extra in ipairs(cases) do
+                local conf = {
+                    client_id = "a",
+                    client_secret = "b",
+                    discovery = 
"https://example.com/.well-known/openid-configuration";,
+                    session = { secret = "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                }
+                for k, v in pairs(extra) do conf[k] = v end
+                local ok, err = plugin.check_schema(conf)
+                ngx.say(ok and "accepted" or err)
+            end
+        }
+    }
+--- response_body
+accepted
+accepted
+accepted
diff --git a/t/plugin/openid-connect11.t b/t/plugin/openid-connect11.t
index c834b2abed..3a220292ab 100644
--- a/t/plugin/openid-connect11.t
+++ b/t/plugin/openid-connect11.t
@@ -80,7 +80,7 @@ passed
 
 
 
-=== TEST 2: a callback whose state was overwritten by a second tab redirects 
back
+=== TEST 2: a callback with a state the session never issued redirects back
 --- config
     location /t {
         content_by_lua_block {
@@ -95,27 +95,21 @@ passed
                 return c and c:match("^([^;]+)")
             end
 
-            -- first tab: start a login flow and keep the session cookie
+            -- start a login flow and keep the session cookie
             local res_a = http.new():request_uri(base .. "/oidc11/page?tab=A")
-            local state_a = res_a.headers["Location"]:match("state=([^&]+)")
             local jar = cookie_of(res_a)
 
-            -- second tab in the same browser: overwrites the state in the 
session
-            local res_b = http.new():request_uri(base .. "/oidc11/page?tab=B", 
{
-                headers = {Cookie = jar}
-            })
-            jar = cookie_of(res_b)
-
-            -- the first tab's callback now carries a stale state
+            -- a state that was pruned, already consumed, or simply forged:
+            -- the session still knows the original URL to go back to
             local res_c = http.new():request_uri(
-                base .. "/oidc11/callback?code=dummy&state=" .. state_a, {
+                base .. "/oidc11/callback?code=dummy&state=deadbeef", {
                     headers = {Cookie = jar}
                 })
             ngx.say(res_c.status, " ", tostring(res_c.headers["Location"]))
         }
     }
 --- response_body
-302 /oidc11/page?tab=B
+302 /oidc11/page?tab=A
 --- error_log
 does not match state restored from session
 
@@ -137,16 +131,11 @@ does not match state restored from session
             end
 
             local res_a = http.new():request_uri(base .. "/oidc11/page?tab=A")
-            local state_a = res_a.headers["Location"]:match("state=([^&]+)")
             local jar = cookie_of(res_a)
 
-            local res_b = http.new():request_uri(base .. "/oidc11/page?tab=B", 
{
-                headers = {Cookie = jar}
-            })
-            jar = cookie_of(res_b)
-
+            -- same stale state as TEST 2, but the redirect is GET-only
             local res_c = http.new():request_uri(
-                base .. "/oidc11/callback?code=dummy&state=" .. state_a, {
+                base .. "/oidc11/callback?code=dummy&state=deadbeef", {
                     method = "POST",
                     body = "",
                     headers = {Cookie = jar}
@@ -172,3 +161,60 @@ does not match state restored from session
     }
 --- response_body
 500
+
+
+
+=== TEST 5: a second tab no longer invalidates the first tab's callback
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local base = "http://127.0.0.1:"; .. ngx.var.server_port
+
+            local function cookie_of(res)
+                local c = res.headers["Set-Cookie"]
+                if type(c) == "table" then
+                    c = table.concat(c, "; ")
+                end
+                return c and c:match("^([^;]+)")
+            end
+
+            -- first tab: start a login flow and keep the session cookie
+            local res_a = http.new():request_uri(base .. "/oidc11/page?tab=A")
+            local state_a = res_a.headers["Location"]:match("state=([^&]+)")
+            local jar = cookie_of(res_a)
+
+            -- second tab in the same browser: starts its own flow
+            local res_b = http.new():request_uri(base .. "/oidc11/page?tab=B", 
{
+                headers = {Cookie = jar}
+            })
+            -- keep the first tab's cookie if the second flow reused the
+            -- session instead of issuing a new one; carrying a session into
+            -- the callback is the whole point of the test, and dropping it
+            -- would silently turn this into the no-session case of TEST 4
+            jar = cookie_of(res_b) or jar
+
+            -- lua-resty-openidc 1.9.0 keeps one authorization state per
+            -- concurrent flow, so the first tab's state is still accepted and
+            -- the callback gets as far as the token endpoint, where the dummy
+            -- code fails. Before 1.9.0 this was a state mismatch instead.
+            local res_c = http.new():request_uri(
+                base .. "/oidc11/callback?code=dummy&state=" .. state_a, {
+                    headers = {Cookie = jar}
+                })
+
+            ngx.say(res_a.status == 302 and state_a ~= nil)
+            ngx.say(res_b.status == 302)
+            ngx.say(jar ~= nil)
+            ngx.say(res_c.status, " ", tostring(res_c.headers["Location"]))
+        }
+    }
+--- response_body
+true
+true
+true
+500 nil
+--- error_log
+OIDC authentication failed: response indicates failure
+--- no_error_log
+does not match state restored from session

Reply via email to