From 138fbf2425d7e444d17119acc5d969b2e3a6b00d Mon Sep 17 00:00:00 2001 From: Piyush Mishra Date: Thu, 26 Feb 2026 13:23:35 +0530 Subject: [PATCH] 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