Skip to content
Merged
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
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
09/10/2026
- coalesce concurrent introspection cache misses for the same token, allowing
waiting requests to share successful and failed results

09/13/204
- cross-tenant requests are fixed with lua-resty session 4.0.x; closes #526
- release 1.8.0
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,11 @@ Currently up to four caches are used
introspection. Cache items expire when the corresponding token
expires. Tokens with unknown expiry are not cached at all. This
cache will contain one entry per introspected access token - usually
this will be a few kB per token.
this will be a few kB per token. Concurrent cache misses for the same
token and cache segment are coalesced within an NGINX instance. One
request calls the introspection endpoint while the others wait for and
share its result, including endpoint failures and responses without an
expiry claim.
* the cache named `jwt_verification` stores the result of JWT
verification. Cache items expire when the corresponding token
expires. Tokens with unknown expiry are not cached for two
Expand Down Expand Up @@ -620,6 +624,15 @@ http {
-- see introspection_expiry_claim) hint as returned by the Authorization Server
-- introspection_interval = 60,

-- Maximum time in seconds that a concurrent introspection waits
-- for the request which owns the per-token lock. Defaults to 5.
-- introspection_lock_timeout = 5,

-- Time in seconds after which an abandoned per-token lock expires.
-- This should exceed the maximum introspection request duration.
-- Defaults to 30.
-- introspection_lock_exptime = 30,

-- Defines the way in which bearer OAuth 2.0 access tokens can be passed to this Resource Server.
-- "cookie" as a cookie header called "PA.global" or using the name specified after ":"
-- "header" "Authorization: bearer" header
Expand Down
171 changes: 151 additions & 20 deletions lib/resty/openidc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ local b64 = ngx.encode_base64
local b64url = require("ngx.base64").encode_base64url
local unb64url = require("ngx.base64").decode_base64url

local function openidc_sha256(value)
local sha256 = (require "resty.sha256"):new()
sha256:update(value)
return sha256:final()
end

local log = ngx.log
local DEBUG = ngx.DEBUG
local ERROR = ngx.ERR
Expand Down Expand Up @@ -1748,41 +1754,28 @@ local function get_introspection_cache_prefix(opts)
.. (opts.client_secret and 'secret' or 'no-client_secret') .. ':'
end

local function get_introspection_cache_key(opts, access_token)
return get_introspection_cache_prefix(opts) .. access_token
end

local function get_cached_introspection(opts, access_token)
local introspection_cache_ignore = opts.introspection_cache_ignore or false
if not introspection_cache_ignore then
return openidc_cache_get("introspection",
get_introspection_cache_prefix(opts) .. access_token)
get_introspection_cache_key(opts, access_token))
end
end

local function set_cached_introspection(opts, access_token, encoded_json, ttl)
local introspection_cache_ignore = opts.introspection_cache_ignore or false
if not introspection_cache_ignore then
openidc_cache_set("introspection",
get_introspection_cache_prefix(opts) .. access_token,
get_introspection_cache_key(opts, access_token),
encoded_json, ttl)
end
end

-- main routine for OAuth 2.0 token introspection
function openidc.introspect(opts)

-- get the access token from the request
local access_token, err = openidc_get_bearer_access_token(opts)
if access_token == nil then
return nil, err
end

-- see if we've previously cached the introspection result for this access token
local json
local v = get_cached_introspection(opts, access_token)

if v then
json = cjson.decode(v)
return json, err
end

local function introspect_access_token(opts, access_token)
-- assemble the parameters to the introspection (token) endpoint
local token_param_name = opts.introspection_token_param_name and opts.introspection_token_param_name or "token"

Expand All @@ -1804,10 +1797,12 @@ function openidc.introspect(opts)

-- call the introspection endpoint
local introspection_endpoint
local err
introspection_endpoint, err = get_introspection_endpoint(opts)
if err then
return nil, err
end
local json
json, err = openidc.call_token_endpoint(opts, introspection_endpoint, body, opts.introspection_endpoint_auth_method, "introspection")


