diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index e195b6244fdb..a745d7b00c13 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -23,9 +23,11 @@ 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 +local isarray = require("table.isarray") local string = string local ngx = ngx local ipairs = ipairs +local next = next local type = type local tostring = tostring local pcall = pcall @@ -638,7 +640,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 +1186,76 @@ 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 booleans as text, arrays of scalars joined +-- by a space, anything else as JSON. Missing, null or empty yields 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_type == "boolean" then + return tostring(value) + end + + if value_type ~= "table" or next(value) == nil then + return nil + end + + if isarray(value) then + local items = core.table.new(#value, 0) + for i, item in ipairs(value) do + local item_type = type(item) + if item_type ~= "string" and item_type ~= "number" and item_type ~= "boolean" then + return core.json.encode(value) + end + items[i] = serialize_claim(item) + end + return concat(items, " ") + end + + return core.json.encode(value) +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 +1269,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 +1414,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 +1612,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 +1636,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 bec39be4bc5f..b94ca840320d 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 from the ID token, then from the user info for claims the ID token does not contain. | +| 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 booleans as text, arrays of strings, numbers, or booleans joined with a space, and other arrays and objects as JSON. If the claim is missing, `null`, or empty, 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,36 @@ 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: 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 1833069fc95b..06ad5c559b6e 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 令牌中没有的再从用户信息读取。 | +| upstream_headers.header | string | 是 | | | 上游请求头名称。 | +| upstream_headers.path | array[string] | 是 | | | 定位 claim 的键,每层嵌套一个键,例如 `["org", "id"]`。键按字面匹配,因此包含 `://`、`/` 或 `.` 的 claim 名称是一个键。字符串原样转发,数字和布尔值转为文本,由字符串、数字或布尔值组成的数组以空格连接,其他数组和对象以 JSON 转发。claim 不存在、为 `null` 或为空时不设置该请求头。 | | 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,36 @@ 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: 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 000000000000..09f46417d6bd --- /dev/null +++ b/t/plugin/openid-connect-upstream-headers.t @@ -0,0 +1,517 @@ +# +# 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-Org-Id: 42 +X-Org-Tags: {"tier":"gold"} +X-Profile-Id: p-1 +X-Ratio: 0.5 +X-Roles: [{"name":"a"}] +X-Scope: read write +X-Scp: read write +X-Verified: false + + + +=== TEST 3: the first claim source holding the claim wins +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local id_token = {email = "old@example.com"} + local userinfo = {email = "new@example.com", phone = "123"} + + local values = plugin._upstream_header_values({ + {header = "X-Email", path = {"email"}}, + {header = "X-Phone", path = {"phone"}}, + {header = "X-Missing", path = {"missing"}}, + }, {id_token, userinfo}) + + ngx.say("email: ", values["X-Email"]) + ngx.say("phone: ", values["X-Phone"]) + ngx.say("missing: ", values["X-Missing"]) + } + } +--- response_body +email: old@example.com +phone: 123 +missing: 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"] = "jack@example.com", + } + 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: jack@example.com + 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: jack@example.com + 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: read write + + + +=== 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