From 138fbf2425d7e444d17119acc5d969b2e3a6b00d Mon Sep 17 00:00:00 2001 From: Piyush Mishra Date: Thu, 26 Feb 2026 13:23:35 +0530 Subject: [PATCH 1/4] feat(openid-connect): add consumer selector for consumer-group realm routing --- apisix/consumer.lua | 38 ++++-- apisix/plugins/openid-connect.lua | 148 +++++++++++++++++++++++ docs/en/latest/plugins/openid-connect.md | 5 + t/plugin/openid-connect2.t | 87 +++++++++++++ 4 files changed, 265 insertions(+), 13 deletions(-) diff --git a/apisix/consumer.lua b/apisix/consumer.lua index cd60a0209ff0..282ceaec6f23 100644 --- a/apisix/consumer.lua +++ b/apisix/consumer.lua @@ -318,30 +318,42 @@ function _M.init_worker() end end -local function get_anonymous_consumer_from_local_cache(name) - local anon_consumer_raw = consumers:get(name) +local function get_consumer_from_local_cache(name, kind) + local consumer_raw = consumers:get(name) - if not anon_consumer_raw or not anon_consumer_raw.value or - not anon_consumer_raw.value.id or not anon_consumer_raw.modifiedIndex then - return nil, nil, "failed to get anonymous consumer " .. name + if not consumer_raw or not consumer_raw.value or + not consumer_raw.value.id or not consumer_raw.modifiedIndex then + return nil, nil, "failed to get " .. kind .. " " .. name end - -- make structure of anon_consumer similar to that of consumer_mod.consumers_kv's response - local anon_consumer = anon_consumer_raw.value - anon_consumer.consumer_name = anon_consumer_raw.value.id - anon_consumer.modifiedIndex = anon_consumer_raw.modifiedIndex + -- keep local cache immutable for the rest of request lifecycle + local consumer = core.table.clone(consumer_raw.value) + consumer.consumer_name = consumer_raw.value.id + consumer.modifiedIndex = consumer_raw.modifiedIndex + if consumer.labels then + consumer.custom_id = consumer.labels["custom_id"] + end - local anon_consumer_conf = { - conf_version = anon_consumer_raw.modifiedIndex + local consumer_conf = { + conf_version = consumer_raw.modifiedIndex } - return anon_consumer, anon_consumer_conf + return consumer, consumer_conf +end + + +function _M.get_consumer(name) + local consumer, consumer_conf, err + consumer, consumer_conf, err = get_consumer_from_local_cache(name, "consumer") + + return consumer, consumer_conf, err end function _M.get_anonymous_consumer(name) local anon_consumer, anon_consumer_conf, err - anon_consumer, anon_consumer_conf, err = get_anonymous_consumer_from_local_cache(name) + anon_consumer, anon_consumer_conf, err = get_consumer_from_local_cache( + name, "anonymous consumer") return anon_consumer, anon_consumer_conf, err end diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 1f84476d1e29..370c1f41843d 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -18,6 +18,7 @@ local core = require("apisix.core") local ngx_re = require("ngx.re") local openidc = require("resty.openidc") +local consumer_mod = require("apisix.consumer") local fetch_secrets = require("apisix.secret").fetch_secrets local jsonschema = require('jsonschema') local string = string @@ -27,8 +28,10 @@ local type = type local tostring = tostring local pcall = pcall local concat = table.concat +local string_rep = string.rep local ngx_encode_base64 = ngx.encode_base64 +local ngx_decode_base64 = ngx.decode_base64 local plugin_name = "openid-connect" @@ -399,6 +402,32 @@ local schema = { description = "JSON schema of OIDC response claim", type = "object", default = nil, + }, + consumer_selector = { + type = "object", + properties = { + enabled = { + type = "boolean", + default = false, + }, + claim = { + type = "string", + default = "iss", + minLength = 1, + }, + map = { + type = "object", + minProperties = 1, + additionalProperties = { + type = "string", + minLength = 1, + }, + }, + strict = { + type = "boolean", + default = true, + } + }, } }, encrypt_fields = {"client_secret", "client_rsa_private_key"}, @@ -440,6 +469,14 @@ function _M.check_schema(conf) end end + if conf.consumer_selector + and conf.consumer_selector.enabled + and not conf.consumer_selector.map + then + return false, "property \"consumer_selector.map\" is required " .. + "when \"consumer_selector.enabled\" is true" + end + return true end @@ -478,6 +515,100 @@ local function get_bearer_access_token(ctx) end +local function decode_jwt_payload(token) + local parts, err = ngx_re.split(token, "\\.", nil, nil, 3) + if not parts then + return nil, "failed to parse JWT: " .. err + end + + if #parts < 2 then + return nil, "invalid JWT format" + end + + local payload = parts[2] + payload = payload:gsub("-", "+"):gsub("_", "/") + local remainder = #payload % 4 + if remainder > 0 then + payload = payload .. string_rep("=", 4 - remainder) + end + + local payload_raw = ngx_decode_base64(payload) + if not payload_raw then + return nil, "failed to decode JWT payload" + end + + local decoded, decode_err = core.json.decode(payload_raw) + if not decoded then + return nil, "failed to decode JWT payload JSON: " .. (decode_err or "unknown error") + end + + return decoded +end + + +local function run_consumer_selector(ctx, conf) + local selector = conf.consumer_selector + if not selector or not selector.enabled then + return false, nil, nil + end + + if ctx.consumer then + return false, nil, nil + end + + local has_token, token, err = get_bearer_access_token(ctx) + if err then + return false, ngx.HTTP_BAD_REQUEST, err + end + + if not has_token then + if selector.strict then + ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. '"' + return false, ngx.HTTP_UNAUTHORIZED, "No bearer token found in request." + end + return false, nil, nil + end + + local payload, payload_err = decode_jwt_payload(token) + if not payload then + if selector.strict then + ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. '"' + return false, ngx.HTTP_UNAUTHORIZED, payload_err + end + return false, nil, nil + end + + local claim_name = selector.claim or "iss" + local claim_value = payload[claim_name] + if type(claim_value) ~= "string" or claim_value == "" then + if selector.strict then + return false, ngx.HTTP_UNAUTHORIZED, "missing claim \"" .. claim_name .. "\" in JWT" + end + return false, nil, nil + end + + local consumer_name = selector.map[claim_value] + if not consumer_name then + if selector.strict then + return false, ngx.HTTP_UNAUTHORIZED, "no consumer mapping for claim \"" .. + claim_name .. "\" value" + end + return false, nil, nil + end + + local consumer, consumer_conf, get_err = consumer_mod.get_consumer(consumer_name) + if not consumer then + core.log.error("failed to resolve consumer [", consumer_name, "] by selector: ", get_err) + return false, ngx.HTTP_INTERNAL_SERVER_ERROR, "failed to resolve mapped consumer" + end + + consumer_mod.attach_consumer(ctx, consumer, consumer_conf) + core.log.info("openid-connect selector mapped claim ", claim_name, " to consumer ", + consumer_name) + return true, nil, nil, token, payload +end + + local function introspect(ctx, conf) -- Extract token, maybe. local has_token, token, err = get_bearer_access_token(ctx) @@ -632,6 +763,23 @@ function _M.rewrite(plugin_conf, ctx) conf.timeout = conf.timeout * 1000 end + local selected, selector_code, selector_body, selector_token, selector_payload = + run_consumer_selector(ctx, conf) + if selector_code then + return selector_code, { message = selector_body } + end + if selected then + -- Preserve standard downstream headers even when auth flow is short-circuited + -- by consumer selector mapping. + add_access_token_header(ctx, conf, selector_token) + if conf.set_userinfo_header and selector_payload then + core.request.set_header(ctx, "X-Userinfo", + ngx_encode_base64(core.json.encode(selector_payload))) + end + -- consumer-group plugins will be merged and executed in rewrite_in_consumer phase + return + end + local path = ctx.var.request_uri if not conf.redirect_uri then diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index cdee06366152..c98c2b13920d 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -121,6 +121,11 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | claim_validator.audience.required | boolean | False | false | | If true, audience claim is required and the name of the claim will be the name defined in `claim`. | | claim_validator.audience.match_with_client_id | boolean | False | false | | If true, require the audience to match the client ID. If the audience is a string, it must exactly match the client ID. If the audience is an array of strings, at least one of the values must match the client ID. If no match is found, you will receive a `mismatched audience` error. This requirement is stated in the OpenID Connect specification to ensure that the token is intended for the specific client. | | claim_schema | object | False | | | JSON schema of OIDC response claim. Example: `{"type":"object","properties":{"access_token":{"type":"string"}},"required":["access_token"]}` - validates that the response contains a required string field `access_token`. | +| consumer_selector | object | False | | | Enables an early consumer selection step before OIDC validation. This is useful when you place realm-specific `openid-connect` configs in consumer groups and want one route to dispatch requests by token claim. | +| consumer_selector.enabled | boolean | False | false | | If true, enable the consumer selector. | +| consumer_selector.claim | string | False | iss | | JWT claim name used for selecting the consumer, such as `iss`. | +| consumer_selector.map | object | Conditional | | | Mapping of claim value to consumer name. Required when `consumer_selector.enabled` is true. | +| consumer_selector.strict | boolean | False | true | | If true, reject requests when bearer token/claim/mapping is missing. If false, skip selector and continue with route-level config. | NOTE: `encrypt_fields = {"client_secret"}` is also defined in the schema, which means that the field will be stored encrypted in etcd. See [encrypted storage fields](../plugin-develop.md#encrypted-storage-fields). In addition, you can use Environment Variables or APISIX secret to store and reference plugin attributes. APISIX currently supports storing secrets in two ways - [Environment Variables and HashiCorp Vault](../terminology/secret.md). diff --git a/t/plugin/openid-connect2.t b/t/plugin/openid-connect2.t index 50ac511fd931..ef0bf8dec8c5 100644 --- a/t/plugin/openid-connect2.t +++ b/t/plugin/openid-connect2.t @@ -711,3 +711,90 @@ property "user1" is required --- error_code: 400 --- response_body_like {"error_msg":"failed to check the configuration of plugin openid-connect err: check claim_schema failed: .*: invalid JSON type: invalid_type"} + + + +=== TEST 16: Set route with consumer selector enabled but map missing, should fail schema check +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "https://idp.example/.well-known/openid-configuration", + "realm": "apisix", + "client_id": "dummy-client", + "client_secret": "dummy-secret", + "bearer_only": true, + "consumer_selector": { + "enabled": true + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/selector" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like +property "consumer_selector.map" is required when "consumer_selector.enabled" is true + + + +=== TEST 17: Set route with consumer selector map, should pass schema check +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "discovery": "https://idp.example/.well-known/openid-configuration", + "realm": "apisix", + "client_id": "dummy-client", + "client_secret": "dummy-secret", + "bearer_only": true, + "consumer_selector": { + "enabled": true, + "claim": "iss", + "map": { + "https://idp.example/realms/a": "consumer-a" + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/selector" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed From 0494e173f63a5ad02054b0ec2f4856bddad65708 Mon Sep 17 00:00:00 2001 From: Piyush Mishra Date: Thu, 5 Mar 2026 12:06:56 +0530 Subject: [PATCH 2/4] fix: revert openidc-connect changes and introduce new var for route splitting --- apisix/consumer.lua | 38 ++---- apisix/http/vars.lua | 95 +++++++++++++++ apisix/init.lua | 1 + apisix/plugins/openid-connect.lua | 148 ----------------------- docs/en/latest/FAQ.md | 46 +++++++ docs/en/latest/admin-api.md | 2 +- docs/en/latest/plugins/openid-connect.md | 5 - t/plugin/openid-connect2.t | 87 ------------- t/router/radixtree-uri-vars.t | 138 +++++++++++++++++++++ 9 files changed, 294 insertions(+), 266 deletions(-) create mode 100644 apisix/http/vars.lua diff --git a/apisix/consumer.lua b/apisix/consumer.lua index 282ceaec6f23..cd60a0209ff0 100644 --- a/apisix/consumer.lua +++ b/apisix/consumer.lua @@ -318,42 +318,30 @@ function _M.init_worker() end end -local function get_consumer_from_local_cache(name, kind) - local consumer_raw = consumers:get(name) +local function get_anonymous_consumer_from_local_cache(name) + local anon_consumer_raw = consumers:get(name) - if not consumer_raw or not consumer_raw.value or - not consumer_raw.value.id or not consumer_raw.modifiedIndex then - return nil, nil, "failed to get " .. kind .. " " .. name + if not anon_consumer_raw or not anon_consumer_raw.value or + not anon_consumer_raw.value.id or not anon_consumer_raw.modifiedIndex then + return nil, nil, "failed to get anonymous consumer " .. name end - -- keep local cache immutable for the rest of request lifecycle - local consumer = core.table.clone(consumer_raw.value) - consumer.consumer_name = consumer_raw.value.id - consumer.modifiedIndex = consumer_raw.modifiedIndex - if consumer.labels then - consumer.custom_id = consumer.labels["custom_id"] - end + -- make structure of anon_consumer similar to that of consumer_mod.consumers_kv's response + local anon_consumer = anon_consumer_raw.value + anon_consumer.consumer_name = anon_consumer_raw.value.id + anon_consumer.modifiedIndex = anon_consumer_raw.modifiedIndex - local consumer_conf = { - conf_version = consumer_raw.modifiedIndex + local anon_consumer_conf = { + conf_version = anon_consumer_raw.modifiedIndex } - return consumer, consumer_conf -end - - -function _M.get_consumer(name) - local consumer, consumer_conf, err - consumer, consumer_conf, err = get_consumer_from_local_cache(name, "consumer") - - return consumer, consumer_conf, err + return anon_consumer, anon_consumer_conf end function _M.get_anonymous_consumer(name) local anon_consumer, anon_consumer_conf, err - anon_consumer, anon_consumer_conf, err = get_consumer_from_local_cache( - name, "anonymous consumer") + anon_consumer, anon_consumer_conf, err = get_anonymous_consumer_from_local_cache(name) return anon_consumer, anon_consumer_conf, err end diff --git a/apisix/http/vars.lua b/apisix/http/vars.lua new file mode 100644 index 000000000000..cab18e3a8297 --- /dev/null +++ b/apisix/http/vars.lua @@ -0,0 +1,95 @@ +-- +-- 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. +-- + +local core = require("apisix.core") +local ngx_re = require("ngx.re") +local ngx = ngx +local string = string +local string_rep = string.rep +local ngx_decode_base64 = ngx.decode_base64 + +local _M = { + version = 0.1, +} + + +local function get_bearer_token(ctx) + local auth_header = core.request.header(ctx, "Authorization") + if not auth_header then + return nil + end + + local parts = ngx_re.split(auth_header, " ", nil, nil, 2) + if not parts or #parts < 2 then + return nil + end + + if string.lower(parts[1]) ~= "bearer" then + return nil + end + + return parts[2] +end + + +local function decode_jwt_payload(token) + local parts = ngx_re.split(token, "\\.", nil, nil, 3) + if not parts or #parts < 2 then + return nil + end + + local payload = parts[2] + payload = payload:gsub("-", "+"):gsub("_", "/") + local remainder = #payload % 4 + if remainder > 0 then + payload = payload .. string_rep("=", 4 - remainder) + end + + local payload_raw = ngx_decode_base64(payload) + if not payload_raw then + return nil + end + + local decoded = core.json.decode(payload_raw) + if not decoded or type(decoded) ~= "table" then + return nil + end + + return decoded +end + + +core.ctx.register_var("jwt_iss", function(ctx) + local token = get_bearer_token(ctx) + if not token then + return nil + end + + local payload = decode_jwt_payload(token) + if not payload then + return nil + end + + if type(payload.iss) ~= "string" then + return nil + end + + return payload.iss +end) + + +return _M diff --git a/apisix/init.lua b/apisix/init.lua index 5fc874254449..e2e88dffac37 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -71,6 +71,7 @@ local control_api_router local is_http = false if ngx.config.subsystem == "http" then is_http = true + require("apisix.http.vars") control_api_router = require("apisix.control.router") end diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index 370c1f41843d..1f84476d1e29 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -18,7 +18,6 @@ local core = require("apisix.core") local ngx_re = require("ngx.re") local openidc = require("resty.openidc") -local consumer_mod = require("apisix.consumer") local fetch_secrets = require("apisix.secret").fetch_secrets local jsonschema = require('jsonschema') local string = string @@ -28,10 +27,8 @@ local type = type local tostring = tostring local pcall = pcall local concat = table.concat -local string_rep = string.rep local ngx_encode_base64 = ngx.encode_base64 -local ngx_decode_base64 = ngx.decode_base64 local plugin_name = "openid-connect" @@ -402,32 +399,6 @@ local schema = { description = "JSON schema of OIDC response claim", type = "object", default = nil, - }, - consumer_selector = { - type = "object", - properties = { - enabled = { - type = "boolean", - default = false, - }, - claim = { - type = "string", - default = "iss", - minLength = 1, - }, - map = { - type = "object", - minProperties = 1, - additionalProperties = { - type = "string", - minLength = 1, - }, - }, - strict = { - type = "boolean", - default = true, - } - }, } }, encrypt_fields = {"client_secret", "client_rsa_private_key"}, @@ -469,14 +440,6 @@ function _M.check_schema(conf) end end - if conf.consumer_selector - and conf.consumer_selector.enabled - and not conf.consumer_selector.map - then - return false, "property \"consumer_selector.map\" is required " .. - "when \"consumer_selector.enabled\" is true" - end - return true end @@ -515,100 +478,6 @@ local function get_bearer_access_token(ctx) end -local function decode_jwt_payload(token) - local parts, err = ngx_re.split(token, "\\.", nil, nil, 3) - if not parts then - return nil, "failed to parse JWT: " .. err - end - - if #parts < 2 then - return nil, "invalid JWT format" - end - - local payload = parts[2] - payload = payload:gsub("-", "+"):gsub("_", "/") - local remainder = #payload % 4 - if remainder > 0 then - payload = payload .. string_rep("=", 4 - remainder) - end - - local payload_raw = ngx_decode_base64(payload) - if not payload_raw then - return nil, "failed to decode JWT payload" - end - - local decoded, decode_err = core.json.decode(payload_raw) - if not decoded then - return nil, "failed to decode JWT payload JSON: " .. (decode_err or "unknown error") - end - - return decoded -end - - -local function run_consumer_selector(ctx, conf) - local selector = conf.consumer_selector - if not selector or not selector.enabled then - return false, nil, nil - end - - if ctx.consumer then - return false, nil, nil - end - - local has_token, token, err = get_bearer_access_token(ctx) - if err then - return false, ngx.HTTP_BAD_REQUEST, err - end - - if not has_token then - if selector.strict then - ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. '"' - return false, ngx.HTTP_UNAUTHORIZED, "No bearer token found in request." - end - return false, nil, nil - end - - local payload, payload_err = decode_jwt_payload(token) - if not payload then - if selector.strict then - ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. '"' - return false, ngx.HTTP_UNAUTHORIZED, payload_err - end - return false, nil, nil - end - - local claim_name = selector.claim or "iss" - local claim_value = payload[claim_name] - if type(claim_value) ~= "string" or claim_value == "" then - if selector.strict then - return false, ngx.HTTP_UNAUTHORIZED, "missing claim \"" .. claim_name .. "\" in JWT" - end - return false, nil, nil - end - - local consumer_name = selector.map[claim_value] - if not consumer_name then - if selector.strict then - return false, ngx.HTTP_UNAUTHORIZED, "no consumer mapping for claim \"" .. - claim_name .. "\" value" - end - return false, nil, nil - end - - local consumer, consumer_conf, get_err = consumer_mod.get_consumer(consumer_name) - if not consumer then - core.log.error("failed to resolve consumer [", consumer_name, "] by selector: ", get_err) - return false, ngx.HTTP_INTERNAL_SERVER_ERROR, "failed to resolve mapped consumer" - end - - consumer_mod.attach_consumer(ctx, consumer, consumer_conf) - core.log.info("openid-connect selector mapped claim ", claim_name, " to consumer ", - consumer_name) - return true, nil, nil, token, payload -end - - local function introspect(ctx, conf) -- Extract token, maybe. local has_token, token, err = get_bearer_access_token(ctx) @@ -763,23 +632,6 @@ function _M.rewrite(plugin_conf, ctx) conf.timeout = conf.timeout * 1000 end - local selected, selector_code, selector_body, selector_token, selector_payload = - run_consumer_selector(ctx, conf) - if selector_code then - return selector_code, { message = selector_body } - end - if selected then - -- Preserve standard downstream headers even when auth flow is short-circuited - -- by consumer selector mapping. - add_access_token_header(ctx, conf, selector_token) - if conf.set_userinfo_header and selector_payload then - core.request.set_header(ctx, "X-Userinfo", - ngx_encode_base64(core.json.encode(selector_payload))) - end - -- consumer-group plugins will be merged and executed in rewrite_in_consumer phase - return - end - local path = ctx.var.request_uri if not conf.redirect_uri then diff --git a/docs/en/latest/FAQ.md b/docs/en/latest/FAQ.md index f9e30f52fb6c..680106ff271f 100644 --- a/docs/en/latest/FAQ.md +++ b/docs/en/latest/FAQ.md @@ -161,6 +161,52 @@ curl -i http://127.0.0.1:9180/apisix/admin/routes/2 -H "X-API-KEY: $admin_key" - All the available operators of the current `lua-resty-radixtree` are listed [here](https://github.com/api7/lua-resty-radixtree#operator-list). +For multi-realm OpenID Connect routing on a single URI, you can dispatch requests to different routes by matching +the built-in variable `jwt_iss`, then configure realm-specific `openid-connect` per route: + +```shell +curl -i http://127.0.0.1:9180/apisix/admin/routes/101 -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/swr", + "vars": [ + ["jwt_iss", "==", "https://idp.example/realms/realm-a"] + ], + "plugins": { + "openid-connect": { + "discovery": "https://idp.example/realms/realm-a/.well-known/openid-configuration", + "client_id": "realm-a-client", + "client_secret": "realm-a-secret", + "bearer_only": true, + "use_jwks": true + } + }, + "upstream_id": "1" +}' + +curl -i http://127.0.0.1:9180/apisix/admin/routes/102 -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/swr", + "vars": [ + ["jwt_iss", "==", "https://idp.example/realms/realm-b"] + ], + "plugins": { + "openid-connect": { + "discovery": "https://idp.example/realms/realm-b/.well-known/openid-configuration", + "client_id": "realm-b-client", + "client_secret": "realm-b-secret", + "bearer_only": true, + "use_jwks": true + } + }, + "upstream_id": "1" +}' +``` + +`jwt_iss` is routing metadata extracted from JWT payload before signature verification. Do not treat route +dispatch as authentication. Always enforce final token validation in route-level `openid-connect` configuration. +To reduce realm mapping exposure, you are recommended to keep failure responses consistent across realm routes and use +a fallback route with uniform error behavior. + 2. Using the [traffic-split](plugins/traffic-split.md) Plugin. ## How do I redirect HTTP traffic to HTTPS with Apache APISIX? diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md index 21f416b71272..6cd56bf2f2da 100644 --- a/docs/en/latest/admin-api.md +++ b/docs/en/latest/admin-api.md @@ -358,7 +358,7 @@ ID's as a text string must be of a length between 1 and 64 characters and they s | remote_addrs | False, can't be used with `remote_addr` | Match Rules | Matches with any one of the multiple `remote_addr`s specified in the form of a non-empty list. | ["127.0.0.1", "192.0.0.0/8", "::1"] | | methods | False | Match Rules | Matches with the specified methods. Matches all methods if empty or unspecified. | ["GET", "POST"] | | priority | False | Match Rules | If different Routes matches to the same `uri`, then the Route is matched based on its `priority`. A higher value corresponds to higher priority. It is set to `0` by default. | priority = 10 | -| vars | False | Match Rules | Matches based on the specified variables consistent with variables in Nginx. Takes the form `[[var, operator, val], [var, operator, val], ...]]`. Note that this is case sensitive when matching a cookie name. See [lua-resty-expr](https://github.com/api7/lua-resty-expr) for more details. | [["arg_name", "==", "json"], ["arg_age", ">", 18]] | +| vars | False | Match Rules | Matches based on the specified variables consistent with variables in Nginx. Takes the form `[[var, operator, val], [var, operator, val], ...]]`. Note that this is case sensitive when matching a cookie name. APISIX also supports built-in custom variables such as `jwt_iss_unverified` for routing by Bearer token issuer before authentication. See [lua-resty-expr](https://github.com/api7/lua-resty-expr) for more details. | [["arg_name", "==", "json"], ["arg_age", ">", 18]] | | filter_func | False | Match Rules | Matches using a user-defined function in Lua. Used in scenarios where `vars` is not sufficient. Functions accept an argument `vars` which provides access to built-in variables (including Nginx variables). | function(vars) return tonumber(vars.arg_userid) % 4 > 2; end | | plugins | False | Plugin | Plugins that are executed during the request/response cycle. See [Plugin](terminology/plugin.md) for more. | | | script | False | Script | Used for writing arbitrary Lua code or directly calling existing plugins to be executed. See [Script](terminology/script.md) for more. | | diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index c98c2b13920d..cdee06366152 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -121,11 +121,6 @@ The `openid-connect` Plugin supports the integration with [OpenID Connect (OIDC) | claim_validator.audience.required | boolean | False | false | | If true, audience claim is required and the name of the claim will be the name defined in `claim`. | | claim_validator.audience.match_with_client_id | boolean | False | false | | If true, require the audience to match the client ID. If the audience is a string, it must exactly match the client ID. If the audience is an array of strings, at least one of the values must match the client ID. If no match is found, you will receive a `mismatched audience` error. This requirement is stated in the OpenID Connect specification to ensure that the token is intended for the specific client. | | claim_schema | object | False | | | JSON schema of OIDC response claim. Example: `{"type":"object","properties":{"access_token":{"type":"string"}},"required":["access_token"]}` - validates that the response contains a required string field `access_token`. | -| consumer_selector | object | False | | | Enables an early consumer selection step before OIDC validation. This is useful when you place realm-specific `openid-connect` configs in consumer groups and want one route to dispatch requests by token claim. | -| consumer_selector.enabled | boolean | False | false | | If true, enable the consumer selector. | -| consumer_selector.claim | string | False | iss | | JWT claim name used for selecting the consumer, such as `iss`. | -| consumer_selector.map | object | Conditional | | | Mapping of claim value to consumer name. Required when `consumer_selector.enabled` is true. | -| consumer_selector.strict | boolean | False | true | | If true, reject requests when bearer token/claim/mapping is missing. If false, skip selector and continue with route-level config. | NOTE: `encrypt_fields = {"client_secret"}` is also defined in the schema, which means that the field will be stored encrypted in etcd. See [encrypted storage fields](../plugin-develop.md#encrypted-storage-fields). In addition, you can use Environment Variables or APISIX secret to store and reference plugin attributes. APISIX currently supports storing secrets in two ways - [Environment Variables and HashiCorp Vault](../terminology/secret.md). diff --git a/t/plugin/openid-connect2.t b/t/plugin/openid-connect2.t index ef0bf8dec8c5..50ac511fd931 100644 --- a/t/plugin/openid-connect2.t +++ b/t/plugin/openid-connect2.t @@ -711,90 +711,3 @@ property "user1" is required --- error_code: 400 --- response_body_like {"error_msg":"failed to check the configuration of plugin openid-connect err: check claim_schema failed: .*: invalid JSON type: invalid_type"} - - - -=== TEST 16: Set route with consumer selector enabled but map missing, should fail schema check ---- config - location /t { - content_by_lua_block { - local t = require("lib.test_admin").test - local code, body = t('/apisix/admin/routes/1', - ngx.HTTP_PUT, - [[{ - "plugins": { - "openid-connect": { - "discovery": "https://idp.example/.well-known/openid-configuration", - "realm": "apisix", - "client_id": "dummy-client", - "client_secret": "dummy-secret", - "bearer_only": true, - "consumer_selector": { - "enabled": true - } - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1980": 1 - }, - "type": "roundrobin" - }, - "uri": "/selector" - }]] - ) - - if code >= 300 then - ngx.status = code - end - ngx.say(body) - } - } ---- error_code: 400 ---- response_body_like -property "consumer_selector.map" is required when "consumer_selector.enabled" is true - - - -=== TEST 17: Set route with consumer selector map, should pass schema check ---- config - location /t { - content_by_lua_block { - local t = require("lib.test_admin").test - local code, body = t('/apisix/admin/routes/1', - ngx.HTTP_PUT, - [[{ - "plugins": { - "openid-connect": { - "discovery": "https://idp.example/.well-known/openid-configuration", - "realm": "apisix", - "client_id": "dummy-client", - "client_secret": "dummy-secret", - "bearer_only": true, - "consumer_selector": { - "enabled": true, - "claim": "iss", - "map": { - "https://idp.example/realms/a": "consumer-a" - } - } - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1980": 1 - }, - "type": "roundrobin" - }, - "uri": "/selector" - }]] - ) - - if code >= 300 then - ngx.status = code - end - ngx.say(body) - } - } ---- response_body -passed diff --git a/t/router/radixtree-uri-vars.t b/t/router/radixtree-uri-vars.t index 6e4b78712f6d..59d8980d519d 100644 --- a/t/router/radixtree-uri-vars.t +++ b/t/router/radixtree-uri-vars.t @@ -437,3 +437,141 @@ GET /t --- error_code: 400 --- response_body {"error_msg":"failed to validate the 'vars' expression: rule should be wrapped inside brackets"} + + + +=== TEST 22: set route(id: 101) with vars(jwt_iss_unverified == realm-a) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/101', + ngx.HTTP_PUT, + [=[{ + "methods": ["GET"], + "plugins": { + "response-rewrite": { + "body": "realm-a\n" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/jwt-split", + "vars": [["jwt_iss_unverified", "==", "realm-a"]] + }]=] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 23: set route(id: 102) with vars(jwt_iss_unverified == realm-b) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/102', + ngx.HTTP_PUT, + [=[{ + "methods": ["GET"], + "plugins": { + "response-rewrite": { + "body": "realm-b\n" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/jwt-split", + "vars": [["jwt_iss_unverified", "==", "realm-b"]] + }]=] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 24: match realm-a route +--- request +GET /jwt-split +--- more_headers +Authorization: Bearer eyJhbGciOiJub25lIn0.eyJpc3MiOiJyZWFsbS1hIn0. +--- response_body +realm-a + + + +=== TEST 25: match realm-b route +--- request +GET /jwt-split +--- more_headers +Authorization: Bearer eyJhbGciOiJub25lIn0.eyJpc3MiOiJyZWFsbS1iIn0. +--- response_body +realm-b + + + +=== TEST 26: no Authorization header should not match realm routes +--- request +GET /jwt-split +--- error_code: 404 +--- response_body +{"error_msg":"404 Route Not Found"} + + + +=== TEST 27: non-bearer Authorization should not match realm routes +--- request +GET /jwt-split +--- more_headers +Authorization: Basic dXNlcjpwYXNz +--- error_code: 404 +--- response_body +{"error_msg":"404 Route Not Found"} + + + +=== TEST 28: malformed bearer token should not match realm routes +--- request +GET /jwt-split +--- more_headers +Authorization: Bearer malformed.token +--- error_code: 404 +--- response_body +{"error_msg":"404 Route Not Found"} + + + +=== TEST 29: bearer token without iss should not match realm routes +--- request +GET /jwt-split +--- more_headers +Authorization: Bearer eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyMSJ9. +--- error_code: 404 +--- response_body +{"error_msg":"404 Route Not Found"} From ba07288715c29e669ec46f944af81b42599d8b47 Mon Sep 17 00:00:00 2001 From: Piyush Mishra Date: Thu, 5 Mar 2026 12:08:33 +0530 Subject: [PATCH 3/4] fix: update var name in doc and tests --- docs/en/latest/admin-api.md | 2 +- t/router/radixtree-uri-vars.t | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md index 6cd56bf2f2da..3c01f11d58af 100644 --- a/docs/en/latest/admin-api.md +++ b/docs/en/latest/admin-api.md @@ -358,7 +358,7 @@ ID's as a text string must be of a length between 1 and 64 characters and they s | remote_addrs | False, can't be used with `remote_addr` | Match Rules | Matches with any one of the multiple `remote_addr`s specified in the form of a non-empty list. | ["127.0.0.1", "192.0.0.0/8", "::1"] | | methods | False | Match Rules | Matches with the specified methods. Matches all methods if empty or unspecified. | ["GET", "POST"] | | priority | False | Match Rules | If different Routes matches to the same `uri`, then the Route is matched based on its `priority`. A higher value corresponds to higher priority. It is set to `0` by default. | priority = 10 | -| vars | False | Match Rules | Matches based on the specified variables consistent with variables in Nginx. Takes the form `[[var, operator, val], [var, operator, val], ...]]`. Note that this is case sensitive when matching a cookie name. APISIX also supports built-in custom variables such as `jwt_iss_unverified` for routing by Bearer token issuer before authentication. See [lua-resty-expr](https://github.com/api7/lua-resty-expr) for more details. | [["arg_name", "==", "json"], ["arg_age", ">", 18]] | +| vars | False | Match Rules | Matches based on the specified variables consistent with variables in Nginx. Takes the form `[[var, operator, val], [var, operator, val], ...]]`. Note that this is case sensitive when matching a cookie name. APISIX also supports built-in custom variables such as `jwt_iss` for routing by Bearer token issuer before authentication. See [lua-resty-expr](https://github.com/api7/lua-resty-expr) for more details. | [["arg_name", "==", "json"], ["arg_age", ">", 18]] | | filter_func | False | Match Rules | Matches using a user-defined function in Lua. Used in scenarios where `vars` is not sufficient. Functions accept an argument `vars` which provides access to built-in variables (including Nginx variables). | function(vars) return tonumber(vars.arg_userid) % 4 > 2; end | | plugins | False | Plugin | Plugins that are executed during the request/response cycle. See [Plugin](terminology/plugin.md) for more. | | | script | False | Script | Used for writing arbitrary Lua code or directly calling existing plugins to be executed. See [Script](terminology/script.md) for more. | | diff --git a/t/router/radixtree-uri-vars.t b/t/router/radixtree-uri-vars.t index 59d8980d519d..383f360c710d 100644 --- a/t/router/radixtree-uri-vars.t +++ b/t/router/radixtree-uri-vars.t @@ -440,7 +440,7 @@ GET /t -=== TEST 22: set route(id: 101) with vars(jwt_iss_unverified == realm-a) +=== TEST 22: set route(id: 101) with vars(jwt_iss == realm-a) --- config location /t { content_by_lua_block { @@ -461,7 +461,7 @@ GET /t "type": "roundrobin" }, "uri": "/jwt-split", - "vars": [["jwt_iss_unverified", "==", "realm-a"]] + "vars": [["jwt_iss", "==", "realm-a"]] }]=] ) @@ -478,7 +478,7 @@ passed -=== TEST 23: set route(id: 102) with vars(jwt_iss_unverified == realm-b) +=== TEST 23: set route(id: 102) with vars(jwt_iss == realm-b) --- config location /t { content_by_lua_block { @@ -499,7 +499,7 @@ passed "type": "roundrobin" }, "uri": "/jwt-split", - "vars": [["jwt_iss_unverified", "==", "realm-b"]] + "vars": [["jwt_iss", "==", "realm-b"]] }]=] ) From f8ac0800e034bdbb04d07d6f62a98427cb8966c3 Mon Sep 17 00:00:00 2001 From: Piyush Mishra Date: Thu, 5 Mar 2026 15:40:20 +0530 Subject: [PATCH 4/4] fix: update the existing ctx vars instead of creating a separate module --- apisix/core/ctx.lua | 67 +++++++++++++++++++++++++++++++ apisix/http/vars.lua | 95 -------------------------------------------- apisix/init.lua | 1 - 3 files changed, 67 insertions(+), 96 deletions(-) delete mode 100644 apisix/http/vars.lua diff --git a/apisix/core/ctx.lua b/apisix/core/ctx.lua index a5d1d47510d1..88caf812e56d 100644 --- a/apisix/core/ctx.lua +++ b/apisix/core/ctx.lua @@ -35,13 +35,17 @@ local gq_parse = require("graphql").parse local jp = require("jsonpath") local setmetatable = setmetatable local sub_str = string.sub +local str_lower = string.lower +local str_rep = string.rep local ngx = ngx local ngx_var = ngx.var local re_gsub = ngx.re.gsub +local re_split = ngx.re.split local ipairs = ipairs local type = type local error = error local pcall = pcall +local ngx_decode_base64 = ngx.decode_base64 local _M = {version = 0.2} @@ -212,6 +216,52 @@ end do + local function get_bearer_token(ctx) + local auth_header = request.header(ctx, "Authorization") + if not auth_header then + return nil + end + + local parts = re_split(auth_header, " ", nil, nil, 2) + if not parts or #parts < 2 then + return nil + end + + if str_lower(parts[1]) ~= "bearer" then + return nil + end + + return parts[2] + end + + + local function decode_jwt_payload(token) + local parts = re_split(token, "\\.", nil, nil, 3) + if not parts or #parts < 2 then + return nil + end + + local payload = parts[2] + payload = payload:gsub("-", "+"):gsub("_", "/") + local remainder = #payload % 4 + if remainder > 0 then + payload = payload .. str_rep("=", 4 - remainder) + end + + local payload_raw = ngx_decode_base64(payload) + if not payload_raw then + return nil + end + + local decoded = json.decode(payload_raw) + if not decoded or type(decoded) ~= "table" then + return nil + end + + return decoded + end + + local var_methods = { method = ngx.req.get_method, cookie = function () @@ -264,6 +314,23 @@ do balancer_port = true, consumer_group_id = true, consumer_name = true, + jwt_iss = function(ctx) + local token = get_bearer_token(ctx) + if not token then + return nil + end + + local payload = decode_jwt_payload(token) + if not payload then + return nil + end + + if type(payload.iss) ~= "string" then + return nil + end + + return payload.iss + end, resp_body = function(ctx) -- only for logger and requires the logger to have a special configuration return ctx.resp_body or '' diff --git a/apisix/http/vars.lua b/apisix/http/vars.lua deleted file mode 100644 index cab18e3a8297..000000000000 --- a/apisix/http/vars.lua +++ /dev/null @@ -1,95 +0,0 @@ --- --- 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. --- - -local core = require("apisix.core") -local ngx_re = require("ngx.re") -local ngx = ngx -local string = string -local string_rep = string.rep -local ngx_decode_base64 = ngx.decode_base64 - -local _M = { - version = 0.1, -} - - -local function get_bearer_token(ctx) - local auth_header = core.request.header(ctx, "Authorization") - if not auth_header then - return nil - end - - local parts = ngx_re.split(auth_header, " ", nil, nil, 2) - if not parts or #parts < 2 then - return nil - end - - if string.lower(parts[1]) ~= "bearer" then - return nil - end - - return parts[2] -end - - -local function decode_jwt_payload(token) - local parts = ngx_re.split(token, "\\.", nil, nil, 3) - if not parts or #parts < 2 then - return nil - end - - local payload = parts[2] - payload = payload:gsub("-", "+"):gsub("_", "/") - local remainder = #payload % 4 - if remainder > 0 then - payload = payload .. string_rep("=", 4 - remainder) - end - - local payload_raw = ngx_decode_base64(payload) - if not payload_raw then - return nil - end - - local decoded = core.json.decode(payload_raw) - if not decoded or type(decoded) ~= "table" then - return nil - end - - return decoded -end - - -core.ctx.register_var("jwt_iss", function(ctx) - local token = get_bearer_token(ctx) - if not token then - return nil - end - - local payload = decode_jwt_payload(token) - if not payload then - return nil - end - - if type(payload.iss) ~= "string" then - return nil - end - - return payload.iss -end) - - -return _M diff --git a/apisix/init.lua b/apisix/init.lua index e2e88dffac37..5fc874254449 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -71,7 +71,6 @@ local control_api_router local is_http = false if ngx.config.subsystem == "http" then is_http = true - require("apisix.http.vars") control_api_router = require("apisix.control.router") end