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