Surface unreachable Protected Resource Metadata instead of falling back to legacy discovery
## Motivation and Context `Flow#locate_authorization_server` treated every failure to fetch Protected Resource Metadata as "the server publishes none" and took the legacy 2025-03-26 path, where the MCP server's origin becomes the authorization base URL. A request that never reached the server, or a `5xx` or `429`, says nothing about what the server publishes, so a transient failure moved the flow to a different authorization server, discarded client information and tokens bound to the real one on the way, and surfaced as an unrelated error, such as a failed registration at the origin, rather than as the fetch failure itself. The comment claimed both reference SDKs behave that way, which is not so: outside browsers the TypeScript SDK propagates network errors from discovery, and the Python SDK propagates them and refuses the legacy path after a `5xx` or `429` from any candidate. Discovery now keeps trying candidate URLs until one serves a JSON object and classifies the outcome once they are exhausted. Like the Python SDK, it remembers a `5xx` or `429` from any candidate before deciding on the legacy path; moving on to the next candidate after a network error is what the loop already did. `MetadataNotPublishedError` (a `4xx` other than `429`, or a body that is not a JSON object) is the only failure that selects the legacy path; `MetadataUnreachableError` (a network error, or a `5xx` or `429`, from any candidate) is raised instead. Both subclass `AuthorizationError`, so callers rescuing that class keep working, and `MCP::Client::HTTP` still falls back from a failed refresh to the full flow, which repeats discovery and raises the same way unless the server has recovered. A body over the response cap is refused outright with a plain `AuthorizationError`, as it already was on the modern path. The message lists every candidate's failure, with the URL stripped of userinfo, query and fragment and cut to a fixed length, but otherwise spelled as requested, and with transport and parser messages bounded, as token endpoint diagnostics already are. The default legacy endpoints for an origin whose own metadata document is absent are unchanged. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/discovery_test.rb`. Against the library before this change, discovery in the scenarios these tests set up took the legacy path instead of raising. ## Breaking Changes When no Protected Resource Metadata candidate serves a JSON object, a request that failed to reach the server, or returned a `5xx` or `429`, now raises instead of attempting the legacy 2025-03-26 discovery at the MCP server's origin, and a Protected Resource Metadata body over the response cap is refused instead of triggering that fallback.
This commit is contained in:
parent
2c33ad061a
commit
5081cc5626
5 changed files with 330 additions and 41 deletions
|
|
@ -39,6 +39,8 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
|
||||||
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
|
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
|
||||||
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3),
|
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3),
|
||||||
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
|
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
|
||||||
|
When no Protected Resource Metadata candidate serves a JSON object, a request that could not reach the server, or that returned a `5xx` or `429`,
|
||||||
|
raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright.
|
||||||
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
|
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
|
||||||
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
|
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
|
||||||
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
|
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,29 @@ module MCP
|
||||||
uri.to_s
|
uri.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Drops userinfo, query and fragment from `url` and leaves the host and path spelled as given, for reporting
|
||||||
|
# a URL next to the request that failed, where it must still match the server's access log; `URI#to_s`
|
||||||
|
# still lowercases the scheme and drops an explicit default port. Unlike `canonicalize_origin_and_path`
|
||||||
|
# it neither normalizes nor resolves dot segments, so its cost stays linear in the URL length, which matters
|
||||||
|
# for a URL the server chose. A URL that does not parse is not echoed, since the raw value could carry
|
||||||
|
# the very credentials being dropped.
|
||||||
|
def redact_url(url)
|
||||||
|
uri = URI.parse(url.to_s)
|
||||||
|
|
||||||
|
uri.fragment = nil
|
||||||
|
uri.query = nil
|
||||||
|
# `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
|
||||||
|
# so clear the components individually.
|
||||||
|
if uri.respond_to?(:user) && (uri.user || uri.password)
|
||||||
|
uri.user = nil
|
||||||
|
uri.password = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
uri.to_s
|
||||||
|
rescue URI::Error
|
||||||
|
"[unparseable URL]"
|
||||||
|
end
|
||||||
|
|
||||||
# Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL):
|
# Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL):
|
||||||
# same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM
|
# same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM
|
||||||
# also advertises a query string, the server's query MUST be identical to it (otherwise
|
# also advertises a query string, the server's query MUST be identical to it (otherwise
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ module MCP
|
||||||
class Flow
|
class Flow
|
||||||
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
|
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
|
||||||
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
|
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
|
||||||
|
METADATA_DIAGNOSTIC_MAX_LENGTH = 128
|
||||||
|
METADATA_URL_MAX_LENGTH = 2048
|
||||||
|
|
||||||
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
|
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
|
||||||
# so a provider naming one of these is refused rather than left believing its value was sent.
|
# so a provider naming one of these is refused rather than left believing its value was sent.
|
||||||
|
|
@ -57,6 +59,17 @@ module MCP
|
||||||
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
|
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
|
||||||
class AuthorizationRefusedError < AuthorizationError; end
|
class AuthorizationRefusedError < AuthorizationError; end
|
||||||
|
|
||||||
|
# Raised by metadata discovery when every candidate URL answered that nothing usable is published there
|
||||||
|
# (a `4xx` other than `429`, a redirect that was not followed, or a body that is not a JSON object).
|
||||||
|
# The only discovery failure that may select the legacy 2025-03-26 path.
|
||||||
|
class MetadataNotPublishedError < AuthorizationError; end
|
||||||
|
|
||||||
|
# Raised by metadata discovery when the answer says nothing about what is published: the request failed to
|
||||||
|
# reach the server, or a candidate answered `5xx` or `429`. Falling back on this would move
|
||||||
|
# the flow to a different authorization server because of a transient failure, so it is surfaced instead,
|
||||||
|
# as the TypeScript SDK does for network errors outside browsers and the Python SDK does for both.
|
||||||
|
class MetadataUnreachableError < AuthorizationError; end
|
||||||
|
|
||||||
# Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
|
# Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
|
||||||
# or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
|
# or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
|
||||||
# not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
|
# not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
|
||||||
|
|
@ -375,7 +388,12 @@ module MCP
|
||||||
#
|
#
|
||||||
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
|
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
|
||||||
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
|
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
|
||||||
# Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
|
# Only a discovery answer saying that nothing usable is published (`MetadataNotPublishedError`: a `4xx` other than `429`,
|
||||||
|
# a redirect that was not followed, or a body that is not a JSON object) selects the legacy path.
|
||||||
|
# A request that failed to reach the server, or returned a `5xx` or `429`, says nothing about what the server publishes,
|
||||||
|
# so once no candidate has served a usable document it is surfaced instead (`MetadataUnreachableError`),
|
||||||
|
# as both SDKs do for network errors (the TypeScript SDK outside browsers) and the Python SDK does for server errors.
|
||||||
|
# A body over the response cap is refused outright and never reaches the fallback either.
|
||||||
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
||||||
def locate_authorization_server(server_url:, resource_metadata_url:)
|
def locate_authorization_server(server_url:, resource_metadata_url:)
|
||||||
prm = begin
|
prm = begin
|
||||||
|
|
@ -383,7 +401,7 @@ module MCP
|
||||||
server_url: server_url,
|
server_url: server_url,
|
||||||
resource_metadata_url: resource_metadata_url,
|
resource_metadata_url: resource_metadata_url,
|
||||||
)
|
)
|
||||||
rescue AuthorizationError
|
rescue MetadataNotPublishedError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -504,44 +522,63 @@ module MCP
|
||||||
first
|
first
|
||||||
end
|
end
|
||||||
|
|
||||||
# Walks candidate metadata URLs and returns the parsed JSON body of
|
# Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object;
|
||||||
# the first 2xx response. Raises `AuthorizationError` for transport
|
# the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still
|
||||||
# failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
|
# be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path,
|
||||||
# so callers do not have to handle raw Faraday/JSON exceptions.
|
# or the OAuth document against the OpenID one). Once the candidates are exhausted, an answer that said nothing
|
||||||
|
# about what is published (a network error, a `5xx`, or a `429`) outranks the rest and raises
|
||||||
|
# `MetadataUnreachableError`; otherwise (any other status, such as a `4xx` other than `429` or a redirect that
|
||||||
|
# was not followed, a body that is not JSON, or not a JSON object) `MetadataNotPublishedError`.
|
||||||
|
# A body over the cap is refused outright by `bounded_request` with a plain `AuthorizationError`,
|
||||||
|
# before any classification. Each failure is listed with its URL stripped of userinfo, query and fragment
|
||||||
|
# and cut to `METADATA_URL_MAX_LENGTH`, but otherwise spelled as requested, so it can be matched against
|
||||||
|
# a server's access log, and with exception text bounded, since the message lands in every log destination
|
||||||
|
# the error passes through.
|
||||||
def fetch_metadata_json(urls, label:)
|
def fetch_metadata_json(urls, label:)
|
||||||
last_error = nil
|
failures = []
|
||||||
|
inconclusive = false
|
||||||
urls.each do |url|
|
urls.each do |url|
|
||||||
response = begin
|
response = begin
|
||||||
http_get(url)
|
http_get(url)
|
||||||
rescue Faraday::Error => e
|
rescue Faraday::Error => e
|
||||||
last_error = "GET #{url} raised #{e.class}: #{e.message}"
|
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH)
|
||||||
|
failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}"
|
||||||
|
inconclusive = true
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
if response.status >= 200 && response.status < 300
|
unless response.status >= 200 && response.status < 300
|
||||||
parsed = begin
|
failures << "GET #{reported_url(url)} returned #{response.status}"
|
||||||
JSON.parse(response_body_string(response))
|
inconclusive = true if response.status >= 500 || response.status == 429
|
||||||
rescue JSON::ParserError => e
|
next
|
||||||
raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
|
|
||||||
end
|
|
||||||
|
|
||||||
# Even valid JSON can be the wrong shape (a top-level array,
|
|
||||||
# a bare `null`, a string, ...). The discovery callers index by
|
|
||||||
# name (`prm["authorization_servers"]`, etc.), so anything that
|
|
||||||
# is not a Hash would raise `TypeError` / `NoMethodError`
|
|
||||||
# downstream. Surface that as `AuthorizationError` instead so
|
|
||||||
# callers see a single, documented error type.
|
|
||||||
unless parsed.is_a?(Hash)
|
|
||||||
raise AuthorizationError,
|
|
||||||
"#{label} from #{url} is not a JSON object (got #{parsed.class})."
|
|
||||||
end
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
end
|
end
|
||||||
|
|
||||||
last_error = "GET #{url} returned #{response.status}"
|
parsed = begin
|
||||||
|
JSON.parse(response_body_string(response))
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) || e.class.name
|
||||||
|
failures << "GET #{reported_url(url)} returned a body that is not JSON: #{detail}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Even valid JSON can be the wrong shape (a top-level array, a bare `null`, a string, ...).
|
||||||
|
# The discovery callers index by name (`prm["authorization_servers"]`, etc.), so anything that
|
||||||
|
# is not a Hash would raise `TypeError` / `NoMethodError` downstream.
|
||||||
|
unless parsed.is_a?(Hash)
|
||||||
|
failures << "GET #{reported_url(url)} returned a body that is not a JSON object (got #{parsed.class})"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
message = "Failed to fetch #{label}: #{failures.join("; ")}."
|
||||||
|
|
||||||
|
if inconclusive
|
||||||
|
raise MetadataUnreachableError, message
|
||||||
|
else
|
||||||
|
raise MetadataNotPublishedError, message
|
||||||
end
|
end
|
||||||
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_pkce_supported!(as_metadata)
|
def ensure_pkce_supported!(as_metadata)
|
||||||
|
|
@ -1188,8 +1225,8 @@ module MCP
|
||||||
parsed = {} unless parsed.is_a?(Hash)
|
parsed = {} unless parsed.is_a?(Hash)
|
||||||
|
|
||||||
error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError
|
error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError
|
||||||
error = token_endpoint_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
|
error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
|
||||||
description = token_endpoint_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
|
description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
|
||||||
message += " #{[error, description].compact.join(": ")}" if error || description
|
message += " #{[error, description].compact.join(": ")}" if error || description
|
||||||
|
|
||||||
error_class.new(message, http_status: response.status, error: error, error_description: description)
|
error_class.new(message, http_status: response.status, error: error, error_description: description)
|
||||||
|
|
@ -1198,17 +1235,23 @@ module MCP
|
||||||
error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status)
|
error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status)
|
||||||
end
|
end
|
||||||
|
|
||||||
def token_endpoint_diagnostic(value, limit:)
|
def bounded_diagnostic(value, limit:)
|
||||||
return unless value.is_a?(String)
|
return unless value.is_a?(String)
|
||||||
|
|
||||||
# RFC 6749 permits printable ASCII except double quotes and backslashes.
|
# RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields.
|
||||||
# Replace other characters to keep provider text on one log line.
|
# Replace other characters to keep text received off the network on one log line.
|
||||||
value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip
|
value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip
|
||||||
return if value.empty?
|
return if value.empty?
|
||||||
|
|
||||||
value.length > limit ? "#{value[0, limit - 3]}..." : value
|
value.length > limit ? "#{value[0, limit - 3]}..." : value
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A candidate URL as it goes into a failure string: redacted, and cut so a URL the server chose cannot
|
||||||
|
# grow the message without limit.
|
||||||
|
def reported_url(url)
|
||||||
|
bounded_diagnostic(Discovery.redact_url(url), limit: METADATA_URL_MAX_LENGTH)
|
||||||
|
end
|
||||||
|
|
||||||
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
|
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
|
||||||
# `application/x-www-form-urlencoded` encoded before they are joined with
|
# `application/x-www-form-urlencoded` encoded before they are joined with
|
||||||
# `:` and base64-encoded for the `Authorization: Basic` header. This is
|
# `:` and base64-encoded for the `Authorization: Basic` header. This is
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,19 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_redact_url_drops_credentials_query_and_fragment_but_keeps_the_spelling
|
||||||
|
# Reported next to a failed request, the URL must still match the server's access log, so the host
|
||||||
|
# and path are left alone: no lowercasing of the host, no dot-segment resolution, no decoding.
|
||||||
|
assert_equal(
|
||||||
|
"https://Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson",
|
||||||
|
Discovery.redact_url("https://user:pass@Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson?token=abc#frag"),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_redact_url_does_not_echo_a_url_it_cannot_parse
|
||||||
|
assert_equal("[unparseable URL]", Discovery.redact_url("https://user:pass@srv.example.com/pr m.json?token=abc"))
|
||||||
|
end
|
||||||
|
|
||||||
def test_canonicalize_url_normalizes_query_to_match_faraday
|
def test_canonicalize_url_normalizes_query_to_match_faraday
|
||||||
# Faraday rewrites `env.url` before sending a request: it sorts
|
# Faraday rewrites `env.url` before sending a request: it sorts
|
||||||
# parameters by name, uppercases percent-encoded hex, and drops
|
# parameters by name, uppercases percent-encoded hex, and drops
|
||||||
|
|
|
||||||
|
|
@ -1084,9 +1084,9 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object
|
def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object
|
||||||
# Valid JSON but the wrong shape. Any PRM discovery failure selects the legacy 2025-03-26 path
|
# Valid JSON but the wrong shape counts as nothing usable being published, so it selects the legacy 2025-03-26 path
|
||||||
# (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing
|
# (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing a domain error rather than
|
||||||
# a domain error rather than a raw `TypeError` from indexing the array.
|
# a raw `TypeError` from indexing the array.
|
||||||
stub_request(:get, @prm_url).to_return(
|
stub_request(:get, @prm_url).to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type" => "application/json" },
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
|
@ -1118,6 +1118,211 @@ module MCP
|
||||||
assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_run_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back
|
||||||
|
# A request that never reached the server says nothing about whether it publishes PRM, so once
|
||||||
|
# the other candidates answer 404 the flow stops instead of moving to the legacy authorization base;
|
||||||
|
# the TypeScript and Python SDKs propagate the error the same way.
|
||||||
|
challenge_url = "https://srv.example.com/prm.json"
|
||||||
|
stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
|
||||||
|
stub_prm_not_found
|
||||||
|
|
||||||
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
||||||
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_match(/Faraday::ConnectionFailed/, error.message)
|
||||||
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
||||||
|
assert_not_requested(:post, "https://srv.example.com/register")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_reports_a_prm_candidate_url_without_its_query_in_the_discovery_error
|
||||||
|
# The candidate URL comes from the server's `WWW-Authenticate` challenge and lands in every log line
|
||||||
|
# the error reaches, so the query is dropped, while the path keeps the spelling that was requested
|
||||||
|
# (`%2E` is not decoded) so the line can be matched against the server's access log.
|
||||||
|
challenge_url = "https://srv.example.com/prm%2Ejson?token=abc"
|
||||||
|
stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
|
||||||
|
stub_prm_not_found
|
||||||
|
|
||||||
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
||||||
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_includes(error.message, "GET https://srv.example.com/prm%2Ejson raised Faraday::ConnectionFailed: connection refused; GET")
|
||||||
|
refute_includes(error.message, "token=abc")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_cuts_an_overlong_prm_candidate_url_in_the_discovery_error
|
||||||
|
# The challenge URL is the server's to choose, so its length is bounded before it lands in the message.
|
||||||
|
challenge_url = "https://srv.example.com/#{"a" * 5000}/prm.json"
|
||||||
|
stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
|
||||||
|
stub_prm_not_found
|
||||||
|
|
||||||
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
||||||
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_includes(error.message, "GET #{challenge_url[0, Flow::METADATA_URL_MAX_LENGTH - 3]}... raised Faraday::ConnectionFailed")
|
||||||
|
refute_includes(error.message, "a" * (Flow::METADATA_URL_MAX_LENGTH - 3))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_bounds_the_transport_error_message_in_the_discovery_error
|
||||||
|
# A caller-supplied client can raise with server-chosen text in the message (Faraday's `raise_error` middleware repeats
|
||||||
|
# the status line and the request URL), so it is bounded like the parser's message.
|
||||||
|
stub_request(:get, @prm_url).to_raise(Faraday::ConnectionFailed.new("x" * 5000))
|
||||||
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
||||||
|
|
||||||
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
||||||
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_includes(error.message, "raised Faraday::ConnectionFailed: #{"x" * 125}...; GET")
|
||||||
|
refute_includes(error.message, "x" * 126)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_bounds_the_parser_message_in_the_discovery_error
|
||||||
|
# json before 2.10 repeats the remaining source in `JSON::ParserError#message`, so a malformed body
|
||||||
|
# could otherwise put up to the response cap into the message once per candidate.
|
||||||
|
stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "{")
|
||||||
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 503)
|
||||||
|
JSON.stubs(:parse).raises(JSON::ParserError, "unexpected token at '#{"{" * 5000}'")
|
||||||
|
|
||||||
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
||||||
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
# `METADATA_DIAGNOSTIC_MAX_LENGTH` is 128: the first 125 characters survive, then three dots.
|
||||||
|
assert_includes(error.message, <<~MESSAGE.chomp)
|
||||||
|
returned a body that is not JSON: unexpected token at '#{"{" * 104}...; \
|
||||||
|
GET https://srv.example.com/.well-known/oauth-protected-resource returned 503.
|
||||||
|
MESSAGE
|
||||||
|
|
||||||
|
refute_includes(error.message, "{" * 105)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back
|
||||||
|
# A `5xx` or `429` says nothing about what the server publishes either, even when a later candidate answers 404;
|
||||||
|
# the Python SDK refuses the legacy path the same way.
|
||||||
|
[503, 429].each do |status|
|
||||||
|
stub_request(:get, @prm_url).to_return(status: status)
|
||||||
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
||||||
|
|
||||||
|
error = assert_raises(Flow::MetadataUnreachableError, "status #{status}") do
|
||||||
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_match(/returned #{status}/, error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
||||||
|
assert_not_requested(:post, "https://srv.example.com/register")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_keeps_a_server_error_in_mind_when_a_later_candidate_is_unusable
|
||||||
|
# An unusable document from a later candidate must not turn an earlier `503` into "nothing published".
|
||||||
|
challenge_url = "https://srv.example.com/prm.json"
|
||||||
|
stub_request(:get, challenge_url).to_return(status: 503)
|
||||||
|
stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]")
|
||||||
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
||||||
|
|
||||||
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
||||||
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_match(/returned 503/, error.message)
|
||||||
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_uses_a_later_prm_candidate_when_the_challenge_url_serves_an_unusable_document
|
||||||
|
# The setup stubs a valid document at the well-known path; a broken one at the challenge URL is skipped.
|
||||||
|
challenge_url = "https://srv.example.com/prm.json"
|
||||||
|
stub_request(:get, challenge_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]")
|
||||||
|
|
||||||
|
result = Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
assert_requested(:get, @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_a_network_error
|
||||||
|
# The OAuth document being unreachable must not stop the OpenID one from being tried.
|
||||||
|
stub_request(:get, @as_metadata_url).to_raise(Faraday::TimeoutError.new("execution expired"))
|
||||||
|
stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(
|
||||||
|
issuer: @auth_base,
|
||||||
|
authorization_endpoint: "#{@auth_base}/authorize",
|
||||||
|
token_endpoint: "#{@auth_base}/token",
|
||||||
|
registration_endpoint: "#{@auth_base}/register",
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
token_endpoint_auth_methods_supported: ["none"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_authorization_flow
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_an_unusable_document
|
||||||
|
# An OAuth document that is not JSON, or not a JSON object, must not stop the OpenID one from being tried.
|
||||||
|
stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(
|
||||||
|
issuer: @auth_base,
|
||||||
|
authorization_endpoint: "#{@auth_base}/authorize",
|
||||||
|
token_endpoint: "#{@auth_base}/token",
|
||||||
|
registration_endpoint: "#{@auth_base}/register",
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
token_endpoint_auth_methods_supported: ["none"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
["{", "[]"].each do |body|
|
||||||
|
stub_request(:get, @as_metadata_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: body)
|
||||||
|
|
||||||
|
assert_equal(:authorized, run_authorization_flow, "body #{body.inspect}")
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration", times: 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_refresh_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back
|
||||||
|
stub_request(:get, @prm_url).to_raise(Faraday::TimeoutError.new("execution expired"))
|
||||||
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
||||||
|
provider = build_legacy_discovery_provider({})
|
||||||
|
provider.save_client_information("client_id" => "test-client")
|
||||||
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
||||||
|
|
||||||
|
assert_raises(Flow::MetadataUnreachableError) do
|
||||||
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
||||||
|
assert_not_requested(:post, "https://srv.example.com/token")
|
||||||
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_refresh_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back
|
||||||
|
stub_request(:get, @prm_url).to_return(status: 503)
|
||||||
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
||||||
|
provider = build_legacy_discovery_provider({})
|
||||||
|
provider.save_client_information("client_id" => "test-client")
|
||||||
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
||||||
|
|
||||||
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
||||||
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_match(/returned 503/, error.message)
|
||||||
|
assert_not_requested(:post, "https://srv.example.com/token")
|
||||||
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
||||||
|
end
|
||||||
|
|
||||||
# Builds a provider for the legacy-discovery tests, capturing the authorization URL so tests can assert
|
# Builds a provider for the legacy-discovery tests, capturing the authorization URL so tests can assert
|
||||||
# which endpoint was used.
|
# which endpoint was used.
|
||||||
def build_legacy_discovery_provider(holder)
|
def build_legacy_discovery_provider(holder)
|
||||||
|
|
@ -1511,10 +1716,10 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_run_refuses_a_protected_resource_metadata_body_over_the_cap
|
def test_run_refuses_a_protected_resource_metadata_body_over_the_cap
|
||||||
# PRM discovery failures select the legacy path by design, so the refusal shows up as
|
# An oversized document is refused outright rather than treated as unpublished, so the flow stops
|
||||||
# the fallback rather than as a raise. The padded document is valid JSON naming
|
# without the legacy fallback. The padded document is valid JSON naming an authorization server,
|
||||||
# an authorization server, so contacting that server is exactly what would happen
|
# so contacting that server is exactly what would happen if the body had been read:
|
||||||
# if the body had been read: the assertion below fails if the cap stops working.
|
# the assertion below fails if the cap stops working.
|
||||||
stub_request(:any, %r{\Ahttps://srv\.example\.com/}).to_return(status: 404)
|
stub_request(:any, %r{\Ahttps://srv\.example\.com/}).to_return(status: 404)
|
||||||
stub_request(:get, @prm_url).to_return(
|
stub_request(:get, @prm_url).to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|
@ -1526,9 +1731,11 @@ module MCP
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_raises(Flow::AuthorizationError) { run_authorization_flow }
|
error = assert_raises(Flow::AuthorizationError) { run_authorization_flow }
|
||||||
|
|
||||||
|
assert_match(/exceeds \d+ bytes/, error.message)
|
||||||
assert_not_requested(:get, @as_metadata_url)
|
assert_not_requested(:get, @as_metadata_url)
|
||||||
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_run_refuses_a_dynamic_client_registration_body_over_the_cap
|
def test_run_refuses_a_dynamic_client_registration_body_over_the_cap
|
||||||
|
|
@ -1632,6 +1839,7 @@ module MCP
|
||||||
headers: { "Content-Type" => "application/json" },
|
headers: { "Content-Type" => "application/json" },
|
||||||
body: "null",
|
body: "null",
|
||||||
)
|
)
|
||||||
|
stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(status: 404)
|
||||||
|
|
||||||
provider = Provider.new(
|
provider = Provider.new(
|
||||||
client_metadata: {
|
client_metadata: {
|
||||||
|
|
@ -3283,7 +3491,7 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_token_endpoint_errors_fall_back_when_diagnostic_extraction_raises
|
def test_token_endpoint_errors_fall_back_when_diagnostic_extraction_raises
|
||||||
Flow.any_instance.stubs(:token_endpoint_diagnostic).raises(ArgumentError, "sensitive provider text")
|
Flow.any_instance.stubs(:bounded_diagnostic).raises(ArgumentError, "sensitive provider text")
|
||||||
|
|
||||||
{ "invalid_grant" => Flow::InvalidGrantError, "invalid_client" => Flow::AuthorizationError }.each do |code, klass|
|
{ "invalid_grant" => Flow::InvalidGrantError, "invalid_client" => Flow::AuthorizationError }.each do |code, klass|
|
||||||
error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details"))
|
error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details"))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue