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
6 changes: 6 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
09/11/2026
- coalesce concurrent introspection cache misses for the same token, allowing
waiting requests to share successful and failed results
- make introspection coordination honor explicit cache bypass, treat locks as
bounded leases, and fall back to direct introspection on coordination failure

09/05/2026
- propagate session destroy and closed-mode revocation-store failures from
logout, session initialization, and access-token retrieval; see #571;
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,14 @@ 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. If coordination times out or fails, the request rechecks the
cache and then falls back to direct introspection, preserving the behavior
without coordination. Setting `introspection_cache_ignore` disables both
caching and request coalescing.
* 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 @@ -689,6 +696,17 @@ 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.
-- On timeout, the request falls back to direct introspection.
-- introspection_lock_timeout = 5,

-- Lease duration in seconds for the per-token lock. This must
-- exceed the maximum introspection request duration; an owner
-- continuing after its lease expires is not supported. 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
244 changes: 219 additions & 25 deletions lib/resty/openidc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2326,46 +2326,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)

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

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 Down Expand Up @@ -2396,10 +2378,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")

if not json then
Expand Down Expand Up @@ -2438,6 +2422,216 @@ 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 get_introspection_coordination_result(dict, cache_key,
result_key_prefix, observed_owner)
local cached_value = dict:get(cache_key)
if cached_value then
return "cached", cached_value
end

local completed_value = observed_owner and
dict:get(result_key_prefix .. observed_owner)
if completed_value then
return "completed", completed_value
end
end

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

local observed_owner = dict:get(key)
local state, value = get_introspection_coordination_result(
dict, cache_key, result_key_prefix, observed_owner)
if state then
return state, value
end

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

-- The owner publishes before releasing its lock. Check its generation
-- after sleeping and before attempting to become a new owner.
state, value = get_introspection_coordination_result(
dict, cache_key, result_key_prefix, observed_owner)
if state then
return state, value
end

ok, err = dict:add(key, owner, exptime)
if ok then
return "locked", nil, observed_owner
end
if err ~= "exists" then
return nil, err
end
-- add() and get() are not atomic. If the first owner disappeared before
-- it could be observed, remember the first later generation we can see.
observed_owner = observed_owner or dict:get(key)

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

state, value = get_introspection_coordination_result(
dict, cache_key, result_key_prefix, observed_owner)
if state then
return state, value
end

return nil, "timeout"
end

-- ngx.shared.DICT has no atomic compare-and-delete operation, so this owner
-- check only narrows the race window. The lock remains a lease: the owner must
-- normally finish and release it before exptime.
local function release_introspection_lock(dict, key, owner)
if dict:get(key) == owner then
dict:delete(key)
end
end

local function publish_introspection_result(dict, key, value, ttl)
local ok, err = dict:set(key, value, ttl)
if not ok then
log(WARN, "failed to publish introspection result: " .. err)
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

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_prefix = "openidc-introspection-result:" .. digest .. ":"
local lock_owner = openidc_random_jti()
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 lock_state
local coordination_value
local observed_owner
lock_state, coordination_value, observed_owner = acquire_introspection_lock(
introspection_cache, lock_key, lock_owner, lock_timeout, lock_exptime,
cache_key, result_key_prefix)
if lock_state == "cached" then
return decode_cached_introspection(coordination_value)
end
if lock_state == "completed" then
local completed = cjson_s.decode(coordination_value)
if completed then
return completed.json, completed.err
end
log(WARN, "failed to decode published introspection result; " ..
"falling back to direct introspection")
return introspect_access_token(opts, access_token)
end
if lock_state ~= "locked" then
err = coordination_value
-- Coordination is an optimization. Preserve the previous behavior when
-- the lock is unavailable, while avoiding a duplicate call if the owner
-- populated the cache immediately before the timeout.
value = get_cached_introspection(opts, access_token)
if value then
return decode_cached_introspection(value)
end
log(WARN, "failed to acquire introspection lock: ", err,
"; falling back to direct introspection")
return introspect_access_token(opts, access_token)
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
release_introspection_lock(introspection_cache, lock_key, lock_owner)
return decode_cached_introspection(value)
end

-- Responses which cannot enter the regular cache (including endpoint
-- failures) are published briefly under the lock generation. Only requests
-- which observed that generation while waiting can share the outcome.
local completed_value = observed_owner and
introspection_cache:get(result_key_prefix .. observed_owner)
if completed_value and observed_owner then
local completed = cjson_s.decode(completed_value)
if completed then
release_introspection_lock(introspection_cache, lock_key, lock_owner)
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 introspection_cache:get(cache_key) then
local completed, encode_err = cjson_s.encode({ json = json, err = err })
if completed then
publish_introspection_result(
introspection_cache, result_key_prefix .. lock_owner,
completed, math.max(lock_timeout, 1))
else
log(WARN, "failed to encode introspection result: " .. encode_err)
end
end

release_introspection_lock(introspection_cache, lock_key, lock_owner)
return json, err

end

Expand Down
Loading