Expand Down Expand Up @@ -1840,6 +1835,142 @@ function openidc.introspect(opts)
end

return json, err
end

local function decode_cached_introspection(value)
local json = cjson.decode(value)
local err

if not json or not json.active then
err = "invalid cached token"
end

return json, err
end

local function acquire_introspection_lock(dict, key, timeout, exptime)
local ok, err = dict:add(key, true, exptime)
if ok then
return true
end
if err ~= "exists" then
return nil, err
end

local elapsed = 0
local step = 0.001
while elapsed < timeout do
step = math.min(step, timeout - elapsed)
ngx.sleep(step)
elapsed = elapsed + step

ok, err = dict:add(key, true, exptime)
if ok then
return true
end
if err ~= "exists" then
return nil, err
end

step = math.min(step * 2, 0.5)
end

return nil, "timeout"
end

-- main routine for OAuth 2.0 token introspection
function openidc.introspect(opts)

-- get the access token from the request
local access_token, err = openidc_get_bearer_access_token(opts)
if access_token == nil then
return nil, err
end

if opts.introspection_cache_ignore then
return introspect_access_token(opts, access_token)
end

-- avoid lock overhead for normal cache hits
local value = get_cached_introspection(opts, access_token)
if value then
return decode_cached_introspection(value)
end

-- Without the shared cache there is nowhere to coordinate workers. Preserve
-- the existing uncached behavior for configurations that omit the dictionary.
local introspection_cache = ngx.shared.introspection
if not introspection_cache then
return introspect_access_token(opts, access_token)
end

local cache_key = get_introspection_cache_key(opts, access_token)
local digest = b64url(openidc_sha256(cache_key))
local lock_key = "openidc-introspection-lock:" .. digest
local result_key = "openidc-introspection-result:" .. digest
local started_at = ngx.now()
local lock_timeout = opts.introspection_lock_timeout or 5
local lock_exptime = opts.introspection_lock_exptime or 30

if type(lock_timeout) ~= "number" or lock_timeout < 0 then
return nil, "introspection_lock_timeout must be a non-negative number"
end
if type(lock_exptime) ~= "number" or lock_exptime <= 0 then
return nil, "introspection_lock_exptime must be a positive number"
end

local locked
locked, err = acquire_introspection_lock(introspection_cache, lock_key,
lock_timeout, lock_exptime)
if not locked then
return nil, "failed to acquire introspection lock: " .. err
end

-- Another request may have populated the regular cache while this request
-- waited for the lock.
value = get_cached_introspection(opts, access_token)
if value then
introspection_cache:delete(lock_key)
return decode_cached_introspection(value)
end

-- Responses which cannot enter the regular cache (including endpoint
-- failures) are published briefly so requests that started during the same
-- in-flight lookup can share the outcome. Requests that start after the
-- lookup completed do not reuse this record.
local completed_value = introspection_cache:get(result_key)
if completed_value then
local completed = cjson_s.decode(completed_value)
if completed and completed.completed_at >= started_at then
introspection_cache:delete(lock_key)
return completed.json, completed.err
end
end

local json
json, err = introspect_access_token(opts, access_token)

-- A cacheable response is already visible to waiters. Publish only outcomes
-- that the regular introspection cache did not retain.
if not get_cached_introspection(opts, access_token) then
local completed, encode_err = cjson_s.encode({
completed_at = ngx.now(),
json = json,
err = err,
})
if completed then
local result_ttl = math.max(lock_timeout, 1)
local ok, set_err = introspection_cache:set(result_key, completed, result_ttl)
if not ok then
log(WARN, "failed to publish introspection result: " .. set_err)
end
else
log(WARN, "failed to encode introspection result: " .. encode_err)
end
end

introspection_cache:delete(lock_key)
return json, err

