Skip to content

Commit 4207de5

Browse files
authored
feat(openid-connect): add jwt audience validator (#11987)
1 parent 7dba835 commit 4207de5

File tree

7 files changed

+631
-0
lines changed

7 files changed

+631
-0
lines changed

apisix/plugins/openid-connect.lua

+61
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ local random = require("resty.random")
2222
local string = string
2323
local ngx = ngx
2424
local ipairs = ipairs
25+
local type = type
2526
local concat = table.concat
2627

2728
local ngx_encode_base64 = ngx.encode_base64
@@ -89,6 +90,32 @@ local schema = {
8990
type = "string",
9091
default = "apisix",
9192
},
93+
claim_validator = {
94+
type = "object",
95+
properties = {
96+
audience = {
97+
type = "object",
98+
description = "audience claim value to validate",
99+
properties = {
100+
claim = {
101+
type = "string",
102+
description = "custom claim name",
103+
default = "aud",
104+
},
105+
required = {
106+
type = "boolean",
107+
description = "audience claim is required",
108+
default = false,
109+
},
110+
match_with_client_id = {
111+
type = "boolean",
112+
description = "audience must euqal to or includes client_id",
113+
default = false,
114+
}
115+
},
116+
},
117+
},
118+
},
92119
logout_path = {
93120
type = "string",
94121
default = "/logout",
@@ -547,6 +574,40 @@ function _M.rewrite(plugin_conf, ctx)
547574
return 403, core.json.encode(error_response)
548575
end
549576
end
577+
578+
-- jwt audience claim validator
579+
local audience_claim = core.table.try_read_attr(conf, "claim_validator",
580+
"audience", "claim") or "aud"
581+
local audience_value = response[audience_claim]
582+
if core.table.try_read_attr(conf, "claim_validator", "audience", "required")
583+
and not audience_value then
584+
core.log.error("OIDC introspection failed: required audience (",
585+
audience_claim, ") not present")
586+
local error_response = { error = "required audience claim not present" }
587+
return 403, core.json.encode(error_response)
588+
end
589+
if core.table.try_read_attr(conf, "claim_validator", "audience", "match_with_client_id")
590+
and audience_value ~= nil then
591+
local error_response = { error = "mismatched audience" }
592+
local matched = false
593+
if type(audience_value) == "table" then
594+
for _, v in ipairs(audience_value) do
595+
if conf.client_id == v then
596+
matched = true
597+
end
598+
end
599+
if not matched then
600+
core.log.error("OIDC introspection failed: ",
601+
"audience list does not contain the client id")
602+
return 403, core.json.encode(error_response)
603+
end
604+
elseif conf.client_id ~= audience_value then
605+
core.log.error("OIDC introspection failed: ",
606+
"audience does not match the client id")
607+
return 403, core.json.encode(error_response)
608+
end
609+
end
610+
550611
-- Add configured access token header, maybe.
551612
add_access_token_header(ctx, conf, access_token)
552613

ci/init-plugin-test-service.sh

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ after() {
5151
# configure keycloak
5252
docker exec apisix_keycloak bash /tmp/kcadm_configure_cas.sh
5353
docker exec apisix_keycloak bash /tmp/kcadm_configure_university.sh
54+
docker exec apisix_keycloak bash /tmp/kcadm_configure_basic.sh
5455

5556
# configure clickhouse
5657
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 @-

ci/pod/docker-compose.plugin.yml

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ services:
5757
- ./ci/pod/keycloak/server.key.pem:/opt/keycloak/conf/server.key.pem
5858
- ./ci/pod/keycloak/kcadm_configure_cas.sh:/tmp/kcadm_configure_cas.sh
5959
- ./ci/pod/keycloak/kcadm_configure_university.sh:/tmp/kcadm_configure_university.sh
60+
- ./ci/pod/keycloak/kcadm_configure_basic.sh:/tmp/kcadm_configure_basic.sh
6061

6162
## kafka-cluster
6263
zookeeper-server1:
+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
3+
#
4+
# Licensed to the Apache Software Foundation (ASF) under one or more
5+
# contributor license agreements. See the NOTICE file distributed with
6+
# this work for additional information regarding copyright ownership.
7+
# The ASF licenses this file to You under the Apache License, Version 2.0
8+
# (the "License"); you may not use this file except in compliance with
9+
# the License. You may obtain a copy of the License at
10+
#
11+
# http://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# Unless required by applicable law or agreed to in writing, software
14+
# distributed under the License is distributed on an "AS IS" BASIS,
15+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
# See the License for the specific language governing permissions and
17+
# limitations under the License.
18+
#
19+
20+
export PATH=/opt/keycloak/bin:$PATH
21+
22+
kcadm.sh config credentials --server http://127.0.0.1:8080 --realm master --user admin --password admin
23+
24+
# create realm
25+
kcadm.sh create realms -s realm=basic -s enabled=true
26+
27+
# set realm keys with specific private key, reuse tls cert and key
28+
PRIVATE_KEY=$(awk 'NF {sub(/\r/, ""); printf "%s\\n", $0}' /opt/keycloak/conf/server.key.pem)
29+
CERTIFICATE=$(awk 'NF {sub(/\r/, ""); printf "%s\\n", $0}' /opt/keycloak/conf/server.crt.pem)
30+
kcadm.sh create components -r basic -s name=rsa-apisix -s providerId=rsa \
31+
-s providerType=org.keycloak.keys.KeyProvider \
32+
-s 'config.priority=["1000"]' \
33+
-s 'config.enabled=["true"]' \
34+
-s 'config.active=["true"]' \
35+
-s "config.privateKey=[\"$PRIVATE_KEY\"]" \
36+
-s "config.certificate=[\"$CERTIFICATE\"]" \
37+
-s 'config.algorithm=["RS256"]'
38+
39+
# create client apisix
40+
kcadm.sh create clients \
41+
-r basic \
42+
-s clientId=apisix \
43+
-s enabled=true \
44+
-s clientAuthenticatorType=client-secret \
45+
-s secret=secret \
46+
-s 'redirectUris=["*"]' \
47+
-s 'directAccessGrantsEnabled=true'
48+
49+
# add audience to client apisix, so that the access token will contain the client id ("apisix") as audience
50+
APISIX_CLIENT_UUID=$(kcadm.sh get clients -r basic -q clientId=apisix | jq -r '.[0].id')
51+
kcadm.sh create clients/$APISIX_CLIENT_UUID/protocol-mappers/models \
52+
-r basic \
53+
-s protocol=openid-connect \
54+
-s name=aud \
55+
-s protocolMapper=oidc-audience-mapper \
56+
-s 'config."id.token.claim"=false' \
57+
-s 'config."access.token.claim"=true' \
58+
-s 'config."included.client.audience"=apisix'
59+
60+
# create client apisix
61+
kcadm.sh create clients \
62+
-r basic \
63+
-s clientId=apisix \
64+
-s enabled=true \
65+
-s clientAuthenticatorType=client-secret \
66+
-s secret=secret \
67+
-s 'redirectUris=["*"]' \
68+
-s 'directAccessGrantsEnabled=true'
69+
70+
# create client apisix-no-aud, without client id audience
71+
# according to Keycloak's default implementation, when unconfigured,
72+
# only the account is listed as an audience, not the client id
73+
74+
kcadm.sh create clients \
75+
-r basic \
76+
-s clientId=apisix-no-aud \
77+
-s enabled=true \
78+
-s clientAuthenticatorType=client-secret \
79+
-s secret=secret \
80+
-s 'redirectUris=["*"]' \
81+
-s 'directAccessGrantsEnabled=true'
82+
83+
# create user jack
84+
kcadm.sh create users -r basic -s username=jack -s enabled=true
85+
kcadm.sh set-password -r basic --username jack --new-password jack

docs/en/latest/plugins/openid-connect.md

+5
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ description: OpenID Connect allows the client to obtain user information from th
4141
| 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`. |
4242
| 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 |
4343
| realm | string | False | "apisix" | | Realm used for authentication. |
44+
| claim_validator | object | False | | | Define the JWT claim validator. |
45+
| claim_validator.audience | object | False | | | OpenID Connect Audience (["aud"](https://openid.net/specs/openid-connect-core-1_0.html)) validator. |
46+
| claim_validator.audience.claim | string | False | "aud" | | Customize the claim used to store the audience. |
47+
| claim_validator.audience.required | boolean | False | false | | Requires that the audience claim must exist and that it follows the custom claim. |
48+
| 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. |
4449
| bearer_only | boolean | False | false | | When set to `true`, APISIX will only check if the authorization header in the request matches a bearer token. |
4550
| logout_path | string | False | "/logout" | | Path for logging out. |
4651
| 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). |

docs/zh/latest/plugins/openid-connect.md

+5
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ description: OpenID Connect(OIDC)是基于 OAuth 2.0 的身份认证协议
4040
| discovery | string || | | 身份认证服务暴露的服务发现端点。 |
4141
| scope | string || "openid" | | OIDC 范围对应于应返回的有关经过身份验证的用户的信息,也称为 [claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims)。默认值是`openid`,这是 OIDC 返回唯一标识经过身份验证的用户的 `sub` 声明所需的范围。可以附加其他范围并用空格分隔,例如 `openid email profile`|
4242
| realm | string || "apisix" | | bearer token 无效时 [`WWW-Authenticate` 响应头](https://www.rfc-editor.org/rfc/rfc6750#section-3)中会伴随着的 `realm` 讯息。 |
43+
| claim_validator | object || | | 设置 JWT claim 验证器。 |
44+
| claim_validator.audience | object || | | OpenID Connect Audience (["aud"](https://openid.net/specs/openid-connect-core-1_0.html)) 验证器。 |
45+
| claim_validator.audience.claim | string || "aud" | | 自定义存储 audience 的声明(字段名)。|
46+
| claim_validator.audience.required | boolean || false | | 要求 JWT 中的 audience 声明必须存在,它将遵循自定义声明设置。 |
47+
| claim_validator.audience.match_with_client_id | boolean || false | | 要求 JWT 中的 audience 声明与 client_id 相等(其值为字符串时)或包含 client_id(其值为字符串数组时),这符合 OpenID Connect 规范中的定义。 |
4348
| bearer_only | boolean || false | | 当设置为 `true` 时,将仅检查请求头中的令牌(Token)。 |
4449
| logout_path | string || "/logout" | | 登出路径。 |
4550
| 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) 进行重定向。 |

0 commit comments

Comments
 (0)