This is an automated email from the ASF dual-hosted git repository.
shreemaan-abhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 2e27e9b4b0 feat(openid-connect): upgrade lua-resty-openidc to 1.9.3
(#14088)
2e27e9b4b0 is described below
commit 2e27e9b4b0a368f62847edcdc2c4028e86f80c3e
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Thu Oct 29 12:05:14 2026 +0800
feat(openid-connect): upgrade lua-resty-openidc to 1.9.3 (#14088)
---
apisix-master-0.rockspec | 4 +-
apisix/plugins/openid-connect.lua | 12 +-
docs/en/latest/plugins/openid-connect.md | 2 +
t/lib/oidc_introspection.lua | 131 ++++++++++++++
t/plugin/openid-connect-introspection.t | 298 +++++++++++++++++++++++++++++++
t/plugin/openid-connect6.t | 2 +-
6 files changed, 442 insertions(+), 7 deletions(-)
diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index ee849849ef..7c274d5048 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -47,12 +47,12 @@ dependencies = {
"api7-lua-resty-jwt = 0.2.6-0",
"lua-resty-hmac-ffi = 0.06-1",
"lua-resty-cookie = 0.4.1-1",
- "lua-resty-session = 4.1.5-1",
+ "lua-resty-session = 4.2.0-1",
"lua-resty-openapi-validator = 1.0.6-1",
"opentracing-openresty = 0.1-0",
"lua-resty-radixtree = 2.9.2-0",
"lua-protobuf = 0.5.3-1",
- "lua-resty-openidc = 1.9.0-1",
+ "lua-resty-openidc = 1.9.3-1",
"lua-resty-saml = 0.2.7",
"luafilesystem = 1.8.0-1",
"nginx-lua-prometheus-api7 = 1.1.0-1",
diff --git a/apisix/plugins/openid-connect.lua
b/apisix/plugins/openid-connect.lua
index c5cf6a6792..fa3fbf8174 100644
--- a/apisix/plugins/openid-connect.lua
+++ b/apisix/plugins/openid-connect.lua
@@ -1101,13 +1101,17 @@ local function introspect(ctx, conf)
end
end
- local res, err = openidc.introspect(conf)
+ local res, err, _, failure = openidc.introspect(conf)
conf.http_request_decorator = nil
if err then
- ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm ..
- '", error="invalid_token", error_description="' .. err .. '"'
- return ngx.HTTP_UNAUTHORIZED, err, nil, nil
+ local status = failure and failure.http_status or
+ ngx.HTTP_INTERNAL_SERVER_ERROR
+ if failure and failure.kind == "invalid_token" then
+ ngx.header["WWW-Authenticate"] = 'Bearer realm="' ..
conf.realm ..
+ '", error="invalid_token", error_description="' .. err ..
'"'
+ end
+ return status, err, nil, nil
end
local valid_issuers = core.table.try_read_attr(conf, "claim_validator",
diff --git a/docs/en/latest/plugins/openid-connect.md
b/docs/en/latest/plugins/openid-connect.md
index 798b541844..f4778bfa53 100644
--- a/docs/en/latest/plugins/openid-connect.md
+++ b/docs/en/latest/plugins/openid-connect.md
@@ -425,6 +425,8 @@ The introspection flow is defined in [RFC
7662](https://datatracker.ietf.org/doc
In this flow, when a client presents an access token to the resource server,
the resource server sends a request to the authorization server's introspection
endpoint, which responds with token details if the token is active, including
information like token expiration, associated scopes, and the user or client it
belongs to.
+When introspection confirms that a token is inactive, the Plugin returns `401
Unauthorized` with an `invalid_token` challenge. If introspection cannot be
completed because the endpoint is unavailable, times out, or returns an invalid
response, the Plugin returns `503 Service Unavailable` without an
`invalid_token` challenge. Concurrent cache misses for the same token are
coalesced while introspection caching is enabled; if coordination times out,
the request falls back to direct introspection.
+
The following diagram illustrates the interaction between different entities
when you implement the authorization code flow with token introspection:

diff --git a/t/lib/oidc_introspection.lua b/t/lib/oidc_introspection.lua
new file mode 100644
index 0000000000..d684360d11
--- /dev/null
+++ b/t/lib/oidc_introspection.lua
@@ -0,0 +1,131 @@
+--
+-- 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 http = require("resty.http")
+local test_admin = require("lib.test_admin").test
+
+
+local _M = {}
+
+
+local function request_uri(uri, opts)
+ local res, err = http.new():request_uri(uri, opts)
+ if not res then
+ error(err)
+ end
+ return res
+end
+
+
+function _M.configure(mode, timeout)
+ local endpoint = "http://127.0.0.1:1970/introspect?mode=" .. mode
+ if mode == "unreachable" then
+ endpoint = "http://127.0.0.1:1979/introspect"
+ end
+
+ local route = {
+ uri = "/protected",
+ plugins = {
+ ["openid-connect"] = {
+ client_id = "test-client",
+ client_secret = "test-secret",
+ discovery =
"http://127.0.0.1:1970/.well-known/openid-configuration",
+ introspection_endpoint = endpoint,
+ introspection_endpoint_auth_method = "client_secret_post",
+ introspection_interval = 60,
+ bearer_only = true,
+ ssl_verify = false,
+ timeout = timeout or 3,
+ },
+ },
+ upstream = {
+ type = "roundrobin",
+ nodes = {
+ ["127.0.0.1:1970"] = 1,
+ },
+ },
+ }
+
+ local code, body = test_admin(
+ "/apisix/admin/routes/oidc-introspection", ngx.HTTP_PUT,
+ core.json.encode(route))
+ if code >= 300 then
+ error(body)
+ end
+
+ ngx.sleep(0.5)
+ request_uri("http://127.0.0.1:1970/reset")
+end
+
+
+local function introspect(token)
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/protected"
+ local res = request_uri(uri, {
+ method = "GET",
+ headers = {
+ Authorization = "Bearer " .. token,
+ },
+ })
+ local challenge = res.headers["WWW-Authenticate"] or
+ res.headers["www-authenticate"] or ""
+ return res.status, challenge:find('error="invalid_token"', 1, true) ~= nil
+end
+
+
+function _M.request(token)
+ return introspect(token)
+end
+
+
+function _M.concurrent(token, count, stagger_after_first)
+ local threads = {}
+ for i = 1, count do
+ threads[i] = assert(ngx.thread.spawn(introspect, token))
+ if i == 1 and stagger_after_first then
+ local deadline = ngx.now() + 2
+ repeat
+ ngx.sleep(0.01)
+ until _M.upstream_calls() == 1 or ngx.now() >= deadline
+ ngx.sleep(stagger_after_first)
+ end
+ end
+
+ local statuses = {}
+ local invalid_token_challenges = 0
+ for _, thread in ipairs(threads) do
+ local ok, status, invalid_token = ngx.thread.wait(thread)
+ if not ok then
+ error(status)
+ end
+ statuses[status] = (statuses[status] or 0) + 1
+ if invalid_token then
+ invalid_token_challenges = invalid_token_challenges + 1
+ end
+ end
+
+ return statuses, invalid_token_challenges
+end
+
+
+function _M.upstream_calls()
+ local res = request_uri("http://127.0.0.1:1970/calls")
+ return tonumber(res.body)
+end
+
+
+return _M
diff --git a/t/plugin/openid-connect-introspection.t
b/t/plugin/openid-connect-introspection.t
new file mode 100644
index 0000000000..f912219be3
--- /dev/null
+++ b/t/plugin/openid-connect-introspection.t
@@ -0,0 +1,298 @@
+#
+# 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);
+log_level('debug');
+worker_connections(512);
+no_long_string();
+no_shuffle();
+no_root_location();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ if (!$block->error_log && !$block->no_error_log) {
+ $block->set_value("no_error_log", "[alert]");
+ }
+
+ my $http_config = <<_EOC_;
+ lua_shared_dict introspection 10m;
+ lua_shared_dict oidc_introspection_test 1m;
+
+ server {
+ listen 1970;
+
+ location = /introspect {
+ content_by_lua_block {
+ local dict = ngx.shared.oidc_introspection_test
+ dict:incr("calls", 1, 0)
+
+ local mode = ngx.var.arg_mode
+ if mode == "slow" then
+ ngx.sleep(7)
+ elseif mode == "timeout" then
+ ngx.sleep(2)
+ elseif mode == "active" or mode == "active-no-exp" or
+ mode == "failure" then
+ ngx.sleep(1)
+ end
+
+ ngx.header.content_type = "application/json"
+ if mode == "failure" then
+ ngx.status = 503
+ ngx.say('{"error":"temporarily unavailable"}')
+ elseif mode == "inactive" then
+ ngx.say('{"active":false}')
+ elseif mode == "missing-active" then
+ ngx.say('{"sub":"customer"}')
+ elseif mode == "nonboolean-active" then
+ ngx.say('{"active":"true","sub":"customer"}')
+ elseif mode == "active-no-exp" then
+ ngx.say('{"active":true,"sub":"customer"}')
+ else
+ ngx.say('{"active":true,"sub":"customer","exp":' ..
+ (ngx.time() + 60) .. '}')
+ end
+ }
+ }
+
+ location = /reset {
+ content_by_lua_block {
+ ngx.shared.oidc_introspection_test:flush_all()
+ ngx.say("ok")
+ }
+ }
+
+ location = /calls {
+ content_by_lua_block {
+ ngx.say(ngx.shared.oidc_introspection_test:get("calls") or 0)
+ }
+ }
+
+ location / {
+ content_by_lua_block {
+ ngx.say("upstream reached")
+ }
+ }
+ }
+_EOC_
+
+ $block->set_value("http_config", $http_config);
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: concurrent cache misses for one active token are coalesced
+--- config
+ location /t {
+ content_by_lua_block {
+ local test = require("lib.oidc_introspection")
+ test.configure("active")
+ local statuses, invalid = test.concurrent("active-token", 35)
+ ngx.say("status 200: ", statuses[200] or 0)
+ ngx.say("invalid_token challenges: ", invalid)
+ ngx.say("upstream calls: ", test.upstream_calls())
+ }
+ }
+--- timeout: 10
+--- response_body
+status 200: 35
+invalid_token challenges: 0
+upstream calls: 1
+
+
+
+=== TEST 2: concurrent endpoint overload is shared as service unavailable
+--- config
+ location /t {
+ content_by_lua_block {
+ local test = require("lib.oidc_introspection")
+ test.configure("failure")
+ local statuses, invalid = test.concurrent("failure-token", 35)
+ ngx.say("status 503: ", statuses[503] or 0)
+ ngx.say("invalid_token challenges: ", invalid)
+ ngx.say("upstream calls: ", test.upstream_calls())
+ }
+ }
+--- timeout: 10
+--- response_body
+status 503: 35
+invalid_token challenges: 0
+upstream calls: 1
+--- error_log
+OIDC introspection failed: response indicates failure, status=503
+
+
+
+=== TEST 3: an inactive token remains unauthorized and invalid
+--- config
+ location /t {
+ content_by_lua_block {
+ local test = require("lib.oidc_introspection")
+ test.configure("inactive")
+ local status, invalid = test.request("inactive-token")
+ ngx.say("status: ", status)
+ ngx.say("invalid_token challenge: ", invalid)
+ ngx.say("upstream calls: ", test.upstream_calls())
+ }
+ }
+--- response_body
+status: 401
+invalid_token challenge: true
+upstream calls: 1
+--- error_log
+OIDC introspection failed: invalid token
+
+
+
+=== TEST 4: a transport error is service unavailable
+--- config
+ location /t {
+ content_by_lua_block {
+ local test = require("lib.oidc_introspection")
+ test.configure("unreachable")
+ local status, invalid = test.request("transport-error-token")
+ ngx.say("status: ", status)
+ ngx.say("invalid_token challenge: ", invalid)
+ ngx.say("upstream calls: ", test.upstream_calls())
+ }
+ }
+--- response_body
+status: 503
+invalid_token challenge: false
+upstream calls: 0
+--- error_log
+OIDC introspection failed: accessing introspection endpoint
+
+
+
+=== TEST 5: an introspection read timeout is service unavailable
+--- config
+ location /t {
+ content_by_lua_block {
+ local test = require("lib.oidc_introspection")
+ test.configure("timeout", 1)
+ local status, invalid = test.request("read-timeout-token")
+ ngx.say("status: ", status)
+ ngx.say("invalid_token challenge: ", invalid)
+ ngx.say("upstream calls: ", test.upstream_calls())
+ }
+ }
+--- timeout: 5
+--- response_body
+status: 503
+invalid_token challenge: false
+upstream calls: 1
+--- error_log
+OIDC introspection failed: accessing introspection endpoint
+
+
+
+=== TEST 6: a coordination timeout falls back to direct introspection
+--- config
+ location /t {
+ content_by_lua_block {
+ local test = require("lib.oidc_introspection")
+ test.configure("slow", 10)
+ local statuses, invalid = test.concurrent("lock-timeout-token", 2,
0.1)
+ ngx.say("status 200: ", statuses[200] or 0)
+ ngx.say("status 503: ", statuses[503] or 0)
+ ngx.say("invalid_token challenges: ", invalid)
+ ngx.say("upstream calls: ", test.upstream_calls())
+ }
+ }
+--- timeout: 15
+--- response_body
+status 200: 2
+status 503: 0
+invalid_token challenges: 0
+upstream calls: 2
+--- error_log
+failed to acquire introspection lock: timeout; falling back to direct
introspection
+
+
+
+=== TEST 7: a non-cacheable active response is shared only in flight
+--- config
+ location /t {
+ content_by_lua_block {
+ local test = require("lib.oidc_introspection")
+ test.configure("active-no-exp")
+ local statuses, invalid = test.concurrent("no-exp-token", 20)
+ local later_status = test.request("no-exp-token")
+ ngx.say("batch status 200: ", statuses[200] or 0)
+ ngx.say("later status: ", later_status)
+ ngx.say("invalid_token challenges: ", invalid)
+ ngx.say("upstream calls: ", test.upstream_calls())
+ }
+ }
+--- timeout: 10
+--- response_body
+batch status 200: 20
+later status: 200
+invalid_token challenges: 0
+upstream calls: 2
+
+
+
+=== TEST 8: a response without active is service unavailable
+--- config
+ location /t {
+ content_by_lua_block {
+ local test = require("lib.oidc_introspection")
+ test.configure("missing-active")
+ local status, invalid = test.request("missing-active-token")
+ ngx.say("status: ", status)
+ ngx.say("invalid_token challenge: ", invalid)
+ ngx.say("upstream calls: ", test.upstream_calls())
+ }
+ }
+--- response_body
+status: 503
+invalid_token challenge: false
+upstream calls: 1
+--- error_log
+OIDC introspection failed: introspection response active claim is not a boolean
+
+
+
+=== TEST 9: a non-boolean active response is service unavailable
+--- config
+ location /t {
+ content_by_lua_block {
+ local test = require("lib.oidc_introspection")
+ test.configure("nonboolean-active")
+ local status, invalid = test.request("nonboolean-active-token")
+ ngx.say("status: ", status)
+ ngx.say("invalid_token challenge: ", invalid)
+ ngx.say("upstream calls: ", test.upstream_calls())
+ }
+ }
+--- response_body
+status: 503
+invalid_token challenge: false
+upstream calls: 1
+--- error_log
+OIDC introspection failed: introspection response active claim is not a boolean
diff --git a/t/plugin/openid-connect6.t b/t/plugin/openid-connect6.t
index 5f30ea556f..e4569a4e6a 100644
--- a/t/plugin/openid-connect6.t
+++ b/t/plugin/openid-connect6.t
@@ -353,7 +353,7 @@ passed
ngx.status = res.status
}
}
---- error_code: 401
+--- error_code: 503
--- error_log
OIDC introspection failed: JSON decoding failed
--- grep_error_log eval