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:
Koichi ITO 2026-09-18 03:49:09 +09:00
commit 5081cc5626
5 changed files with 330 additions and 41 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"))