Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions apisix/consumer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions apisix/plugins/openid-connect.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/en/latest/plugins/openid-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
87 changes: 87 additions & 0 deletions t/plugin/openid-connect2.t
Original file line number Diff line number Diff line change
Expand Up @@ -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