Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apisix-master-0.rockspec
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 95 additions & 2 deletions apisix/plugins/openid-connect.lua
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ local function build_session_opts(session_conf)
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.

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 = {
Expand Down Expand Up @@ -298,6 +317,70 @@ 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",

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.

["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",
Expand Down Expand Up @@ -371,6 +454,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",
Expand Down Expand Up @@ -464,7 +555,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"}
}
Expand Down Expand Up @@ -509,7 +600,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)

Expand Down Expand Up @@ -701,6 +793,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
Expand Down
53 changes: 52 additions & 1 deletion docs/en/latest/plugins/openid-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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. |
Expand All @@ -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).

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading