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
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
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
- return structured introspection failure details so callers can distinguish
invalid tokens from unavailable endpoints and configuration failures
- require introspection responses to contain a boolean `active` claim, treating
truthy non-boolean values as invalid responses
- release 1.9.2

09/05/2026
Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,14 @@ lAc5Csj0o5Q+oEhPUAVBIF07m4rd0OvAVPOCQ2NJhQSL1oWASbf+fg==

Sample `nginx.conf` configuration for validating Bearer Access Tokens against a PingFederate OAuth 2.0 Authorization Server.

`introspect` returns `res, err, endpoint_status, failure`. On failure,
`failure.kind` classifies the cause and `failure.http_status` recommends the
client response status. The classifications are `invalid_request`,
`invalid_token`, `endpoint_unavailable`, `invalid_response`, and
`configuration_error`. The numeric `endpoint_status` is preserved separately
because an authorization server's status is not necessarily the correct status
for the protected resource to return to its client.

```nginx
events {
worker_connections 128;
Expand Down Expand Up @@ -625,12 +633,12 @@ http {
}

-- call introspect for OAuth 2.0 Bearer Access Token validation
local res, err = require("resty.openidc").introspect(opts)
local res, err, endpoint_status, failure = require("resty.openidc").introspect(opts)

if err then
ngx.status = 403
ngx.status = failure and failure.http_status or 500
ngx.say(err)
ngx.exit(ngx.HTTP_FORBIDDEN)
ngx.exit(ngx.status)
end

-- at this point res is a Lua table that represents the JSON
Expand Down Expand Up @@ -740,12 +748,12 @@ http {
}

-- call introspect for OAuth 2.0 Bearer Access Token validation
local res, err = require("resty.openidc").introspect(opts)
local res, err, endpoint_status, failure = require("resty.openidc").introspect(opts)

if err then
ngx.status = 403
ngx.status = failure and failure.http_status or 500
ngx.say(err)
ngx.exit(ngx.HTTP_FORBIDDEN)
ngx.exit(ngx.status)
end

-- at this point res is a Lua table that represents the JSON
Expand Down
108 changes: 82 additions & 26 deletions lib/resty/openidc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -910,7 +910,7 @@ function openidc.call_token_endpoint(opts, endpoint, body, auth, endpoint_name,

local ep_name = endpoint_name or 'token'
if not endpoint then
return nil, 'no endpoint URI for ' .. ep_name
return nil, 'no endpoint URI for ' .. ep_name, nil, "configuration_error"
end

local headers = {
Expand Down Expand Up @@ -970,12 +970,12 @@ function openidc.call_token_endpoint(opts, endpoint, body, auth, endpoint_name,
local proof_err
res, err, proof_err = request_token_endpoint()
if proof_err then
return nil, err
return nil, err, nil, "configuration_error"
end
if not res then
err = "accessing " .. ep_name .. " endpoint (" .. endpoint .. ") failed: " .. err
log(ERROR, err)
return nil, err
return nil, err, nil, "endpoint_unavailable"
end

local dpop_nonce = ep_name == "token" and opts.use_dpop and openidc_dpop_nonce_from_response(res, { 400, 401 })
Expand All @@ -984,12 +984,12 @@ function openidc.call_token_endpoint(opts, endpoint, body, auth, endpoint_name,
log(DEBUG, "retrying " .. ep_name .. " endpoint call with DPoP nonce")
res, err, proof_err = request_token_endpoint(dpop_nonce)
if proof_err then
return nil, err
return nil, err, nil, "configuration_error"
end
if not res then
err = "accessing " .. ep_name .. " endpoint (" .. endpoint .. ") failed: " .. err
log(ERROR, err)
return nil, err
return nil, err, nil, "endpoint_unavailable"
end
end

Expand All @@ -1004,18 +1004,22 @@ function openidc.call_token_endpoint(opts, endpoint, body, auth, endpoint_name,
local json
json, err = openidc_parse_json_response(res, ignore_body_on_success, expected_status)
if err then
return nil, err
local successful_status = openidc_is_expected_response_status(
res.status, expected_status or 200)
local failure_kind = successful_status and
"invalid_response" or "endpoint_unavailable"
return nil, err, res.status, failure_kind
end
if ep_name == "token" and opts.use_dpop then
err = openidc_validate_dpop_token_response(json)
if err then
return nil, err
return nil, err, res.status, "invalid_response"
end
if response_dpop_nonce then
json._dpop_nonce = response_dpop_nonce
end
end
return json, nil
return json, nil, res.status
end

-- computes access_token expires_in value (in seconds)
Expand Down Expand Up @@ -2347,6 +2351,13 @@ local function set_cached_introspection(opts, access_token, encoded_json, ttl)
end
end

local function introspection_failure(kind, http_status)
return {
kind = kind,
http_status = http_status,
}
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 @@ -2381,20 +2392,43 @@ local function introspect_access_token(opts, access_token)
local err
introspection_endpoint, err = get_introspection_endpoint(opts)
if err then
return nil, err
local kind = type(opts.discovery) == "string" and
"endpoint_unavailable" or "configuration_error"
local http_status = kind == "endpoint_unavailable" and
ngx.HTTP_SERVICE_UNAVAILABLE or ngx.HTTP_INTERNAL_SERVER_ERROR
return nil, err, nil, introspection_failure(kind, http_status)
end
if not introspection_endpoint then
return nil, "no endpoint URI for introspection", nil,
introspection_failure("configuration_error", ngx.HTTP_INTERNAL_SERVER_ERROR)
end
local json
json, err = openidc.call_token_endpoint(opts, introspection_endpoint, body, opts.introspection_endpoint_auth_method, "introspection")
local endpoint_status
local failure_kind
json, err, endpoint_status, failure_kind = openidc.call_token_endpoint(
opts, introspection_endpoint, body,
opts.introspection_endpoint_auth_method, "introspection")

if not json then
return json, err
if json == nil then
local kind = failure_kind or "endpoint_unavailable"
local http_status = kind == "configuration_error" and
ngx.HTTP_INTERNAL_SERVER_ERROR or ngx.HTTP_SERVICE_UNAVAILABLE
return json, err, endpoint_status,
introspection_failure(kind, http_status)
end

if type(json) ~= "table" or type(json.active) ~= "boolean" then
err = "introspection response active claim is not a boolean"
return json, err, endpoint_status,
introspection_failure("invalid_response", ngx.HTTP_SERVICE_UNAVAILABLE)
end

-- check if negative cache should be in use
local introspection_enable_negative_cache = opts.introspection_enable_negative_cache or false
if not json.active and not introspection_enable_negative_cache then
if json.active == false and not introspection_enable_negative_cache then
err = "invalid token"
return json, err
return json, err, endpoint_status,
introspection_failure("invalid_token", ngx.HTTP_UNAUTHORIZED)
end

-- cache the results
Expand All @@ -2417,19 +2451,29 @@ local function introspect_access_token(opts, access_token)
set_cached_introspection(opts, access_token, cjson.encode(json), ttl)
end

if not json.active then
if json.active == false then
err = "invalid token"
return json, err, endpoint_status,
introspection_failure("invalid_token", ngx.HTTP_UNAUTHORIZED)
end

return json, err
return json, err, endpoint_status
end

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

if not json or not json.active then
if type(json) ~= "table" or type(json.active) ~= "boolean" then
err = "cached introspection response active claim is not a boolean"
return json, err, nil,
introspection_failure("invalid_response", ngx.HTTP_SERVICE_UNAVAILABLE)
end

if json.active == false then
err = "invalid cached token"
return json, err, nil,
introspection_failure("invalid_token", ngx.HTTP_UNAUTHORIZED)
end

return json, err
Expand Down Expand Up @@ -2526,7 +2570,8 @@ 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
return nil, err, nil,
introspection_failure("invalid_request", ngx.HTTP_UNAUTHORIZED)
end

if opts.introspection_cache_ignore then
Expand Down Expand Up @@ -2555,10 +2600,12 @@ function openidc.introspect(opts)
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"
return nil, "introspection_lock_timeout must be a non-negative number", nil,
introspection_failure("configuration_error", ngx.HTTP_INTERNAL_SERVER_ERROR)
end
if type(lock_exptime) ~= "number" or lock_exptime <= 0 then
return nil, "introspection_lock_exptime must be a positive number"
return nil, "introspection_lock_exptime must be a positive number", nil,
introspection_failure("configuration_error", ngx.HTTP_INTERNAL_SERVER_ERROR)
end

local lock_state
Expand All @@ -2573,7 +2620,8 @@ function openidc.introspect(opts)
if lock_state == "completed" then
local completed = cjson_s.decode(coordination_value)
if completed then
return completed.json, completed.err
return completed.json, completed.err, completed.endpoint_status,
completed.failure
end
log(WARN, "failed to decode published introspection result; " ..
"falling back to direct introspection")
Expand Down Expand Up @@ -2610,17 +2658,25 @@ function openidc.introspect(opts)
local completed = cjson_s.decode(completed_value)
if completed then
release_introspection_lock(introspection_cache, lock_key, lock_owner)
return completed.json, completed.err
return completed.json, completed.err, completed.endpoint_status,
completed.failure
end
end

local json
json, err = introspect_access_token(opts, access_token)
local endpoint_status
local failure
json, err, endpoint_status, failure = 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 })
local completed, encode_err = cjson_s.encode({
json = json,
err = err,
endpoint_status = endpoint_status,
failure = failure,
})
if completed then
publish_introspection_result(
introspection_cache, result_key_prefix .. lock_owner,
Expand All @@ -2631,7 +2687,7 @@ function openidc.introspect(opts)
end

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

end

Expand Down
Loading
Loading