This is an automated email from the ASF dual-hosted git repository.

nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new eee3214893 fix(openid-connect): enforce audience, issuer and required 
scopes (#13829)
eee3214893 is described below

commit eee32148933ae7ac0a250b1c182cfbee56cfeedd
Author: Nic <[email protected]>
AuthorDate: Tue Aug 18 14:35:10 2026 +0800

    fix(openid-connect): enforce audience, issuer and required scopes (#13829)
---
 apisix/plugins/openid-connect.lua          |  97 +++++++++--
 docs/en/latest/plugins/openid-connect.md   |   6 +-
 docs/zh/latest/plugins/openid-connect.md   |   6 +-
 t/plugin/openid-connect-claim-validation.t | 202 +++++++++++++++++++++++
 t/plugin/openid-connect-required-scopes.t  | 252 +++++++++++++++++++++++++++++
 5 files changed, 544 insertions(+), 19 deletions(-)

diff --git a/apisix/plugins/openid-connect.lua 
b/apisix/plugins/openid-connect.lua
index bc1842a13d..b4913a741c 100644
--- a/apisix/plugins/openid-connect.lua
+++ b/apisix/plugins/openid-connect.lua
@@ -19,6 +19,7 @@ local core              = require("apisix.core")
 local secret            = require("apisix.secret")
 local ngx_re            = require("ngx.re")
 local openidc           = require("resty.openidc")
+local jwt               = require("resty.jwt")
 local jsonschema        = require('jsonschema')
 local pkey              = require("resty.openssl.pkey")
 local dump_jwk          = require("resty.openssl.auxiliary.jwk").dump_jwk
@@ -1026,17 +1027,24 @@ local function introspect(ctx, conf)
         if not valid_issuers then
             local discovery, discovery_err = openidc.get_discovery_doc(conf)
             if discovery_err then
-                core.log.warn("OIDC access discovery url failed : ", 
discovery_err)
-            else
-                core.log.info("valid_issuers not provided explicitly," ..
-                              " using issuer from discovery doc: ",
-                              discovery.issuer)
-                valid_issuers = {discovery.issuer}
+                -- The discovery document is the only source of the trusted
+                -- issuer when valid_issuers is not configured. Continuing
+                -- would verify the signature with no issuer constraint at
+                -- all, so a token minted by another issuer holding the same
+                -- key would be accepted; fail closed instead.
+                core.log.error("OIDC access discovery url failed : ", 
discovery_err)
+                ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. 
conf.realm ..
+                    '", error="invalid_token", error_description="issuer 
validation unavailable"'
+                -- the discovery error is logged above; the caller logs 
whatever
+                -- is returned here again, and it can carry the discovery URL
+                return ngx.HTTP_UNAUTHORIZED, "issuer validation unavailable", 
nil, nil
             end
+            core.log.info("valid_issuers not provided explicitly," ..
+                          " using issuer from discovery doc: ",
+                          discovery.issuer)
+            valid_issuers = {discovery.issuer}
         end
-        if valid_issuers then
-            opts.valid_issuers = valid_issuers
-        end
+        opts.valid_issuers = valid_issuers
         local res, err = openidc.bearer_jwt_verify(conf, opts)
         if err then
             -- Error while validating or token invalid.
@@ -1113,6 +1121,33 @@ local function required_scopes_present(required_scopes, 
http_scopes)
     return true
 end
 
+
+-- Resolve the scopes granted to a session established through the
+-- authorization code flow. lua-resty-openidc does not surface the `scope`
+-- field of the token endpoint response, so read the claim from the access
+-- token, which providers that support scope-based authorization issue as a
+-- JWT, and fall back to the ID token claims. Returns nil when the granted
+-- scopes cannot be determined, which the caller must not treat as "granted".
+local function session_scopes(response)
+    local scope
+    if type(response.access_token) == "string" then
+        local jwt_obj = jwt:load_jwt(response.access_token)
+        if jwt_obj and jwt_obj.valid and type(jwt_obj.payload) == "table" then
+            scope = jwt_obj.payload.scope
+        end
+    end
+
+    if not scope and type(response.id_token) == "table" then
+        scope = response.id_token.scope
+    end
+
+    if type(scope) ~= "string" then
+        return nil
+    end
+
+    return split_scopes_by_space(scope)
+end
+
 local function validate_claims_in_oidcauth_response(resp, conf)
     if not conf.claim_schema then
         return true
@@ -1220,15 +1255,20 @@ function _M.rewrite(plugin_conf, ctx)
             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
+            local match_with_client_id = core.table.try_read_attr(conf, 
"claim_validator",
+                                                                  "audience",
+                                                                  
"match_with_client_id")
+            -- match_with_client_id cannot be satisfied by a token without the
+            -- audience claim, so it implies `required`: otherwise a token that
+            -- simply omits `aud` would skip the check the operator asked for.
+            if (core.table.try_read_attr(conf, "claim_validator", "audience", 
"required")
+                or match_with_client_id) 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
+            if match_with_client_id then
                 local error_response = { error = "mismatched audience" }
                 local matched = false
                 if type(audience_value) == "table" then
@@ -1292,6 +1332,13 @@ function _M.rewrite(plugin_conf, ctx)
             conf.session_contents.enc_id_token = true
         end
 
+        -- The granted scopes are read from the access token, so it has to be
+        -- part of the session when required_scopes is enforced.
+        if conf.required_scopes and conf.session_contents then
+            conf.session_contents = core.table.clone(conf.session_contents)
+            conf.session_contents.access_token = true
+        end
+
         -- Authenticate the request. This will validate the access token if it
         -- is stored in a sessions cookie, and also renew the token if 
required.
         -- If no token can be extracted, the response will redirect to the ID
@@ -1397,6 +1444,30 @@ function _M.rewrite(plugin_conf, ctx)
                         '", error="invalid_token", error_description="' .. err 
.. '"'
                 return ngx.HTTP_UNAUTHORIZED
             end
+
+            -- The session flow is authorized by the same required_scopes as 
the
+            -- token flow above. A session whose granted scopes cannot be read
+            -- is denied rather than allowed unchecked: the operator asked for
+            -- scope-based authorization, so silently skipping it would let any
+            -- authenticated user through.
+            if conf.required_scopes then
+                local http_scopes = session_scopes(response)
+                if not http_scopes then
+                    core.log.error("OIDC authentication failed: the scopes 
granted to ",
+                                   "the session are unknown, so required 
scopes ",
+                                   concat(conf.required_scopes, ", "), " 
cannot be checked")
+                    session:close()
+                    return 403, core.json.encode({ error = "required scopes 
not present" })
+                end
+                if not required_scopes_present(conf.required_scopes, 
http_scopes) then
+                    core.log.error("OIDC authentication failed: required 
scopes not present")
+                    session:close()
+                    return 403, core.json.encode({
+                        error = "required scopes " .. 
concat(conf.required_scopes, ", ") ..
+                                " not present"
+                    })
+                end
+            end
             -- If the openidc module has returned a response, it may contain,
             -- respectively, the access token, the ID token, the refresh token,
             -- and the userinfo.
diff --git a/docs/en/latest/plugins/openid-connect.md 
b/docs/en/latest/plugins/openid-connect.md
index e474083c43..dc3bbca211 100644
--- a/docs/en/latest/plugins/openid-connect.md
+++ b/docs/en/latest/plugins/openid-connect.md
@@ -46,7 +46,7 @@ The `openid-connect` Plugin supports the integration with 
[OpenID Connect (OIDC)
 | client_secret | string | True | | | OAuth client secret. |
 | discovery | string | True | | | URL to the well-known discovery document of 
the OpenID provider, which contains a list of OP API endpoints. The Plugin can 
directly utilize the endpoints from the discovery document. You can also 
configure these endpoints individually, which takes precedence over the 
endpoints supplied in the discovery document. |
 | 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). 
This is used to authorize users with proper permission. 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  [...]
-| required_scopes | array[string] | False | | | Scopes required to be present 
in the access token. Used in conjunction with the introspection endpoint when 
`bearer_only` is `true`. If any required scope is missing, the Plugin rejects 
the request with a 403 forbidden error. |
+| required_scopes | array[string] | False | | | Scopes required to be present 
in the access token. If any required scope is missing, the Plugin rejects the 
request with a 403 forbidden error. In the authorization code flow, the granted 
scopes are read from the `scope` claim of the access token, falling back to the 
claim of the ID token when the access token carries none; a session whose 
granted scopes cannot be determined either way is rejected. |
 | realm | string | False | apisix | | Realm in 
[`WWW-Authenticate`](https://www.rfc-editor.org/rfc/rfc6750#section-3) response 
header accompanying a 401 unauthorized request due to invalid bearer token. |
 | bearer_only | boolean | False | false | | If true, strictly require bearer 
access token in requests for authentication. |
 | logout_path | string | False | /logout | | Path to activate the logout. |
@@ -138,11 +138,11 @@ The `openid-connect` Plugin supports the integration with 
[OpenID Connect (OIDC)
 | introspection_expiry_claim | string | False | exp | | Name of the expiry 
claim, which controls the TTL of the cached and introspected access token. |
 | introspection_addon_headers | array[string] | False | | | Used to append 
additional header values to the introspection HTTP request. If the specified 
header does not exist in the origin request, the value will not be appended. |
 | claim_validator | object | False | | | JWT claim validation configurations. |
-| claim_validator.issuer.valid_issuers | array[string] | False | | | An array 
of trusted JWT issuers. If unconfigured, the issuer returned by the discovery 
endpoint will be used. If both are unavailable, the issuer will not be 
validated. |
+| claim_validator.issuer.valid_issuers | array[string] | False | | | An array 
of trusted JWT issuers. If unconfigured, the issuer returned by the discovery 
endpoint will be used, and a token is rejected while the discovery document 
cannot be fetched, since no trusted issuer is known then. |
 | claim_validator.audience | object | False | | | [Audience 
claim](https://openid.net/specs/openid-connect-core-1_0.html) validation 
configurations. |
 | claim_validator.audience.claim | string | False | aud | | Name of the claim 
that contains the audience. |
 | claim_validator.audience.required | boolean | False | false | | If true, 
audience claim is required and the name of the claim will be the name defined 
in `claim`. |
-| 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_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. A token without the 
audience claim is rejected too, as it cannot match the client ID. This 
requirement is stated in the OpenID Conn [...]
 | 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: The flat `lua-resty-openidc` option names that `par` and `dpop` own 
(`use_par`, `pushed_authorization_request_endpoint`, 
`pushed_authorization_request_endpoint_auth_method`, `use_dpop`, 
`dpop_signing_alg`, `dpop_private_key`, `dpop_public_jwk`) are rejected. 
Setting them directly would skip the validation and the `dpop.private_key` 
encryption the nested objects provide. Use the nested attributes instead.
diff --git a/docs/zh/latest/plugins/openid-connect.md 
b/docs/zh/latest/plugins/openid-connect.md
index 672ec191d6..1833069fc9 100644
--- a/docs/zh/latest/plugins/openid-connect.md
+++ b/docs/zh/latest/plugins/openid-connect.md
@@ -46,7 +46,7 @@ import TabItem from '@theme/TabItem';
 | client_secret | string | 是 | | | OAuth 客户端密钥。 |
 | discovery | string | 是 | | | OpenID 提供商的 well-known 发现文档 URL,包含 OP API 
端点列表。插件可直接使用发现文档中的端点。你也可以单独配置这些端点,单独配置的值优先于发现文档中提供的端点。 |
 | scope | string | 否 | openid | | 与认证用户相关信息对应的 OIDC 范围,也称为 
[claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims)。用于授权具有适当权限的用户。默认值为
 `openid`,这是 OIDC 返回唯一标识认证用户的 `sub` claim 所需的范围。可以附加额外的范围并以空格分隔,例如 `openid 
email profile`。 |
-| required_scopes | array[string] | 否 | | | 访问令牌中必须存在的范围。在 `bearer_only` 为 
`true` 时与 introspection 端点结合使用。如果缺少任何必需范围,插件将以 403 forbidden 错误拒绝请求。 |
+| required_scopes | array[string] | 否 | | | 访问令牌中必须存在的范围。如果缺少任何必需范围,插件将以 403 
forbidden 错误拒绝请求。在授权码流程中,已授予的范围取自访问令牌的 `scope` 声明;当访问令牌中没有该声明时,回退到 ID 
令牌的同名声明。两者都无法确定已授予范围的会话会被拒绝。 |
 | realm | string | 否 | apisix | | 由于无效 bearer token 导致 401 
未授权请求时,[`WWW-Authenticate`](https://www.rfc-editor.org/rfc/rfc6750#section-3) 
响应头中的 Realm 值。 |
 | bearer_only | boolean | 否 | false | | 如果为 true,则严格要求请求中携带 bearer 访问令牌进行身份验证。 
|
 | logout_path | string | 否 | /logout | | 触发注销的路径。 |
@@ -137,11 +137,11 @@ import TabItem from '@theme/TabItem';
 | introspection_expiry_claim | string | 否 | exp | | 过期 claim 
的名称,用于控制缓存和内省的访问令牌的 TTL。 |
 | introspection_addon_headers | array[string] | 否 | | | 用于向内省 HTTP 
请求追加额外头值。如果指定的头在原始请求中不存在,则不会追加该值。 |
 | claim_validator | object | 否 | | | JWT claim 验证配置。 |
-| claim_validator.issuer.valid_issuers | array[string] | 否 | | | 受信任的 JWT 
颁发者数组。如果未配置,将使用发现端点返回的颁发者。如果两者均不可用,则不验证颁发者。 |
+| claim_validator.issuer.valid_issuers | array[string] | 否 | | | 受信任的 JWT 
颁发者数组。如果未配置,将使用发现端点返回的颁发者;在发现文档无法获取期间令牌会被拒绝,因为此时没有已知的受信任颁发者。 |
 | claim_validator.audience | object | 否 | | | [受众 
claim](https://openid.net/specs/openid-connect-core-1_0.html) 验证配置。 |
 | claim_validator.audience.claim | string | 否 | aud | | 包含受众的 claim 名称。 |
 | claim_validator.audience.required | boolean | 否 | false | | 如果为 true,则受众 
claim 为必填项,claim 名称为 `claim` 中定义的名称。 |
-| claim_validator.audience.match_with_client_id | boolean | 否 | false | | 如果为 
true,则要求受众与客户端 ID 匹配。如果受众是字符串,则必须与客户端 ID 完全匹配。如果受众是字符串数组,则至少一个值必须与客户端 ID 
匹配。如果未找到匹配,将收到 `mismatched audience` 错误。OpenID Connect 
规范规定了此要求,以确保令牌是为特定客户端颁发的。 |
+| claim_validator.audience.match_with_client_id | boolean | 否 | false | | 如果为 
true,则要求受众与客户端 ID 匹配。如果受众是字符串,则必须与客户端 ID 完全匹配。如果受众是字符串数组,则至少一个值必须与客户端 ID 
匹配。如果未找到匹配,将收到 `mismatched audience` 错误。不含受众声明的令牌同样会被拒绝,因为它无法与客户端 ID 匹配。OpenID 
Connect 规范规定了此要求,以确保令牌是为特定客户端颁发的。 |
 | claim_schema | object | 否 | | | OIDC 响应 claim 的 JSON 
schema。示例:`{"type":"object","properties":{"access_token":{"type":"string"}},"required":["access_token"]}`
 - 验证响应包含必填的字符串字段 `access_token`。 |
 
 注意:`par` 和 `dpop` 所对应的 `lua-resty-openidc` 
扁平选项名(`use_par`、`pushed_authorization_request_endpoint`、`pushed_authorization_request_endpoint_auth_method`、`use_dpop`、`dpop_signing_alg`、`dpop_private_key`、`dpop_public_jwk`)会被拒绝。直接设置它们会绕过嵌套对象提供的校验以及
 `dpop.private_key` 的加密存储,请改用嵌套属性。
diff --git a/t/plugin/openid-connect-claim-validation.t 
b/t/plugin/openid-connect-claim-validation.t
new file mode 100644
index 0000000000..23c930cf91
--- /dev/null
+++ b/t/plugin/openid-connect-claim-validation.t
@@ -0,0 +1,202 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!defined $block->request) {
+        $block->set_value("request", "GET /t");
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set up a route validating tokens locally, audience matched with 
the client id
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local json = require("toolkit.json")
+            local f = assert(io.open("t/certs/public.pem"))
+            local public_key = f:read("*a")
+            f:close()
+
+            local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, 
json.encode({
+                uri = "/hello",
+                plugins = {
+                    ["openid-connect"] = {
+                        client_id = "apisix",
+                        client_secret = "secret",
+                        -- never fetched: valid_issuers is configured 
explicitly
+                        discovery = 
"http://127.0.0.1:1980/discovery-unavailable";,
+                        bearer_only = true,
+                        public_key = public_key,
+                        token_signing_alg_values_expected = "RS256",
+                        claim_validator = {
+                            issuer = { valid_issuers = 
{"https://example.com/issuer"} },
+                            audience = { match_with_client_id = true },
+                        },
+                    },
+                },
+                upstream = {
+                    type = "roundrobin",
+                    nodes = { ["127.0.0.1:1980"] = 1 },
+                },
+            }))
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: a token without the audience claim does not satisfy 
match_with_client_id
+--- config
+    location /t {
+        content_by_lua_block {
+            local jwt = require("resty.jwt")
+            local http = require("resty.http")
+            local f = assert(io.open("t/certs/private.pem"))
+            local private_key = f:read("*a")
+            f:close()
+
+            local function token(aud)
+                return jwt:sign(private_key, {
+                    header = { typ = "JWT", alg = "RS256" },
+                    payload = {
+                        iss = "https://example.com/issuer";,
+                        sub = "jack",
+                        aud = aud,
+                        exp = ngx.time() + 3600,
+                        iat = ngx.time(),
+                    },
+                })
+            end
+
+            for _, case in ipairs({
+                { name = "no audience", tok = token(nil) },
+                { name = "other audience", tok = token("another-client") },
+                { name = "client id as audience", tok = token("apisix") },
+            }) do
+                local httpc = http.new()
+                local res = httpc:request_uri("http://127.0.0.1:"; .. 
ngx.var.server_port .. "/hello", {
+                    method = "GET",
+                    headers = { Authorization = "Bearer " .. case.tok },
+                })
+                ngx.say(case.name, ": ", res and res.status or "request 
failed")
+            end
+        }
+    }
+--- response_body
+no audience: 403
+other audience: 403
+client id as audience: 200
+--- error_log
+required audience (aud) not present
+audience does not match the client id
+
+
+
+=== TEST 3: set up a route deriving the trusted issuer from an unreachable 
discovery document
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local json = require("toolkit.json")
+            local f = assert(io.open("t/certs/public.pem"))
+            local public_key = f:read("*a")
+            f:close()
+
+            local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, 
json.encode({
+                uri = "/hello",
+                plugins = {
+                    ["openid-connect"] = {
+                        client_id = "apisix",
+                        client_secret = "secret",
+                        discovery = 
"http://127.0.0.1:1980/discovery-unavailable";,
+                        bearer_only = true,
+                        public_key = public_key,
+                        token_signing_alg_values_expected = "RS256",
+                    },
+                },
+                upstream = {
+                    type = "roundrobin",
+                    nodes = { ["127.0.0.1:1980"] = 1 },
+                },
+            }))
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 4: a token is rejected while the trusted issuer cannot be determined
+--- config
+    location /t {
+        content_by_lua_block {
+            local jwt = require("resty.jwt")
+            local http = require("resty.http")
+            local f = assert(io.open("t/certs/private.pem"))
+            local private_key = f:read("*a")
+            f:close()
+
+            local tok = jwt:sign(private_key, {
+                header = { typ = "JWT", alg = "RS256" },
+                payload = {
+                    iss = "https://attacker.example.com";,
+                    sub = "jack",
+                    exp = ngx.time() + 3600,
+                    iat = ngx.time(),
+                },
+            })
+
+            local httpc = http.new()
+            local res, req_err = httpc:request_uri("http://127.0.0.1:"; .. 
ngx.var.server_port .. "/hello", {
+                method = "GET",
+                headers = { Authorization = "Bearer " .. tok },
+            })
+            if not res then
+                ngx.say("request failed: ", req_err)
+                return
+            end
+            ngx.say(res.status)
+            ngx.say(res.headers["WWW-Authenticate"])
+        }
+    }
+--- response_body_like
+^401
+Bearer realm="apisix", error="invalid_token", error_description="issuer 
validation unavailable"$
+--- error_log
+OIDC access discovery url failed
diff --git a/t/plugin/openid-connect-required-scopes.t 
b/t/plugin/openid-connect-required-scopes.t
new file mode 100644
index 0000000000..65aa822b4b
--- /dev/null
+++ b/t/plugin/openid-connect-required-scopes.t
@@ -0,0 +1,252 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!defined $block->request) {
+        $block->set_value("request", "GET /t");
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set up a route protected by a session, requiring a scope the ID 
provider grants
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+                "uri": "/*",
+                "plugins": {
+                    "openid-connect": {
+                        "client_id": "apisix",
+                        "client_secret": "secret",
+                        "discovery": 
"http://127.0.0.1:8080/realms/basic/.well-known/openid-configuration";,
+                        "redirect_uri": "http://127.0.0.1:1984/authenticated";,
+                        "ssl_verify": false,
+                        "session": { "secret": 
"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                        "required_scopes": ["profile"]
+                    }
+                },
+                "upstream": {
+                    "type": "roundrobin",
+                    "nodes": { "127.0.0.1:1980": 1 }
+                }
+            }]])
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: log in and reach the upstream, the session carries the required 
scope
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local login_keycloak = require("lib.keycloak").login_keycloak
+            local concatenate_cookies = 
require("lib.keycloak").concatenate_cookies
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/uri"
+            local res, err = login_keycloak(uri, "jack", "jack")
+            if not res then
+                ngx.say(err)
+                return
+            end
+
+            local location = res.headers['Location']
+            if location:sub(1, 1) == "/" then
+                location = "http://127.0.0.1:"; .. ngx.var.server_port .. 
location
+            end
+
+            local httpc = http.new()
+            res, err = httpc:request_uri(location, {
+                method = "GET",
+                headers = { ["Cookie"] = 
concatenate_cookies(res.headers['Set-Cookie']) },
+            })
+            if not res then
+                ngx.say(err)
+                return
+            end
+            ngx.say(res.status)
+        }
+    }
+--- response_body
+200
+
+
+
+=== TEST 3: require a scope the ID provider does not grant
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+                "uri": "/*",
+                "plugins": {
+                    "openid-connect": {
+                        "client_id": "apisix",
+                        "client_secret": "secret",
+                        "discovery": 
"http://127.0.0.1:8080/realms/basic/.well-known/openid-configuration";,
+                        "redirect_uri": "http://127.0.0.1:1984/authenticated";,
+                        "ssl_verify": false,
+                        "session": { "secret": 
"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                        "required_scopes": ["super-admin"]
+                    }
+                },
+                "upstream": {
+                    "type": "roundrobin",
+                    "nodes": { "127.0.0.1:1980": 1 }
+                }
+            }]])
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 4: a session missing the required scope is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local login_keycloak = require("lib.keycloak").login_keycloak
+            local concatenate_cookies = 
require("lib.keycloak").concatenate_cookies
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/uri"
+            local res, err = login_keycloak(uri, "jack", "jack")
+            if not res then
+                ngx.say(err)
+                return
+            end
+
+            local location = res.headers['Location']
+            if location:sub(1, 1) == "/" then
+                location = "http://127.0.0.1:"; .. ngx.var.server_port .. 
location
+            end
+
+            local httpc = http.new()
+            res, err = httpc:request_uri(location, {
+                method = "GET",
+                headers = { ["Cookie"] = 
concatenate_cookies(res.headers['Set-Cookie']) },
+            })
+            if not res then
+                ngx.say(err)
+                return
+            end
+            ngx.say(res.status)
+            ngx.say(res.body)
+        }
+    }
+--- response_body
+403
+{"error":"required scopes super-admin not present"}
+--- error_log
+required scopes not present
+
+
+
+=== TEST 5: restricting session_contents does not drop the scope check
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+                "uri": "/*",
+                "plugins": {
+                    "openid-connect": {
+                        "client_id": "apisix",
+                        "client_secret": "secret",
+                        "discovery": 
"http://127.0.0.1:8080/realms/basic/.well-known/openid-configuration";,
+                        "redirect_uri": "http://127.0.0.1:1984/authenticated";,
+                        "ssl_verify": false,
+                        "session": { "secret": 
"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                        "session_contents": { "id_token": true },
+                        "required_scopes": ["profile"]
+                    }
+                },
+                "upstream": {
+                    "type": "roundrobin",
+                    "nodes": { "127.0.0.1:1980": 1 }
+                }
+            }]])
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 6: the granted scope is still read from the session
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local login_keycloak = require("lib.keycloak").login_keycloak
+            local concatenate_cookies = 
require("lib.keycloak").concatenate_cookies
+
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port .. "/uri"
+            local res, err = login_keycloak(uri, "jack", "jack")
+            if not res then
+                ngx.say(err)
+                return
+            end
+
+            local location = res.headers['Location']
+            if location:sub(1, 1) == "/" then
+                location = "http://127.0.0.1:"; .. ngx.var.server_port .. 
location
+            end
+
+            local httpc = http.new()
+            res, err = httpc:request_uri(location, {
+                method = "GET",
+                headers = { ["Cookie"] = 
concatenate_cookies(res.headers['Set-Cookie']) },
+            })
+            if not res then
+                ngx.say(err)
+                return
+            end
+            ngx.say(res.status)
+        }
+    }
+--- response_body
+200

Reply via email to