From a9fd7f34a68c57394d4e8da1e051c725b206a0b6 Mon Sep 17 00:00:00 2001 From: LIAN_XIAOYI Date: Thu, 2 Jul 2026 22:42:31 +0900 Subject: [PATCH 1/6] feat(openid-connect): support PAR and DPoP client options --- apisix-master-0.rockspec | 2 +- apisix/plugins/openid-connect.lua | 87 +++++++++++++- docs/en/latest/plugins/openid-connect.md | 53 ++++++++- docs/zh/latest/plugins/openid-connect.md | 53 ++++++++- t/plugin/openid-connect.t | 137 +++++++++++++++++++++-- 5 files changed, 319 insertions(+), 13 deletions(-) diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec index 69ca2a12d3cd..2173efcdb902 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 = 0.20260623-1", diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index eb0d3b526c58..568976eeaa1f 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -52,6 +52,25 @@ local function build_session_opts(session_conf) end +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 = { @@ -298,6 +317,60 @@ 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", + }, + }, + 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", + }, + }, + ["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", @@ -371,6 +444,14 @@ local schema = { type = "integer", default = 60 }, + client_jwt_assertion_alg = { + description = "Signing algorithm for the client assertion JWT.", + type = "string" + }, + 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", @@ -464,7 +545,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"} } @@ -509,7 +590,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) @@ -701,6 +783,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 four headers this diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index e196d6922206..a431b4bffb29 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 | | | Authentication method for the PAR endpoint. If unset, `token_endpoint_auth_method` is 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. | @@ -109,6 +118,8 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | 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_jwt_assertion_alg | string | False | | | Signing algorithm for the client assertion JWT. Defaults to `RS256` for `private_key_jwt` and `HS256` for `client_secret_jwt`. When the discovery document advertises `token_endpoint_auth_signing_alg_values_supported`, the configured value must be supported by the OP. | +| 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. | @@ -133,7 +144,7 @@ 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: `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). @@ -338,6 +349,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": "PS256", + "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 445d2a977f3e..2520ed4bc6a2 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 | 否 | | | PAR 端点的认证方法。未设置时使用 `token_endpoint_auth_method`。 | +| 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` 标头中设置访问令牌。 | @@ -109,6 +118,8 @@ import TabItem from '@theme/TabItem'; | 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_jwt_assertion_alg | string | 否 | | | 客户端断言 JWT 的签名算法。`private_key_jwt` 默认使用 `RS256`,`client_secret_jwt` 默认使用 `HS256`。当发现文档声明 `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,7 @@ 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#加密存储字段)。 +注意: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 +349,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": "PS256", + "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 312104c6789b..2c9538c4117e 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,22 @@ 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(conf.client_rsa_private_key ~= "89ae4c8edadf1cd1c9f034335f136f87ad84b625c8f1") + ngx.say(conf.dpop.private_key ~= "dpop-private-key") } } --- response_body -qO8TJbXcxCUnkkaTs3PxWDk5a54lv7FmngKQaxuXV4cL+7Kp1R4D8NS4w88so4e+ +true +true @@ -1845,6 +1847,125 @@ done +=== TEST 50a: 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 = "PS256", + private_key = "-----BEGIN PRIVATE KEY-----\nMIIEowIBAAK\n-----END PRIVATE KEY-----", + public_jwk = { + kty = "RSA", + e = "AQAB", + n = "abc", + }, + }, + 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", + 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 50b: 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 50c: 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 50d: 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 51: Configure plugin with a custom session.cookie_name. --- config location /t { From 6a6f8e4f7ba7731a571d600c5b64de1490e787f0 Mon Sep 17 00:00:00 2001 From: LIAN_XIAOYI Date: Fri, 3 Jul 2026 23:43:50 +0900 Subject: [PATCH 2/6] fix(openid-connect): reject private fields in DPoP JWK --- apisix/plugins/openid-connect.lua | 10 +++++++ t/plugin/openid-connect.t | 49 ++++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 568976eeaa1f..791eaefbfdda 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -359,6 +359,16 @@ local schema = { 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"] = { diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t index 2c9538c4117e..4198b1f72d8b 100644 --- a/t/plugin/openid-connect.t +++ b/t/plugin/openid-connect.t @@ -1847,7 +1847,7 @@ done -=== TEST 50a: Accept PAR, DPoP, and client assertion algorithm options. +=== TEST 51a: Accept PAR, DPoP, and client assertion algorithm options. --- config location /t { content_by_lua_block { @@ -1889,7 +1889,7 @@ done -=== TEST 50b: Reject unsupported DPoP signing algorithm in schema. +=== TEST 52b: Reject unsupported DPoP signing algorithm in schema. --- config location /t { content_by_lua_block { @@ -1915,7 +1915,7 @@ done -=== TEST 50c: Accept PAR enabled without endpoint in schema. +=== TEST 53c: Accept PAR enabled without endpoint in schema. --- config location /t { content_by_lua_block { @@ -1940,7 +1940,7 @@ done -=== TEST 50d: Reject DPoP enabled without key material in schema. +=== TEST 54d: Reject DPoP enabled without key material in schema. --- config location /t { content_by_lua_block { @@ -1966,7 +1966,40 @@ done -=== TEST 51: Configure plugin with a custom session.cookie_name. +=== 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 +property "dpop" validation failed: property "public_jwk" validation failed: object matches forbidden schema +done + + + +=== TEST 56: Configure plugin with a custom session.cookie_name. --- config location /t { content_by_lua_block { @@ -2010,7 +2043,7 @@ passed -=== TEST 52: Full OIDC login issues the session cookie under the configured cookie_name. +=== TEST 57: Full OIDC login issues the session cookie under the configured cookie_name. --- config location /t { content_by_lua_block { @@ -2062,7 +2095,7 @@ passed -=== TEST 53: Configure plugin with a short session.absolute_timeout. +=== TEST 58: Configure plugin with a short session.absolute_timeout. --- config location /t { content_by_lua_block { @@ -2106,7 +2139,7 @@ passed -=== TEST 54: Session is rejected once absolute_timeout elapses, re-initiating authentication. +=== TEST 59: Session is rejected once absolute_timeout elapses, re-initiating authentication. --- config location /t { content_by_lua_block { From 594f6a5e436f73d6e5e4260473e9753bd3a09bb4 Mon Sep 17 00:00:00 2001 From: LIAN_XIAOYI Date: Fri, 3 Jul 2026 23:44:18 +0900 Subject: [PATCH 3/6] test(openid-connect): assert encrypted keys are present --- t/plugin/openid-connect.t | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t index 4198b1f72d8b..b16e9645c24a 100644 --- a/t/plugin/openid-connect.t +++ b/t/plugin/openid-connect.t @@ -163,8 +163,10 @@ passed local etcd = require("apisix.core.etcd") local res = assert(etcd.get('/routes/1')) local conf = res.body.node.value.plugins["openid-connect"] - ngx.say(conf.client_rsa_private_key ~= "89ae4c8edadf1cd1c9f034335f136f87ad84b625c8f1") - ngx.say(conf.dpop.private_key ~= "dpop-private-key") + 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") } } From b8be0d3511ee92ed356b6f45773f7d28587d1885 Mon Sep 17 00:00:00 2001 From: LIAN_XIAOYI Date: Fri, 3 Jul 2026 23:45:39 +0900 Subject: [PATCH 4/6] test(openid-connect): cover PAR runtime mapping --- t/plugin/openid-connect.t | 117 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 4 deletions(-) diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t index b16e9645c24a..93e7fb2b6bea 100644 --- a/t/plugin/openid-connect.t +++ b/t/plugin/openid-connect.t @@ -2001,7 +2001,116 @@ done -=== TEST 56: Configure plugin with a custom session.cookie_name. +=== 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 + + 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 "" + ngx.say(string.find(location, "http://127.0.0.1:16969/authorize", 1, true) ~= nil) + ngx.say(string.find(location, "client_id=test_client", 1, true) ~= nil) + ngx.say(string.find(location, "request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Apar-runtime", 1, true) ~= nil) + ngx.say(string.find(location, "scope=", 1, true) == nil) + ngx.say(string.find(location, "state=", 1, true) == nil) + } + } +--- timeout: 10s +--- response_body +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 { @@ -2045,7 +2154,7 @@ passed -=== TEST 57: 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 { @@ -2097,7 +2206,7 @@ passed -=== TEST 58: 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 { @@ -2141,7 +2250,7 @@ passed -=== TEST 59: 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 { From 14a836651c0a0093649ad436bfcae68333ab73e4 Mon Sep 17 00:00:00 2001 From: LIAN_XIAOYI Date: Sat, 4 Jul 2026 00:11:53 +0900 Subject: [PATCH 5/6] test(openid-connect): assert PAR redirect query shape --- t/plugin/openid-connect.t | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t index 93e7fb2b6bea..5bb3a567e6ba 100644 --- a/t/plugin/openid-connect.t +++ b/t/plugin/openid-connect.t @@ -2092,11 +2092,21 @@ done ngx.status = res.status local location = res.headers["Location"] or "" - ngx.say(string.find(location, "http://127.0.0.1:16969/authorize", 1, true) ~= nil) - ngx.say(string.find(location, "client_id=test_client", 1, true) ~= nil) - ngx.say(string.find(location, "request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Apar-runtime", 1, true) ~= nil) - ngx.say(string.find(location, "scope=", 1, true) == nil) - ngx.say(string.find(location, "state=", 1, true) == nil) + local query = string.match(location, "^http://127%.0%.0%.1:16969/authorize%?(.*)$") + local args = query and ngx.decode_args(query) or {} + local params = 0 + for _ in pairs(args) do + params = params + 1 + end + + ngx.say(query ~= nil) + ngx.say(params == 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 @@ -2106,6 +2116,9 @@ true true true true +true +true +true --- error_code: 302 From d64a6f11b581ed787b226587767269486dbd0974 Mon Sep 17 00:00:00 2001 From: LIAN_XIAOYI Date: Sat, 4 Jul 2026 00:13:45 +0900 Subject: [PATCH 6/6] test(openid-connect): reuse table helper in PAR assertion --- t/plugin/openid-connect.t | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t index 5bb3a567e6ba..da0658d7d7c2 100644 --- a/t/plugin/openid-connect.t +++ b/t/plugin/openid-connect.t @@ -2094,13 +2094,10 @@ done 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 params = 0 - for _ in pairs(args) do - params = params + 1 - end + local core = require("apisix.core") ngx.say(query ~= nil) - ngx.say(params == 2) + 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)