end

Expand Down
71 changes: 69 additions & 2 deletions tests/spec/introspection_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ local function assert_introspection_endpoint_call_contains(s, case_insensitive)
case_insensitive)
end

local function error_log_occurrences(s)
local count = 0
local pos = 1
local log = test_support.load("/tmp/server/logs/error.log")
while true do
pos = log:find(s, pos, true)
if not pos then
return count
end
count = count + 1
pos = pos + #s
end
end

local function request_introspection_concurrently(jwt, count, expected_status)
expected_status = expected_status or "200"
local output = "/tmp/introspection-statuses"
os.remove(output)
local command = "seq 1 " .. count .. " | xargs -P " .. count ..
" -I '{}' curl -sS -o /dev/null -w '%{http_code}\\n'" ..
" -H 'Authorization: Bearer " .. jwt .. "'" ..
" http://127.0.0.1/introspect > " .. output
local ok = os.execute(command)
assert.truthy(ok == true or ok == 0)

local statuses = test_support.load(output)
local seen = 0
for status in statuses:gmatch("%d+") do
assert.are.equals(expected_status, status)
seen = seen + 1
end
assert.are.equals(count, seen)
end

describe("when the introspection endpoint is invoked", function()
test_support.start_server()
teardown(test_support.stop_server)
Expand Down Expand Up @@ -370,7 +404,41 @@ describe("when the response is active but lacks the exp claim", function()
end)
end)

-- TODO find a way to assert caching
describe("when a batch sends 35 concurrent requests with the same uncached token", function()
test_support.start_server({
delay_response = { introspection = 1000 },
introspection_opts = { introspection_cache_ignore = false },
})
teardown(test_support.stop_server)
local jwt = test_support.trim(http.request("http://127.0.0.1/jwt"))
request_introspection_concurrently(jwt, 35)

it("coalesces the batch into one introspection endpoint call", function()
assert.are.equals(1, error_log_occurrences("Received introspection request:"))
end)
end)

describe("when concurrent requests introspect a response without an expiry", function()
test_support.start_server({
delay_response = { introspection = 300 },
remove_introspection_claims = { "exp" },
introspection_opts = { introspection_cache_ignore = false },
})
teardown(test_support.stop_server)
local jwt = test_support.trim(http.request("http://127.0.0.1/jwt"))
request_introspection_concurrently(jwt, 20)

it("shares the in-flight response without caching it for later requests", function()
assert.are.equals(1, error_log_occurrences("Received introspection request:"))
os.execute("sleep 0.1")
local _, status = http.request({
url = "http://127.0.0.1/introspect",
headers = { authorization = "Bearer " .. jwt }
})
assert.are.equals(200, status)
assert.are.equals(2, error_log_occurrences("Received introspection request:"))
end)
end)

describe("when introspection endpoint is not resolvable", function()
test_support.start_server({
Expand Down Expand Up @@ -564,4 +632,3 @@ describe("when introspection endpoint hasn't been specified but discovery doc pr
assert.are.equals(200, status)
end)
end)

3 changes: 3 additions & 0 deletions tests/spec/test_support.lua
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ local DEFAULT_INTROSPECTION_OPTS = {
introspection_endpoint = "http://127.0.0.1/introspection",
client_id = "client_id",
client_secret = "client_secret",
-- Most specs inspect the outgoing request, so caching is opt-in in tests.
introspection_cache_ignore = true,
}

local DEFAULT_TOKEN_RESPONSE_EXPIRES_IN = "3600"
Expand Down Expand Up @@ -151,6 +153,7 @@ http {
access_log /tmp/server/logs/access.log;
lua_package_path '~/lua/?.lua;/tmp/server/conf/?.lua;;';
lua_shared_dict discovery 1m;
lua_shared_dict introspection 1m;
init_by_lua_block {
test_globals = require("test_globals")
}
Expand Down
Loading