Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(openid-connect): add jwt audience validator #11987

Merged
merged 22 commits into from
Mar 6, 2025
Merged
Show file tree
Hide file tree
Changes from 18 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
62 changes: 62 additions & 0 deletions apisix/plugins/openid-connect.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ local random = require("resty.random")
local string = string
local ngx = ngx
local ipairs = ipairs
local type = type
local concat = table.concat

local ngx_encode_base64 = ngx.encode_base64
Expand Down Expand Up @@ -89,6 +90,33 @@ local schema = {
type = "string",
default = "apisix",
},
claim_validator = {
type = "object",
properties = {
audience = {
type = "object",
description = "audience claim value to validate",
properties = {
claim = {
type = "string",
description = "custom claim name",
default = "aud",
},
required = {
type = "boolean",
description = "audience claim is required",
default = false,
},
match_with_client_id = {
type = "boolean",
description = "audience must euqal to or includes client_id",
Copy link
Member

Choose a reason for hiding this comment

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

From the code, it can only be equals?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, if the match is true but not required, the correlation check with client_id is only performed if the aud value is not empty.

default = false,
}
},
},
},
default = {},
},
logout_path = {
type = "string",
default = "/logout",
Expand Down Expand Up @@ -547,6 +575,40 @@ function _M.rewrite(plugin_conf, ctx)
return 403, core.json.encode(error_response)
end
end

-- jwt audience claim validator
local audience_claim = core.table.try_read_attr(conf, "claim_validator",
"audience", "claim") or "aud"
local audience_value = response[audience_claim]
if core.table.try_read_attr(conf, "claim_validator", "audience", "required")
and not audience_value then
core.log.error("OIDC introspection failed: required audience (",
audience_claim, ") not present")
local error_response = { error = "required audience claim not present" }
return 403, core.json.encode(error_response)
end
if core.table.try_read_attr(conf, "claim_validator", "audience", "match_with_client_id")
and audience_value ~= nil then
local error_response = { error = "mismatched audience" }
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we directly use a json string and return it instead of using a lua table and calling json.encode?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If so, should I need to set the Content-Type: application/json header manually? 🤔

local matched = false
if type(audience_value) == "table" then
for _, v in ipairs(audience_value) do
if conf.client_id == v then
matched = true
end
end
if not matched then
core.log.error("OIDC introspection failed: ",
"audience list does not contain the client id")
return 403, core.json.encode(error_response)
end
elseif conf.client_id ~= audience_value then
core.log.error("OIDC introspection failed: ",
Copy link
Member

Choose a reason for hiding this comment

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

Compared to error, warn level seems more appropriate for these logs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, but the rest of the code uses error, so I'm choosing to follow the precedent, should we break that "convention"?

"audience does not match the client id")
return 403, core.json.encode(error_response)
end
end

-- Add configured access token header, maybe.
add_access_token_header(ctx, conf, access_token)

Expand Down
1 change: 1 addition & 0 deletions ci/init-plugin-test-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ after() {
# configure keycloak
docker exec apisix_keycloak bash /tmp/kcadm_configure_cas.sh
docker exec apisix_keycloak bash /tmp/kcadm_configure_university.sh
docker exec apisix_keycloak bash /tmp/kcadm_configure_basic.sh

# configure clickhouse
echo 'CREATE TABLE default.test (`host` String, `client_ip` String, `route_id` String, `service_id` String, `@timestamp` String, PRIMARY KEY(`@timestamp`)) ENGINE = MergeTree()' | curl 'http://localhost:8123/' --data-binary @-
Expand Down
1 change: 1 addition & 0 deletions ci/pod/docker-compose.plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ services:
- ./ci/pod/keycloak/server.key.pem:/opt/keycloak/conf/server.key.pem
- ./ci/pod/keycloak/kcadm_configure_cas.sh:/tmp/kcadm_configure_cas.sh
- ./ci/pod/keycloak/kcadm_configure_university.sh:/tmp/kcadm_configure_university.sh
- ./ci/pod/keycloak/kcadm_configure_basic.sh:/tmp/kcadm_configure_basic.sh

## kafka-cluster
zookeeper-server1:
Expand Down
85 changes: 85 additions & 0 deletions ci/pod/keycloak/kcadm_configure_basic.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bash

#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

export PATH=/opt/keycloak/bin:$PATH

kcadm.sh config credentials --server http://127.0.0.1:8080 --realm master --user admin --password admin

# create realm
kcadm.sh create realms -s realm=basic -s enabled=true

# set realm keys with specific private key, reuse tls cert and key
PRIVATE_KEY=$(awk 'NF {sub(/\r/, ""); printf "%s\\n", $0}' /opt/keycloak/conf/server.key.pem)
CERTIFICATE=$(awk 'NF {sub(/\r/, ""); printf "%s\\n", $0}' /opt/keycloak/conf/server.crt.pem)
kcadm.sh create components -r basic -s name=rsa-apisix -s providerId=rsa \
-s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["1000"]' \
-s 'config.enabled=["true"]' \
-s 'config.active=["true"]' \
-s "config.privateKey=[\"$PRIVATE_KEY\"]" \
-s "config.certificate=[\"$CERTIFICATE\"]" \
-s 'config.algorithm=["RS256"]'

# create client apisix
kcadm.sh create clients \
-r basic \
-s clientId=apisix \
-s enabled=true \
-s clientAuthenticatorType=client-secret \
-s secret=secret \
-s 'redirectUris=["*"]' \
-s 'directAccessGrantsEnabled=true'

# add audience to client apisix, so that the access token will contain the client id ("apisix") as audience
APISIX_CLIENT_UUID=$(kcadm.sh get clients -r basic -q clientId=apisix | jq -r '.[0].id')
kcadm.sh create clients/$APISIX_CLIENT_UUID/protocol-mappers/models \
-r basic \
-s protocol=openid-connect \
-s name=aud \
-s protocolMapper=oidc-audience-mapper \
-s 'config."id.token.claim"=false' \
-s 'config."access.token.claim"=true' \
-s 'config."included.client.audience"=apisix'

# create client apisix
kcadm.sh create clients \
-r basic \
-s clientId=apisix \
-s enabled=true \
-s clientAuthenticatorType=client-secret \
-s secret=secret \
-s 'redirectUris=["*"]' \
-s 'directAccessGrantsEnabled=true'

# create client apisix-no-aud, without client id audience
# according to Keycloak's default implementation, when unconfigured,
# only the account is listed as an audience, not the client id

kcadm.sh create clients \
-r basic \
-s clientId=apisix-no-aud \
-s enabled=true \
-s clientAuthenticatorType=client-secret \
-s secret=secret \
-s 'redirectUris=["*"]' \
-s 'directAccessGrantsEnabled=true'

# create user jack
kcadm.sh create users -r basic -s username=jack -s enabled=true
kcadm.sh set-password -r basic --username jack --new-password jack
5 changes: 5 additions & 0 deletions docs/en/latest/plugins/openid-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ description: OpenID Connect allows the client to obtain user information from th
| scope | string | False | "openid" | | OIDC scope that corresponds to information that should be returned about the authenticated user, also known as [claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims). The default value is `openid`, the required scope for OIDC to return a `sub` claim that uniquely identifies the authenticated user. Additional scopes can be appended and delimited by spaces, such as `openid email profile`. |
| required_scopes | string[] | False | | | Array of strings. Used in conjunction with the introspection endpoint (when `bearer_only` is `true`). If present, the plugin will check if the token contains all required scopes. If not, 403 will be returned with an error message |
| realm | string | False | "apisix" | | Realm used for authentication. |
| claim_validator | object | False | {} | | Define the JWT claim validator. |
| claim_validator.audience | object | False | | | OpenID Connect Audience (["aud"](https://openid.net/specs/openid-connect-core-1_0.html)) validator. |
| claim_validator.audience.claim | string | False | "aud" | | Customize the claim used to store the audience. |
| claim_validator.audience.required | boolean | False | false | | Requires that the audience claim must exist and that it follows the custom claim. |
| claim_validator.audience.match_with_client_id | boolean | False | false | | Requires that the audience claim value must be equal to the client_id (when the value is a string) or contain the client_id (when the value is an array of strings), as required by the OpenID Connect specification. |
| bearer_only | boolean | False | false | | When set to `true`, APISIX will only check if the authorization header in the request matches a bearer token. |
| logout_path | string | False | "/logout" | | Path for logging out. |
| post_logout_redirect_uri | string | False | | | URL to redirect to after logging out. If the OIDC discovery endpoint does not provide an [`end_session_endpoint`](https://openid.net/specs/openid-connect-rpinitiated-1_0.html), the plugin internally redirects using the [`redirect_after_logout_uri`](https://github.com/zmartzone/lua-resty-openidc). Otherwise, it redirects using the [`post_logout_redirect_uri`](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). |
Expand Down
5 changes: 5 additions & 0 deletions docs/zh/latest/plugins/openid-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ description: OpenID Connect(OIDC)是基于 OAuth 2.0 的身份认证协议
| discovery | string | 是 | | | 身份认证服务暴露的服务发现端点。 |
| scope | string | 否 | "openid" | | OIDC 范围对应于应返回的有关经过身份验证的用户的信息,也称为 [claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims)。默认值是`openid`,这是 OIDC 返回唯一标识经过身份验证的用户的 `sub` 声明所需的范围。可以附加其他范围并用空格分隔,例如 `openid email profile`。 |
| realm | string | 否 | "apisix" | | bearer token 无效时 [`WWW-Authenticate` 响应头](https://www.rfc-editor.org/rfc/rfc6750#section-3)中会伴随着的 `realm` 讯息。 |
| claim_validator | object | 否 | {} | | 设置 JWT claim 验证器。 |
| claim_validator.audience | object | 否 | | | OpenID Connect Audience (["aud"](https://openid.net/specs/openid-connect-core-1_0.html)) 验证器。 |
| claim_validator.audience.claim | string | 否 | "aud" | | 自定义存储 audience 的声明(字段名)。|
| claim_validator.audience.required | boolean | 否 | false | | 要求 JWT 中的 audience 声明必须存在,它将遵循自定义声明设置。 |
| claim_validator.audience.match_with_client_id | boolean | 否 | false | | 要求 JWT 中的 audience 声明与 client_id 相等(其值为字符串时)或包含 client_id(其值为字符串数组时),这符合 OpenID Connect 规范中的定义。 |
| bearer_only | boolean | 否 | false | | 当设置为 `true` 时,将仅检查请求头中的令牌(Token)。 |
| logout_path | string | 否 | "/logout" | | 登出路径。 |
| post_logout_redirect_uri | string | 否 | | | 调用登出接口后想要跳转的 URL。如果 OIDC 的服务发现端点没有提供 [`end_session_endpoint`](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) ,插件内部会使用 [`redirect_after_logout_uri`](https://github.com/zmartzone/lua-resty-openidc) 进行重定向,否则使用 [`post_logout_redirect_uri`](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) 进行重定向。 |
Expand Down
2 changes: 1 addition & 1 deletion t/plugin/openid-connect.t
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ OIDC introspection failed: invalid token
}
}
--- response_body
{"accept_none_alg":false,"accept_unsupported_alg":true,"access_token_expires_leeway":0,"access_token_in_authorization_header":false,"bearer_only":false,"client_id":"kbyuFDidLLm280LIwVFiazOqjO3ty8KH","client_jwt_assertion_expires_in":60,"client_secret":"60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa","discovery":"http://127.0.0.1:1980/.well-known/openid-configuration","force_reauthorize":false,"iat_slack":120,"introspection_endpoint_auth_method":"client_secret_basic","introspection_interval":0,"jwk_expires_in":86400,"jwt_verification_cache_ignore":false,"logout_path":"/logout","realm":"apisix","renew_access_token_on_expiry":true,"revoke_tokens_on_logout":false,"scope":"openid","set_access_token_header":true,"set_id_token_header":true,"set_refresh_token_header":false,"set_userinfo_header":true,"ssl_verify":false,"timeout":3,"token_endpoint_auth_method":"client_secret_basic","unauth_action":"auth","use_nonce":false,"use_pkce":false}
{"accept_none_alg":false,"accept_unsupported_alg":true,"access_token_expires_leeway":0,"access_token_in_authorization_header":false,"bearer_only":false,"claim_validator":[],"client_id":"kbyuFDidLLm280LIwVFiazOqjO3ty8KH","client_jwt_assertion_expires_in":60,"client_secret":"60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa","discovery":"http://127.0.0.1:1980/.well-known/openid-configuration","force_reauthorize":false,"iat_slack":120,"introspection_endpoint_auth_method":"client_secret_basic","introspection_interval":0,"jwk_expires_in":86400,"jwt_verification_cache_ignore":false,"logout_path":"/logout","realm":"apisix","renew_access_token_on_expiry":true,"revoke_tokens_on_logout":false,"scope":"openid","set_access_token_header":true,"set_id_token_header":true,"set_refresh_token_header":false,"set_userinfo_header":true,"ssl_verify":false,"timeout":3,"token_endpoint_auth_method":"client_secret_basic","unauth_action":"auth","use_nonce":false,"use_pkce":false}



Expand Down
Loading
Loading