diff --git a/ChangeLog b/ChangeLog index 6fd82e0..c49c047 100644 --- a/ChangeLog +++ b/ChangeLog @@ -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 diff --git a/README.md b/README.md index 8b19fbb..0c9e33c 100644 --- a/README.md +++ b/README.md @@ -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; @@ -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 @@ -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 diff --git a/lib/resty/openidc.lua b/lib/resty/openidc.lua index a770217..029ec59 100644 --- a/lib/resty/openidc.lua +++ b/lib/resty/openidc.lua @@ -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 = { @@ -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 }) @@ -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 @@ -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) @@ -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" @@ -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 @@ -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 @@ -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 @@ -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 @@ -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") @@ -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, @@ -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 diff --git a/tests/spec/introspection_spec.lua b/tests/spec/introspection_spec.lua index 812ede4..2d88182 100644 --- a/tests/spec/introspection_spec.lua +++ b/tests/spec/introspection_spec.lua @@ -103,7 +103,7 @@ describe("when the introspection endpoint is invoked", function() teardown(test_support.stop_server) local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) describe("without any Authorization header", function() - local _, status = http.request({ + local _, status, headers = http.request({ url = "http://127.0.0.1/introspect" }) it("the token is invalid", function() @@ -112,9 +112,13 @@ describe("when the introspection endpoint is invoked", function() it("an error is logged", function() assert.error_log_contains("no Authorization header found") end) + it("classifies the malformed credential", function() + assert.are.equals("invalid_request", headers["x-introspection-failure-kind"]) + assert.are.equals("401", headers["x-introspection-client-status"]) + end) end) describe("with a bearer token", function() - local _, status = http.request({ + local _, status, headers = http.request({ url = "http://127.0.0.1/introspect", headers = { authorization = "Bearer " .. jwt } }) @@ -136,6 +140,9 @@ describe("when the introspection endpoint is invoked", function() it("the response is valid", function() assert.are.equals(200, status) end) + it("returns the introspection endpoint status", function() + assert.are.equals("200", headers["x-introspection-endpoint-status"]) + end) end) end) @@ -509,7 +516,7 @@ describe("when the response is inactive", function() }) teardown(test_support.stop_server) local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) - local _, status = http.request({ + local _, status, headers = http.request({ url = "http://127.0.0.1/introspect", headers = { authorization = "Bearer " .. jwt } }) @@ -519,6 +526,79 @@ describe("when the response is inactive", function() it("an error has been logged", function() assert.error_log_contains("Introspection error: invalid token") end) + it("classifies the inactive token", function() + assert.are.equals("invalid_token", headers["x-introspection-failure-kind"]) + assert.are.equals("401", headers["x-introspection-client-status"]) + assert.are.equals("200", headers["x-introspection-endpoint-status"]) + end) +end) + +describe("when an inactive response is served from the negative cache", function() + test_support.start_server({ + introspection_response = { + active = false + }, + introspection_opts = { + introspection_cache_ignore = false, + introspection_enable_negative_cache = true, + }, + }) + teardown(test_support.stop_server) + local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) + local request = { + url = "http://127.0.0.1/introspect", + headers = { authorization = "Bearer " .. jwt } + } + local _, first_status, first_headers = http.request(request) + local _, cached_status, cached_headers = http.request(request) + + it("retains the invalid-token classification", function() + assert.are.equals(401, first_status) + assert.are.equals(401, cached_status) + assert.are.equals("invalid_token", + cached_headers["x-introspection-failure-kind"]) + assert.are.equals("401", cached_headers["x-introspection-client-status"]) + end) + it("omits the endpoint status because the cached request made no call", function() + assert.are.equals("200", first_headers["x-introspection-endpoint-status"]) + assert.is_nil(cached_headers["x-introspection-endpoint-status"]) + assert.are.equals(1, error_log_occurrences("Received introspection request:")) + end) +end) + +describe("when the response lacks the active claim", function() + test_support.start_server({ + remove_introspection_claims = { "active" }, + }) + teardown(test_support.stop_server) + local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) + local _, status, headers = http.request({ + url = "http://127.0.0.1/introspect", + headers = { authorization = "Bearer " .. jwt } + }) + + it("treats the response as unavailable rather than an inactive token", function() + assert.are.equals(503, status) + assert.are.equals("invalid_response", headers["x-introspection-failure-kind"]) + assert.are.equals("200", headers["x-introspection-endpoint-status"]) + end) +end) + +describe("when the response has a non-boolean active claim", function() + test_support.start_server({ + introspection_response = { active = "true" }, + }) + teardown(test_support.stop_server) + local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) + local _, status, headers = http.request({ + url = "http://127.0.0.1/introspect", + headers = { authorization = "Bearer " .. jwt } + }) + + it("rejects the malformed response", function() + assert.are.equals(503, status) + assert.are.equals("invalid_response", headers["x-introspection-failure-kind"]) + end) end) describe("when the response is active but lacks the exp claim", function() @@ -596,7 +676,7 @@ describe("when a batch receives an introspection endpoint failure", function() }) teardown(test_support.stop_server) local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) - request_introspection_concurrently(jwt, 35, "401") + request_introspection_concurrently(jwt, 35, "503") it("coalesces the batch into one introspection endpoint call", function() assert.are.equals(1, error_log_occurrences("Received introspection request:")) @@ -604,6 +684,9 @@ describe("when a batch receives an introspection endpoint failure", function() it("shares the endpoint failure with every request", function() assert.are.equals(35, error_log_occurrences( "Introspection error: response indicates failure, status=503,")) + assert.are.equals(35, error_log_occurrences("endpoint_status=503")) + assert.are.equals(35, error_log_occurrences("kind=endpoint_unavailable")) + assert.are.equals(35, error_log_occurrences("client_status=503")) end) end) @@ -713,12 +796,16 @@ describe("when introspection endpoint is not resolvable", function() }) teardown(test_support.stop_server) local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) - local _, status = http.request({ + local _, status, headers = http.request({ url = "http://127.0.0.1/introspect", headers = { authorization = "Bearer " .. jwt } }) - it("the response is invalid", function() - assert.are.equals(401, status) + it("the service is unavailable", function() + assert.are.equals(503, status) + end) + it("classifies the endpoint failure", function() + assert.are.equals("endpoint_unavailable", headers["x-introspection-failure-kind"]) + assert.is_nil(headers["x-introspection-endpoint-status"]) end) it("an error has been logged", function() assert.error_log_contains("Introspection error:.*foo.example.org could not be resolved.*") @@ -738,8 +825,8 @@ describe("when introspection endpoint is not reachable", function() url = "http://127.0.0.1/introspect", headers = { authorization = "Bearer " .. jwt } }) - it("the response is invalid", function() - assert.are.equals(401, status) + it("the service is unavailable", function() + assert.are.equals(503, status) end) it("an error has been logged", function() assert.error_log_contains("Introspection error:.*accessing introspection endpoint %(http://127.1.2.3/%) failed") @@ -774,8 +861,8 @@ describe("when introspection endpoint is slow and a simple timeout is configured url = "http://127.0.0.1/introspect", headers = { authorization = "Bearer " .. jwt } }) - it("the response is invalid", function() - assert.are.equals(401, status) + it("the service is unavailable", function() + assert.are.equals(503, status) end) it("an error has been logged", function() assert.error_log_contains("Introspection error:.*accessing introspection endpoint %(http://127.0.0.1/introspection%) failed: timeout") @@ -795,8 +882,8 @@ describe("when introspection endpoint is slow and a table timeout is configured" url = "http://127.0.0.1/introspect", headers = { authorization = "Bearer " .. jwt } }) - it("the response is invalid", function() - assert.are.equals(401, status) + it("the service is unavailable", function() + assert.are.equals(503, status) end) it("an error has been logged", function() assert.error_log_contains("Introspection error:.*accessing introspection endpoint %(http://127.0.0.1/introspection%) failed: timeout") @@ -811,16 +898,20 @@ describe("when introspection endpoint sends a 4xx status", function() }) teardown(test_support.stop_server) local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) - local _, status = http.request({ + local _, status, headers = http.request({ url = "http://127.0.0.1/introspect", headers = { authorization = "Bearer " .. jwt } }) - it("the response is invalid", function() - assert.are.equals(401, status) + it("the service is unavailable", function() + assert.are.equals(503, status) end) it("an error has been logged", function() assert.error_log_contains("Introspection error:.*response indicates failure, status=404,") end) + it("preserves the endpoint status separately", function() + assert.are.equals("404", headers["x-introspection-endpoint-status"]) + assert.are.equals("endpoint_unavailable", headers["x-introspection-failure-kind"]) + end) end) describe("when introspection endpoint doesn't return proper JSON", function() @@ -831,16 +922,20 @@ describe("when introspection endpoint doesn't return proper JSON", function() }) teardown(test_support.stop_server) local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) - local _, status = http.request({ + local _, status, headers = http.request({ url = "http://127.0.0.1/introspect", headers = { authorization = "Bearer " .. jwt } }) - it("the response is invalid", function() - assert.are.equals(401, status) + it("the service is unavailable", function() + assert.are.equals(503, status) end) it("an error has been logged", function() assert.error_log_contains("Introspection error: JSON decoding failed") end) + it("classifies the successful but malformed response", function() + assert.are.equals("200", headers["x-introspection-endpoint-status"]) + assert.are.equals("invalid_response", headers["x-introspection-failure-kind"]) + end) end) describe("when a request_decorator has been specified when calling the token endpoint", function() @@ -866,16 +961,19 @@ describe("when introspection endpoint hasn't been specified", function() }) teardown(test_support.stop_server) local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) - local _, status = http.request({ + local _, status, headers = http.request({ url = "http://127.0.0.1/introspect", headers = { authorization = "Bearer " .. jwt } }) - it("the response is invalid", function() - assert.are.equals(401, status) + it("the configuration is invalid", function() + assert.are.equals(500, status) end) it("an error has been logged", function() assert.error_log_contains("Introspection error: no endpoint URI for introspection") end) + it("classifies the configuration failure", function() + assert.are.equals("configuration_error", headers["x-introspection-failure-kind"]) + end) end) describe("when introspection endpoint hasn't been specified but discovery doc provides introspection_endpoint claim", function() diff --git a/tests/spec/test_support.lua b/tests/spec/test_support.lua index f4ba858..4016769 100644 --- a/tests/spec/test_support.lua +++ b/tests/spec/test_support.lua @@ -567,10 +567,22 @@ http { if opts.decorate then opts.http_request_decorator = test_globals.body_decorator end - local json, err = test_globals.oidc.introspect(opts) + local json, err, endpoint_status, failure = + test_globals.oidc.introspect(opts) + if endpoint_status then + ngx.header["X-Introspection-Endpoint-Status"] = endpoint_status + end + if failure then + ngx.header["X-Introspection-Failure-Kind"] = failure.kind + ngx.header["X-Introspection-Client-Status"] = failure.http_status + end if err then - ngx.status = 401 - ngx.log(ngx.ERR, "Introspection error: " .. err) + ngx.status = failure and failure.http_status or 500 + ngx.log(ngx.ERR, "Introspection error: " .. err .. + ", endpoint_status=" .. tostring(endpoint_status) .. + ", kind=" .. (failure and failure.kind or "unknown") .. + ", client_status=" .. + tostring(failure and failure.http_status)) else ngx.header.content_type = 'application/json;charset=UTF-8' ngx.say(test_globals.cjson.encode(json))