diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index e195b6244fdb..4ad51d75642b 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -51,6 +51,11 @@ local UNHANDLED_REDIRECT_URI_ERR = "unhandled request to the redirect_uri" -- authorization callbacks local MAX_AUTH_FLOW_RESTARTS = 3 +local VAR_TEMPLATE_DESC = + "supports ${var} / ${var ?? default} templates, resolved " .. + "per-request from the request context (e.g. http_ for a " .. + "request header, or a value set by another plugin)" + -- Session config is passed as-is to resty.session.start(); the only -- translation is the legacy session.cookie.lifetime alias from the @@ -143,9 +148,18 @@ end local schema = { type = "object", properties = { - client_id = {type = "string"}, - client_secret = {type = "string"}, - discovery = {type = "string"}, + client_id = { + type = "string", + description = VAR_TEMPLATE_DESC, + }, + client_secret = { + type = "string", + description = VAR_TEMPLATE_DESC, + }, + discovery = { + type = "string", + description = VAR_TEMPLATE_DESC, + }, scope = { type = "string", default = "openid", @@ -1172,8 +1186,40 @@ local function validate_claims_in_oidcauth_response(resp, conf) end +-- discovery/client_id/client_secret accept ${var}/${var ?? default} templates so a +-- higher-priority custom plugin can select the IdP configuration per request +-- without openid-connect hard-coding that routing logic itself. +local function resolve_conf_var(ctx, field_name, value) + if type(value) ~= "string" then + return value + end + + local resolved, err = core.utils.resolve_var(value, ctx.var) + if err then + return nil, "failed to resolve \"" .. field_name .. "\": " .. err + end + + if resolved == nil or resolved == "" then + return nil, "resolved value of \"" .. field_name .. "\" is empty; ensure an " .. + "upstream plugin sets the request-context variable it references" + end + + return resolved +end + + function _M.rewrite(plugin_conf, ctx) local conf = core.table.clone(plugin_conf) + + for _, field_name in ipairs({"discovery", "client_id", "client_secret"}) do + local resolved, err = resolve_conf_var(ctx, field_name, conf[field_name]) + if err then + core.log.error("openid-connect: ", err) + return 500 + end + conf[field_name] = resolved + end + flatten_openidc_options(conf) -- Snapshot the client-supplied X-Access-Token (it doubles as a bearer diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index bec39be4bc5f..eaf075b5d109 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -165,6 +165,24 @@ And reference it in the Plugin configuration: "client_secret": "$ENV://KEYCLOAK_CLIENT_SECRET" ``` +### Per-request templating of `client_id`, `client_secret` and `discovery` + +`client_id`, `client_secret`, and `discovery` support `${var}` / `${var ?? default}` templates, resolved from the request context on every request (the same templating [`limit-count`](./limit-count.md) uses for `count`/`time_window`). Only values containing `${` are treated as templates; any other value, including one with a bare `$`, is used as-is. The Plugin configuration is never modified, so each request resolves its own values. + +For example, clients can send the values in request headers and reference them with `http_`: + +```json +{ + "discovery": "${http_x_oidc_discovery}", + "client_id": "${http_x_oidc_client_id ?? my-default-client}", + "client_secret": "${http_x_oidc_client_secret}" +} +``` + +Variables can also be set by another Plugin that runs earlier, but this is not required. + +If a template resolves to an empty value (for example, the header is missing and no default is given), `openid-connect` returns `500` instead of forwarding an empty value to the identity provider. + ## Examples The examples below demonstrate how you can configure the `openid-connect` Plugin for different scenarios. diff --git a/docs/zh/latest/plugins/openid-connect.md b/docs/zh/latest/plugins/openid-connect.md index 1833069fc95b..6777002e9790 100644 --- a/docs/zh/latest/plugins/openid-connect.md +++ b/docs/zh/latest/plugins/openid-connect.md @@ -164,6 +164,24 @@ export KEYCLOAK_CLIENT_SECRET=abc "client_secret": "$ENV://KEYCLOAK_CLIENT_SECRET" ``` +### 按请求模板化 `client_id`、`client_secret` 和 `discovery` + +`client_id`、`client_secret` 和 `discovery` 支持 `${var}` / `${var ?? default}` 模板,在每次请求时从请求上下文中解析(与 [`limit-count`](./limit-count.md) 用于 `count`/`time_window` 的模板机制相同)。只有包含 `${` 的值才会被视为模板,其他值(包括含有单独 `$` 的值)将按原样使用。插件配置本身不会被修改,每个请求独立解析自己的值。 + +例如,客户端可以通过请求头传递这些值,并使用 `http_` 引用: + +```json +{ + "discovery": "${http_x_oidc_discovery}", + "client_id": "${http_x_oidc_client_id ?? my-default-client}", + "client_secret": "${http_x_oidc_client_secret}" +} +``` + +变量也可以由更早运行的其他插件设置,但这不是必需的。 + +如果模板解析为空值(例如缺少请求头且未提供默认值),`openid-connect` 将返回 `500`,而不是向身份提供商转发空值。 + ## 示例 以下示例展示了如何针对不同场景配置 `openid-connect` 插件。 diff --git a/t/plugin/openid-connect-config-templating.t b/t/plugin/openid-connect-config-templating.t new file mode 100644 index 000000000000..2665adc953ab --- /dev/null +++ b/t/plugin/openid-connect-config-templating.t @@ -0,0 +1,287 @@ +# +# 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: "discovery"/"client_id"/"client_secret" templates fall back to their default +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/oidc-tpl-default', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "${oidc_discovery ?? http://127.0.0.1:8080/realms/University/.well-known/openid-configuration}", + "realm": "University", + "client_id": "${oidc_client_id ?? course_management}", + "client_secret": "${oidc_client_secret ?? d1ec69e9-55d2-4109-a3ea-befa071579d5}", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. + [[/oidc-tpl-default/authenticated", + "ssl_verify": false, + "timeout": 10, + "introspection_endpoint_auth_method": "client_secret_post", + "introspection_endpoint": "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token/introspect", + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + } + }, + "proxy-rewrite": { + "regex_uri": ["^/oidc-tpl-default/.*", "/uri"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/oidc-tpl-default/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 2: no ctx var set for the template falls back to the default and still authenticates +--- 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 httpc = http.new() + + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/oidc-tpl-default/uri" + local res, err = login_keycloak(uri, "teacher@gmail.com", "123456") + if err then + ngx.status = 500 + ngx.say(err) + return + end + + local cookie_str = concatenate_cookies(res.headers['Set-Cookie']) + local redirect_uri = "http://127.0.0.1:" .. ngx.var.server_port .. res.headers['Location'] + res, err = httpc:request_uri(redirect_uri, { + method = "GET", + headers = { + ["Cookie"] = cookie_str + } + }) + + if not res then + ngx.status = 500 + ngx.say(err) + return + elseif res.status ~= 200 then + ngx.status = 500 + ngx.say("Invoking the original URI didn't return the expected result: " + .. res.status .. " " .. res.body) + return + end + + ngx.status = res.status + ngx.say(res.body) + } + } +--- no_error_log +[error] +--- response_body_like +uri: /uri +cookie: .* + + + +=== TEST 3: an unresolved template with no default short-circuits with 500 +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/oidc-tpl-missing', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "realm": "University", + "client_id": "${oidc_client_id_never_set}", + "client_secret": "d1ec69e9-55d2-4109-a3ea-befa071579d5", + "redirect_uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/authenticated", + "ssl_verify": false, + "timeout": 10, + "session": { + "secret": "jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/oidc-tpl-missing/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 4: hitting the route from TEST 3 fails closed instead of forwarding an empty client_id +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/oidc-tpl-missing/uri" + local res, err = httpc:request_uri(uri, {method = "GET"}) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + ngx.status = res.status + } + } +--- error_log +openid-connect: resolved value of "client_id" is empty +--- error_code: 500 + + + +=== TEST 5: bearer-only route with credentials templated from request headers +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/oidc-tpl-header', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "client_id": "${http_x_client_id}", + "client_secret": "${http_x_client_secret}", + "bearer_only": true, + "ssl_verify": false, + "timeout": 10, + "introspection_endpoint_auth_method": "client_secret_post" + }, + "proxy-rewrite": { + "regex_uri": ["^/oidc-tpl-header/.*", "/hello"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/oidc-tpl-header/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 6: header-resolved values authenticate; a later request with other values neither reuses nor leaks them +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local secret = "d1ec69e9-55d2-4109-a3ea-befa071579d5" + + local res, err = httpc:request_uri( + "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token", { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=" + .. secret .. "&username=teacher@gmail.com&password=123456", + headers = {["Content-Type"] = "application/x-www-form-urlencoded"}, + }) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + local token = require("cjson").decode(res.body).access_token + + local function call(client_secret) + local r, e = httpc:request_uri("http://127.0.0.1:" .. ngx.var.server_port + .. "/oidc-tpl-header/hello", { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. token, + ["X-Client-Id"] = "course_management", + ["X-Client-Secret"] = client_secret, + }, + }) + return r and r.status or e + end + + ngx.say(call(secret)) + ngx.say(call("wrong-secret")) + ngx.say(call(secret)) + } + } +--- error_log +OIDC introspection failed +--- response_body +200 +401 +200