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

shreemaan-abhishek 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 b6deb41da7 feat(openid-connect): map verified claims to upstream 
request headers (#14030)
b6deb41da7 is described below

commit b6deb41da7f8237c4fb02dd24171a4f0e389ed0b
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Tue Oct 27 20:26:41 2026 +0800

    feat(openid-connect): map verified claims to upstream request headers 
(#14030)
---
 apisix/plugins/openid-connect.lua          | 122 ++++++-
 docs/en/latest/plugins/openid-connect.md   |  35 ++
 docs/zh/latest/plugins/openid-connect.md   |  35 ++
 t/plugin/openid-connect-upstream-headers.t | 539 +++++++++++++++++++++++++++++
 4 files changed, 729 insertions(+), 2 deletions(-)

diff --git a/apisix/plugins/openid-connect.lua 
b/apisix/plugins/openid-connect.lua
index e195b6244f..c5cf6a6792 100644
--- a/apisix/plugins/openid-connect.lua
+++ b/apisix/plugins/openid-connect.lua
@@ -26,6 +26,7 @@ local dump_jwk          = 
require("resty.openssl.auxiliary.jwk").dump_jwk
 local string            = string
 local ngx               = ngx
 local ipairs            = ipairs
+local next              = next
 local type              = type
 local tostring          = tostring
 local pcall             = pcall
@@ -638,7 +639,32 @@ local schema = {
             description = "JSON schema of OIDC response claim",
             type = "object",
             default = nil,
-        }
+        },
+        upstream_headers = {
+            description = "Verified claims to forward to the upstream as 
request headers.",
+            type = "array",
+            minItems = 1,
+            items = {
+                type = "object",
+                properties = {
+                    header = {
+                        description = "Name of the upstream request header.",
+                        type = "string",
+                        pattern = [[^[!#$%&'*+.^_`|~0-9A-Za-z-]+$]],
+                    },
+                    path = {
+                        description = "Keys leading to the claim, one per 
nesting level.",
+                        type = "array",
+                        minItems = 1,
+                        items = {
+                            type = "string",
+                            minLength = 1,
+                        },
+                    },
+                },
+                required = {"header", "path"},
+            },
+        },
     },
     encrypt_fields = {"client_secret", "client_rsa_private_key", 
"dpop.private_key",
                       "session.secret", "session.redis.password"},
@@ -1159,6 +1185,68 @@ local function session_scopes(response)
     return split_scopes_by_space(scope)
 end
 
+
+local function lookup_claim(claims, path)
+    local value = claims
+    for _, key in ipairs(path) do
+        if type(value) ~= "table" then
+            return nil
+        end
+        value = value[key]
+    end
+    return value
+end
+
+
+-- Strings as is, numbers and true as text, tables as base64 of their
+-- JSON. Missing, null and false yield nil.
+local function serialize_claim(value)
+    local value_type = type(value)
+    if value_type == "string" then
+        return value
+    end
+
+    if value_type == "number" then
+        -- tostring() switches to exponent notation for large integers
+        if value % 1 == 0 and value >= -2^53 and value <= 2^53 then
+            return string.format("%d", value)
+        end
+        return tostring(value)
+    end
+
+    if value == true then
+        return "true"
+    end
+
+    if value_type == "table" then
+        -- an empty array goes out as {} as well
+        if next(value) == nil then
+            return ngx_encode_base64("{}")
+        end
+        return ngx_encode_base64(core.json.encode(value))
+    end
+
+    return nil
+end
+
+
+-- Each header gets the value from the first claim source holding the claim.
+local function upstream_header_values(upstream_headers, sources)
+    local values = core.table.new(0, #upstream_headers)
+    for _, mapping in ipairs(upstream_headers) do
+        for _, claims in ipairs(sources) do
+            local value = serialize_claim(lookup_claim(claims, mapping.path))
+            if value then
+                values[mapping.header] = value
+                break
+            end
+        end
+    end
+    return values
+end
+_M._upstream_header_values = upstream_header_values
+
+
 local function validate_claims_in_oidcauth_response(resp, conf)
     if not conf.claim_schema then
         return true
@@ -1172,7 +1260,7 @@ local function validate_claims_in_oidcauth_response(resp, 
conf)
 end
 
 
-function _M.rewrite(plugin_conf, ctx)
+local function authenticate(plugin_conf, ctx)
     local conf = core.table.clone(plugin_conf)
     flatten_openidc_options(conf)
 
@@ -1317,6 +1405,10 @@ function _M.rewrite(plugin_conf, ctx)
             -- Add configured access token header, maybe.
             add_access_token_header(ctx, conf, access_token)
 
+            if conf.upstream_headers then
+                ctx.openid_connect_claim_sources = {userinfo}
+            end
+
             if userinfo and conf.set_userinfo_header then
                 -- Set X-Userinfo header to introspection endpoint response.
                 core.request.set_header(ctx, "X-Userinfo",
@@ -1511,6 +1603,14 @@ function _M.rewrite(plugin_conf, ctx)
                 core.request.set_header(ctx, "X-Raw-ID-Token", enc_id_token)
             end
 
+            if conf.upstream_headers then
+                -- the ID token is verified, userinfo fills in what it lacks
+                ctx.openid_connect_claim_sources = {
+                    response.id_token or {},
+                    response.user or {},
+                }
+            end
+
             -- a successful authentication resets the restart budget
             if session:get("auth_flow_restarts") then
                 session:set("auth_flow_restarts", nil)
@@ -1527,4 +1627,22 @@ function _M.rewrite(plugin_conf, ctx)
 end
 
 
+function _M.rewrite(plugin_conf, ctx)
+    local code, body = authenticate(plugin_conf, ctx)
+
+    -- runs on every outcome so spoofed values never reach the upstream
+    local upstream_headers = plugin_conf.upstream_headers
+    if upstream_headers then
+        local values = upstream_header_values(upstream_headers,
+                                              ctx.openid_connect_claim_sources 
or {})
+        for _, mapping in ipairs(upstream_headers) do
+            core.request.set_header(ctx, mapping.header, 
values[mapping.header])
+        end
+    end
+
+    return code, body
+end
+
+
+
 return _M
diff --git a/docs/en/latest/plugins/openid-connect.md 
b/docs/en/latest/plugins/openid-connect.md
index bec39be4bc..798b541844 100644
--- a/docs/en/latest/plugins/openid-connect.md
+++ b/docs/en/latest/plugins/openid-connect.md
@@ -76,6 +76,9 @@ The `openid-connect` Plugin supports the integration with 
[OpenID Connect (OIDC)
 | set_raw_id_token_header | boolean | False | false | | If true and if the raw 
signed ID token JWT is available, set the value in the `X-Raw-ID-Token` request 
header. Unlike `X-ID-Token`, this header contains the original RS256-signed JWT 
from the identity provider and can be verified against the provider's JWKS 
endpoint. The plugin automatically persists the raw JWT in the session when 
this option is enabled. |
 | set_userinfo_header | boolean | False | true | | If true and if user info 
data is available, set the value in the `X-Userinfo` request header. |
 | set_refresh_token_header | boolean | False | false | | If true and if the 
refresh token is available, set the value in the `X-Refresh-Token` request 
header. |
+| upstream_headers | array[object] | False | | | Claims to forward to the 
Upstream as request headers. The Plugin clears every declared header first, 
then sets it only from a verified claim, so a client cannot supply its own 
value, including on requests let through by `unauth_action` set to `pass`. With 
`bearer_only`, `public_key`, `use_jwks`, or introspection, claims come from the 
verified access token payload or the introspection response. In the 
authorization code flow, claims come fr [...]
+| upstream_headers.header | string | True | | | Name of the Upstream request 
header. |
+| upstream_headers.path | array[string] | True | | | Keys leading to the 
claim, one key per nesting level, such as `["org", "id"]`. A key is matched 
literally, so a claim name containing `://`, `/`, or `.` is a single key. 
Strings are forwarded as is, numbers and `true` as text, and arrays and 
objects, including empty ones, as base64-encoded JSON. If the claim is missing, 
`null`, `false`, or an empty string, the header is not set. |
 | session | object | False | | | Session configuration used when `bearer_only` 
is `false` and the Plugin uses Authorization Code flow. |
 | session.secret | string | True | | 16 or more characters | Key used for 
session encryption and HMAC operation when `bearer_only` is `false`. |
 | session.cookie_name | string | False | | | Session cookie name. Forwarded to 
[lua-resty-session](https://github.com/bungle/lua-resty-session#configuration) 
4.x as `cookie_name`. |
@@ -460,6 +463,38 @@ The following diagram illustrates the interaction between 
different entities whe
 
 When `set_userinfo_header` is `true` (the default), the Plugin sets user info 
data in the `X-Userinfo` request header, which the Upstream can use for further 
processing.
 
+### Forward Claims to the Upstream
+
+The following example forwards selected claims of a verified bearer token to 
the Upstream as request headers, so that the Upstream does not need to parse 
the token.
+
+Configure `upstream_headers` in the Plugin, where each entry names a header 
and the `path` of keys leading to the claim:
+
+```json
+"openid-connect": {
+  "client_id": "api",
+  "discovery": "https://idp.example.com/.well-known/openid-configuration";,
+  "bearer_only": true,
+  "use_jwks": true,
+  "upstream_headers": [
+    { "header": "X-Profile-Id", "path": 
["https://example.com/claims/profile/id";] },
+    { "header": "X-Org-Id", "path": ["org", "id"] },
+    { "header": "X-Client-Scopes", "path": ["scope"] }
+  ]
+}
+```
+
+For a token with the payload `{"https://example.com/claims/profile/id": 
"profile-1", "org": {"id": 42}, "scope": ["read", "write"]}`, the Upstream 
receives the following headers:
+
+```text
+X-Profile-Id: profile-1
+X-Org-Id: 42
+X-Client-Scopes: WyJyZWFkIiwid3JpdGUiXQ==
+```
+
+The `scope` array is forwarded as base64-encoded JSON, which decodes to 
`["read","write"]`.
+
+If a claim is not in the token, the Upstream does not receive the header, even 
when the client sent one.
+
 ## Troubleshooting
 
 This section covers a few commonly seen issues when working with this Plugin 
to help you troubleshoot.
diff --git a/docs/zh/latest/plugins/openid-connect.md 
b/docs/zh/latest/plugins/openid-connect.md
index 1833069fc9..ee77105e2b 100644
--- a/docs/zh/latest/plugins/openid-connect.md
+++ b/docs/zh/latest/plugins/openid-connect.md
@@ -75,6 +75,9 @@ import TabItem from '@theme/TabItem';
 | set_id_token_header | boolean | 否 | true | | 如果为 true 并且 ID 令牌可用,则在 
`X-ID-Token` 请求标头中设置值。 |
 | set_userinfo_header | boolean | 否 | true | | 如果为 true 并且用户信息数据可用,则在 
`X-Userinfo` 请求标头中设置值。 |
 | set_refresh_token_header | boolean | 否 | false | | 如果为 true 并且刷新令牌可用,则在 
`X-Refresh-Token` 请求标头中设置值。 |
+| upstream_headers | array[object] | 否 | | | 以请求头形式转发给上游的 
claim。插件会先清除所有声明的请求头,再只用已校验的 claim 设置,因此客户端无法自行提供这些值,包括 `unauth_action` 为 
`pass` 时放行的请求。使用 `bearer_only`、`public_key`、`use_jwks` 或令牌自省时,claim 
来自已校验的访问令牌负载或自省响应。授权码流程中,claim 先从 ID 令牌读取,ID 令牌中没有的再从用户信息读取。值为 `null` 或 `false` 
的 claim 视为不存在,会继续从下一个来源读取。 |
+| upstream_headers.header | string | 是 | | | 上游请求头名称。 |
+| upstream_headers.path | array[string] | 是 | | | 定位 claim 的键,每层嵌套一个键,例如 
`["org", "id"]`。键按字面匹配,因此包含 `://`、`/` 或 `.` 的 claim 名称是一个键。字符串原样转发,数字和 `true` 
转为文本,数组和对象(包括空数组和空对象)以 base64 编码的 JSON 转发。claim 不存在、为 `null`、`false` 
或空字符串时不设置该请求头。 |
 | session | object | 否 | | | 当 `bearer_only` 为 `false` 且插件使用 Authorization 
Code 流程时使用的 Session 配置。 |
 | session.secret | string | 是 | | 16 个字符以上 | 当 `bearer_only` 为 `false` 时,用于 
session 加密和 HMAC 运算的密钥。|
 | session.cookie_name | string | 否 | | | 会话 Cookie 名称。作为 `cookie_name` 透传到 
[lua-resty-session](https://github.com/bungle/lua-resty-session#configuration) 
4.x。 |
@@ -459,6 +462,38 @@ OpenID Connect (OIDC) 中的 UserInfo 端点在 [OpenID Connect Core 
1.0 第 5.3
 
 当 `set_userinfo_header` 为 `true`(默认值)时,插件在 `X-Userinfo` 
请求头中设置用户信息数据,上游服务可使用该数据进行进一步处理。
 
+### 将 claim 转发给上游
+
+以下示例将已校验 bearer 令牌中的部分 claim 以请求头形式转发给上游,上游无需解析令牌。
+
+在插件中配置 `upstream_headers`,每一项指定请求头名称和定位 claim 的键路径 `path`:
+
+```json
+"openid-connect": {
+  "client_id": "api",
+  "discovery": "https://idp.example.com/.well-known/openid-configuration";,
+  "bearer_only": true,
+  "use_jwks": true,
+  "upstream_headers": [
+    { "header": "X-Profile-Id", "path": 
["https://example.com/claims/profile/id";] },
+    { "header": "X-Org-Id", "path": ["org", "id"] },
+    { "header": "X-Client-Scopes", "path": ["scope"] }
+  ]
+}
+```
+
+对于负载为 `{"https://example.com/claims/profile/id": "profile-1", "org": {"id": 
42}, "scope": ["read", "write"]}` 的令牌,上游收到以下请求头:
+
+```text
+X-Profile-Id: profile-1
+X-Org-Id: 42
+X-Client-Scopes: WyJyZWFkIiwid3JpdGUiXQ==
+```
+
+数组 `scope` 以 base64 编码的 JSON 转发,解码后为 `["read","write"]`。
+
+令牌中不存在的 claim 不会产生对应请求头,即使客户端自带该请求头。
+
 ## 故障排除
 
 本节涵盖使用此插件时常见的一些问题,以帮助你进行故障排查。
diff --git a/t/plugin/openid-connect-upstream-headers.t 
b/t/plugin/openid-connect-upstream-headers.t
new file mode 100644
index 0000000000..07c7706bfb
--- /dev/null
+++ b/t/plugin/openid-connect-upstream-headers.t
@@ -0,0 +1,539 @@
+#
+# 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->error_log) && (!defined $block->no_error_log)) {
+        $block->set_value("no_error_log", "[error]");
+    }
+
+    if (!defined $block->request) {
+        $block->set_value("request", "GET /t");
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: schema rejects malformed upstream_headers
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.openid-connect")
+            local function conf(upstream_headers)
+                return {
+                    client_id = "apisix",
+                    discovery = "http://127.0.0.1:1980/discovery";,
+                    bearer_only = true,
+                    use_jwks = true,
+                    upstream_headers = upstream_headers,
+                }
+            end
+
+            for _, case in ipairs({
+                { name = "valid", value = {{header = "X-Client-Id", path = 
{"azp"}}} },
+                { name = "empty list", value = {} },
+                { name = "missing path", value = {{header = "X-Client-Id"}} },
+                { name = "empty path", value = {{header = "X-Client-Id", path 
= {}}} },
+                { name = "empty key", value = {{header = "X-Client-Id", path = 
{""}}} },
+                { name = "missing header", value = {{path = {"azp"}}} },
+                { name = "bad header name", value = {{header = "X Client", 
path = {"azp"}}} },
+            }) do
+                local ok, err = plugin.check_schema(conf(case.value))
+                ngx.say(case.name, ": ", ok and "ok" or err)
+            end
+        }
+    }
+--- response_body
+valid: ok
+empty list: property "upstream_headers" validation failed: expect array to 
have at least 1 items
+missing path: property "upstream_headers" validation failed: failed to 
validate item 1: property "path" is required
+empty path: property "upstream_headers" validation failed: failed to validate 
item 1: property "path" validation failed: expect array to have at least 1 items
+empty key: property "upstream_headers" validation failed: failed to validate 
item 1: property "path" validation failed: failed to validate item 1: string 
too short, expected at least 1, got 0
+missing header: property "upstream_headers" validation failed: failed to 
validate item 1: property "header" is required
+bad header name: property "upstream_headers" validation failed: failed to 
validate item 1: property "header" validation failed: failed to match pattern 
"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" with "X Client"
+
+
+
+=== TEST 2: claim lookup and serialization
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local plugin = require("apisix.plugins.openid-connect")
+
+            local claims = core.json.decode([[{
+                "https://platform.example.com/claims/identity/profile/id": 
"p-1",
+                "a.b": "dotted",
+                "azp": "client-a",
+                "scope": "read write",
+                "scp": ["read", "write"],
+                "org": {"id": "42", "tags": {"tier": "gold"}},
+                "age": 30,
+                "big": 1700000000123,
+                "ratio": 0.5,
+                "admin": true,
+                "verified": false,
+                "roles": [{"name": "a"}],
+                "empty_array": [],
+                "empty_object": {},
+                "nothing": null
+            }]])
+
+            local values = plugin._upstream_header_values({
+                {header = "X-Profile-Id",
+                 path = 
{"https://platform.example.com/claims/identity/profile/id"}},
+                {header = "X-Dotted", path = {"a.b"}},
+                {header = "X-Client-Id", path = {"azp"}},
+                {header = "X-Scope", path = {"scope"}},
+                {header = "X-Scp", path = {"scp"}},
+                {header = "X-Org-Id", path = {"org", "id"}},
+                {header = "X-Org-Tags", path = {"org", "tags"}},
+                {header = "X-Age", path = {"age"}},
+                {header = "X-Big", path = {"big"}},
+                {header = "X-Ratio", path = {"ratio"}},
+                {header = "X-Admin", path = {"admin"}},
+                {header = "X-Verified", path = {"verified"}},
+                {header = "X-Roles", path = {"roles"}},
+                {header = "X-Empty-Array", path = {"empty_array"}},
+                {header = "X-Empty-Object", path = {"empty_object"}},
+                {header = "X-Null", path = {"nothing"}},
+                {header = "X-Missing", path = {"missing"}},
+                {header = "X-Too-Deep", path = {"azp", "x"}},
+            }, {claims})
+
+            local keys = {}
+            for k in pairs(values) do
+                table.insert(keys, k)
+            end
+            table.sort(keys)
+            for _, k in ipairs(keys) do
+                ngx.say(k, ": ", values[k])
+            end
+        }
+    }
+--- response_body
+X-Admin: true
+X-Age: 30
+X-Big: 1700000000123
+X-Client-Id: client-a
+X-Dotted: dotted
+X-Empty-Array: e30=
+X-Empty-Object: e30=
+X-Org-Id: 42
+X-Org-Tags: eyJ0aWVyIjoiZ29sZCJ9
+X-Profile-Id: p-1
+X-Ratio: 0.5
+X-Roles: W3sibmFtZSI6ImEifV0=
+X-Scope: read write
+X-Scp: WyJyZWFkIiwid3JpdGUiXQ==
+
+
+
+=== TEST 3: the first claim source holding the claim wins, null and false 
count as missing
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local plugin = require("apisix.plugins.openid-connect")
+            local id_token = core.json.decode([[{
+                "email": "[email protected]",
+                "admin": false,
+                "role": null,
+                "verified": false
+            }]])
+            local userinfo = core.json.decode([[{
+                "email": "[email protected]",
+                "phone": "123",
+                "admin": true,
+                "role": "editor",
+                "verified": false
+            }]])
+
+            local values = plugin._upstream_header_values({
+                {header = "X-Email", path = {"email"}},
+                {header = "X-Phone", path = {"phone"}},
+                {header = "X-Missing", path = {"missing"}},
+                {header = "X-Admin", path = {"admin"}},
+                {header = "X-Role", path = {"role"}},
+                {header = "X-Verified", path = {"verified"}},
+            }, {id_token, userinfo})
+
+            ngx.say("email: ", values["X-Email"])
+            ngx.say("phone: ", values["X-Phone"])
+            ngx.say("missing: ", values["X-Missing"])
+            ngx.say("admin: ", values["X-Admin"])
+            ngx.say("role: ", values["X-Role"])
+            ngx.say("verified: ", values["X-Verified"])
+        }
+    }
+--- response_body
+email: [email protected]
+phone: 123
+missing: nil
+admin: true
+role: editor
+verified: nil
+
+
+
+=== TEST 4: set up a bearer_only route mapping claims to upstream headers
+--- 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 ns = "https://platform.example.com/claims/identity/profile/";
+            local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, 
json.encode({
+                uri = "/uri",
+                plugins = {
+                    ["openid-connect"] = {
+                        client_id = "apisix",
+                        -- 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",
+                        unauth_action = "deny",
+                        set_access_token_header = false,
+                        set_userinfo_header = false,
+                        claim_validator = {
+                            issuer = { valid_issuers = 
{"https://example.com/issuer"} },
+                        },
+                        upstream_headers = {
+                            {header = "X-Claim-Profile-Id", path = {ns .. 
"id"}},
+                            {header = "X-Claim-Email", path = {ns .. "email"}},
+                            {header = "X-Claim-Session-Id", path = {ns .. 
"session/id"}},
+                            {header = "X-Claim-User-Type", path = {ns .. 
"user-type"}},
+                            {header = "X-Claim-Client-Id", path = {"azp"}},
+                            {header = "X-Claim-Client-Scopes", path = 
{"scope"}},
+                            {header = "X-Claim-Org-Id", path = {"org", "id"}},
+                        },
+                    },
+                },
+                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 5: verified claims reach the upstream, spoofed headers never do
+--- 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 ns = "https://platform.example.com/claims/identity/profile/";
+            local function token(extra)
+                local payload = {
+                    iss = "https://example.com/issuer";,
+                    sub = "jack",
+                    exp = ngx.time() + 3600,
+                    iat = ngx.time(),
+                    azp = "client-a",
+                    scope = "read write",
+                    [ns .. "id"] = "profile-1",
+                    [ns .. "email"] = "[email protected]",
+                }
+                for k, v in pairs(extra) do
+                    payload[k] = v
+                end
+                return jwt:sign(private_key, {
+                    header = { typ = "JWT", alg = "RS256" },
+                    payload = payload,
+                })
+            end
+
+            local spoofed = {
+                ["X-Claim-Profile-Id"] = "spoofed",
+                ["X-Claim-Email"] = "spoofed",
+                ["X-Claim-Session-Id"] = "spoofed",
+                ["X-Claim-User-Type"] = "spoofed",
+                ["X-Claim-Client-Id"] = "spoofed",
+                ["X-Claim-Client-Scopes"] = "spoofed",
+                ["X-Claim-Org-Id"] = "spoofed",
+            }
+
+            for _, case in ipairs({
+                { name = "all claims", tok = token({
+                    [ns .. "session/id"] = "session-1",
+                    [ns .. "user-type"] = "internal",
+                    org = { id = 42 },
+                }) },
+                { name = "missing claims", tok = token({}) },
+                { name = "no token" },
+                { name = "invalid token", tok = "invalid" },
+            }) do
+                local headers = {}
+                for k, v in pairs(spoofed) do
+                    headers[k] = v
+                end
+                if case.tok then
+                    headers["Authorization"] = "Bearer " .. case.tok
+                end
+
+                local httpc = http.new()
+                local res = httpc:request_uri("http://127.0.0.1:"; .. 
ngx.var.server_port .. "/uri", {
+                    method = "GET",
+                    headers = headers,
+                })
+                ngx.say(case.name, ": ", res.status)
+                if res.status == 200 then
+                    for line in res.body:gmatch("[^\n]+") do
+                        if line:find("^x%-claim%-") then
+                            ngx.say("  ", line)
+                        end
+                    end
+                end
+            end
+        }
+    }
+--- response_body
+all claims: 200
+  x-claim-client-id: client-a
+  x-claim-client-scopes: read write
+  x-claim-email: [email protected]
+  x-claim-org-id: 42
+  x-claim-profile-id: profile-1
+  x-claim-session-id: session-1
+  x-claim-user-type: internal
+missing claims: 200
+  x-claim-client-id: client-a
+  x-claim-client-scopes: read write
+  x-claim-email: [email protected]
+  x-claim-profile-id: profile-1
+no token: 401
+invalid token: 401
+--- error_log
+No bearer token found in request
+
+
+
+=== TEST 6: set up a route letting unauthenticated requests pass
+--- 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 = "/uri",
+                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";,
+                        public_key = public_key,
+                        token_signing_alg_values_expected = "RS256",
+                        unauth_action = "pass",
+                        set_access_token_header = false,
+                        set_userinfo_header = false,
+                        session = { secret = 
"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" },
+                        claim_validator = {
+                            issuer = { valid_issuers = 
{"https://example.com/issuer"} },
+                        },
+                        upstream_headers = {
+                            {header = "X-Claim-Client-Id", path = {"azp"}},
+                            {header = "X-Claim-Client-Scopes", path = 
{"scope"}},
+                        },
+                    },
+                },
+                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 7: passed-through requests carry no declared header, authenticated 
ones do
+--- 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://example.com/issuer";,
+                    sub = "jack",
+                    exp = ngx.time() + 3600,
+                    iat = ngx.time(),
+                    azp = "client-a",
+                    scope = {"read", "write"},
+                },
+            })
+
+            for _, case in ipairs({
+                { name = "anonymous" },
+                { name = "authenticated", tok = tok },
+            }) do
+                local headers = {
+                    ["X-Claim-Client-Id"] = "spoofed",
+                    ["X-Claim-Client-Scopes"] = "spoofed",
+                }
+                if case.tok then
+                    headers["Authorization"] = "Bearer " .. case.tok
+                end
+
+                local httpc = http.new()
+                local res = httpc:request_uri("http://127.0.0.1:"; .. 
ngx.var.server_port .. "/uri", {
+                    method = "GET",
+                    headers = headers,
+                })
+                ngx.say(case.name, ": ", res.status)
+                for line in res.body:gmatch("[^\n]+") do
+                    if line:find("^x%-claim%-") then
+                        ngx.say("  ", line)
+                    end
+                end
+            end
+        }
+    }
+--- response_body
+anonymous: 200
+authenticated: 200
+  x-claim-client-id: client-a
+  x-claim-client-scopes: WyJyZWFkIiwid3JpdGUiXQ==
+
+
+
+=== TEST 8: set up a session route mapping ID token claims to upstream headers
+--- 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" },
+                        "upstream_headers": [
+                            { "header": "X-Oidc-User", "path": 
["preferred_username"] },
+                            { "header": "X-Oidc-Missing", "path": 
["no_such_claim"] }
+                        ]
+                    }
+                },
+                "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 9: a logged-in session forwards its claims and drops spoofed headers
+--- 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']),
+                    ["X-Oidc-User"] = "spoofed",
+                    ["X-Oidc-Missing"] = "spoofed",
+                },
+            })
+            if not res then
+                ngx.say(err)
+                return
+            end
+            ngx.say(res.status)
+            for line in res.body:gmatch("[^\n]+") do
+                if line:find("^x%-oidc%-") then
+                    ngx.say(line)
+                end
+            end
+        }
+    }
+--- response_body
+200
+x-oidc-user: jack

Reply via email to