Skip to content

feat(openid-connect): support PAR and DPoP client options#13649

Open
kevinlzw wants to merge 6 commits into
apache:masterfrom
kevinlzw:feat/openid-connect-par-dpop
Open

feat(openid-connect): support PAR and DPoP client options#13649
kevinlzw wants to merge 6 commits into
apache:masterfrom
kevinlzw:feat/openid-connect-par-dpop

Conversation

@kevinlzw

@kevinlzw kevinlzw commented Jul 2, 2026

Copy link
Copy Markdown

Description

I maintain lua-resty-openidc, and the latest lua-resty-openidc 1.9.0 release added client-side PAR and DPoP support. This PR bumps APISIX to that release and exposes the new client-side OAuth/OIDC options through the openid-connect Plugin.

This PR adds nested APISIX Plugin configuration for:

  • OAuth 2.0 Pushed Authorization Requests (PAR)
  • OAuth 2.0 DPoP proof generation for token and userinfo requests
  • client assertion JWT signing algorithm and audience options

The APISIX Plugin keeps user-facing PAR and DPoP options grouped under par and dpop, then maps them to the flat option names expected by lua-resty-openidc before invoking the library. It also encrypts dpop.private_key in etcd and documents the new options in English and Chinese.

Related to #11219. This PR covers the openid-connect Plugin acting as an OAuth/OIDC client/Relying Party. It does not implement APISIX resource-server-side DPoP proof validation.

Backward compatibility

This change is backward compatible for existing openid-connect Plugin configurations:

  • Existing attributes are not renamed or removed.
  • PAR and DPoP are opt-in and disabled by default.
  • Existing routes keep using the same behavior unless they configure the new par, dpop, or client assertion algorithm attributes.
  • dpop.private_key encryption only affects the new DPoP private-key field.

Which issue(s) this PR fixes:

Fixes #13085
Related to #11219

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible

Tests

  • git -C apisix diff --check
  • LuaJIT bytecode compile check for apisix/plugins/openid-connect.lua
  • prove -I../test-nginx/lib -I./ -r -s t/plugin/openid-connect.t
    • Files=1, Tests=175, Result: PASS
  • make lint was run in a Docker temporary copy of the Windows checkout after normalizing CRLF line endings and installing luacheck in the temporary container:
    • luacheck -q apisix t/lib: 0 warnings / 0 errors in 376 files
    • lj-releng still fails on existing repository-wide line-length/style findings, including pre-existing openid-connect.lua long lines not introduced by this PR.

@kevinlzw kevinlzw marked this pull request as ready for review July 2, 2026 13:45
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. doc Documentation things enhancement New feature or request labels Jul 2, 2026
@juzhiyuan juzhiyuan requested review from bzp2010 and nic-6443 July 3, 2026 00:15
@kevinlzw

kevinlzw commented Jul 3, 2026

Copy link
Copy Markdown
Author

This PR should also fix #13085.

},
public_jwk = {
description = "Public JWK matching dpop.private_key.",
type = "object",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified a JWK carrying private key material passes check_schema here — {kty = "RSA", e = "AQAB", n = "...", d = "..."} is accepted. Since public_jwk is (rightly) not in encrypt_fields, a user who pastes their complete JWK ends up with the private exponent stored in plaintext in etcd, and the mistake only surfaces per request when lua-resty-openidc rejects the proof. Rejecting private JWK fields at config time keeps the key out of etcd and fails fast:

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"}},
    }},
},

Same field list as upstream's openidc_dpop_public_jwk_private_field; I checked the not/anyOf combo works with the jsonschema library APISIX uses.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed catch. I added the schema-side rejection for private JWK members (d, p, q, dp, dq, qi, oth, and k) under dpop.public_jwk, so complete/private JWKs now fail check_schema before anything can be persisted in etcd. I also added a regression case that verifies a JWK containing d is rejected.

end


local function flatten_openidc_options(conf)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests are all schema-level, so nothing exercises this mapping at runtime — a typo in any of these assignments (or an option rename on the lua-resty-openidc side) would still pass CI. A single e2e block would cover it: serve a fake discovery doc plus a PAR mock from the test nginx (t/plugin/openid-connect-redis.t already does the fake-discovery part with a local authorization_endpoint), enable par with endpoint pointing at the mock, hit the route, and assert the 302 Location carries only client_id and request_uri and that scope/state stay out of the query. That one test proves the PAR wiring and the flattening end to end.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that makes sense. I added a Test::Nginx runtime block with a fake discovery document and a fake PAR endpoint, then configured the plugin through the Admin API and hit the route to exercise the actual flattening path. The test now parses the final authorize redirect and asserts the query shape exactly: only client_id and request_uri are present, while scope, state, response_type, and redirect_uri are absent.

Comment thread t/plugin/openid-connect.t Outdated
Comment on lines +166 to +167
ngx.say(conf.client_rsa_private_key ~= "89ae4c8edadf1cd1c9f034335f136f87ad84b625c8f1")
ngx.say(conf.dpop.private_key ~= "dpop-private-key")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These ~= checks turn vacuously true when the field comes back nil, so a value dropped somewhere between admin API and etcd would read as "encrypted". The encryption in tests is deterministic (fixed key), so pinning the ciphertext like the old test did still works; if you'd rather avoid that brittleness, something like type(conf.dpop.private_key) == "string" and conf.dpop.private_key ~= "dpop-private-key" keeps the nil case failing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I updated the assertions to require the stored values to be strings before comparing them with the plaintext values, so a missing/nil value no longer passes as encrypted. I kept it value-agnostic rather than pinning the deterministic ciphertext, to avoid making the test depend on the exact encrypted blob.

@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Jul 3, 2026
@kevinlzw

kevinlzw commented Jul 7, 2026

Copy link
Copy Markdown
Author

Hi @nic-6443 , @bzp2010 ,

Can you help review after my changes if possible? Thank you very much.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc Documentation things enhancement New feature or request size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: openid-connect token introspection client_secret_basic, secret sent in header and body

2 participants