diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index ebd30148..11e92154 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -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 as the authorization base URL, its metadata is fetched from `/.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. + 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 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. diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb index 04030cc8..a8ad3be7 100644 --- a/lib/mcp/client/oauth/discovery.rb +++ b/lib/mcp/client/oauth/discovery.rb @@ -336,6 +336,29 @@ def canonicalize_origin_and_path(url) uri.to_s 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): # 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 diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 0d38f2db..37c0cce2 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -16,6 +16,8 @@ module OAuth class Flow TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128 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`, # so a provider naming one of these is refused rather than left believing its value was sent. @@ -57,6 +59,17 @@ class InvalidGrantError < AuthorizationError; end # or authorization server metadata failure by rescuing a class rather than by matching the message text. 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, # 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 @@ -375,7 +388,12 @@ def fetch_protected_resource_metadata(server_url:, resource_metadata_url:) # # 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. - # 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 def locate_authorization_server(server_url:, resource_metadata_url:) prm = begin @@ -383,7 +401,7 @@ def locate_authorization_server(server_url:, resource_metadata_url:) server_url: server_url, resource_metadata_url: resource_metadata_url, ) - rescue AuthorizationError + rescue MetadataNotPublishedError nil end @@ -504,44 +522,63 @@ def first_authorization_server(prm) first end - # Walks candidate metadata URLs and returns the parsed JSON body of - # the first 2xx response. Raises `AuthorizationError` for transport - # failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`) - # so callers do not have to handle raw Faraday/JSON exceptions. + # Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object; + # the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still + # be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path, + # 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:) - last_error = nil + failures = [] + inconclusive = false urls.each do |url| response = begin http_get(url) 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 end - if response.status >= 200 && response.status < 300 - parsed = begin - JSON.parse(response_body_string(response)) - rescue JSON::ParserError => e - raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}." - end + unless response.status >= 200 && response.status < 300 + failures << "GET #{reported_url(url)} returned #{response.status}" + inconclusive = true if response.status >= 500 || response.status == 429 + 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. 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 + 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 - return parsed + # 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 - last_error = "GET #{url} returned #{response.status}" + return parsed + end + + message = "Failed to fetch #{label}: #{failures.join("; ")}." + + if inconclusive + raise MetadataUnreachableError, message + else + raise MetadataNotPublishedError, message end - raise AuthorizationError, "Failed to fetch #{label}: #{last_error}." end def ensure_pkce_supported!(as_metadata) @@ -1188,8 +1225,8 @@ def token_endpoint_error(response) parsed = {} unless parsed.is_a?(Hash) error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError - error = token_endpoint_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH) - description = token_endpoint_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH) + error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH) + description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH) message += " #{[error, description].compact.join(": ")}" if error || description error_class.new(message, http_status: response.status, error: error, error_description: description) @@ -1198,17 +1235,23 @@ def token_endpoint_error(response) error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status) end - def token_endpoint_diagnostic(value, limit:) + def bounded_diagnostic(value, limit:) return unless value.is_a?(String) - # RFC 6749 permits printable ASCII except double quotes and backslashes. - # Replace other characters to keep provider text on one log line. + # RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields. + # 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 return if value.empty? value.length > limit ? "#{value[0, limit - 3]}..." : value 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 # `application/x-www-form-urlencoded` encoded before they are joined with # `:` and base64-encoded for the `Authorization: Basic` header. This is diff --git a/test/mcp/client/oauth/discovery_test.rb b/test/mcp/client/oauth/discovery_test.rb index c8d9fda7..d58c7f2f 100644 --- a/test/mcp/client/oauth/discovery_test.rb +++ b/test/mcp/client/oauth/discovery_test.rb @@ -259,6 +259,19 @@ def test_canonicalize_url_drops_userinfo ) 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 # Faraday rewrites `env.url` before sending a request: it sorts # parameters by name, uppercases percent-encoded hex, and drops diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 26adb604..5a41b3b1 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -1084,9 +1084,9 @@ def test_run_raises_when_prm_resource_is_malformed_uri end 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 - # (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing - # a domain error rather than a raw `TypeError` from indexing the array. + # 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 a domain error rather than + # a raw `TypeError` from indexing the array. stub_request(:get, @prm_url).to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -1118,6 +1118,211 @@ def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") 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 # which endpoint was used. def build_legacy_discovery_provider(holder) @@ -1511,10 +1716,10 @@ def test_run_refuses_an_authorization_server_metadata_body_over_the_cap end 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 - # the fallback rather than as a raise. The padded document is valid JSON naming - # an authorization server, so contacting that server is exactly what would happen - # if the body had been read: the assertion below fails if the cap stops working. + # An oversized document is refused outright rather than treated as unpublished, so the flow stops + # without the legacy fallback. The padded document is valid JSON naming an authorization server, + # so contacting that server is exactly what would happen if the body had been read: + # the assertion below fails if the cap stops working. stub_request(:any, %r{\Ahttps://srv\.example\.com/}).to_return(status: 404) stub_request(:get, @prm_url).to_return( status: 200, @@ -1526,9 +1731,11 @@ def test_run_refuses_a_protected_resource_metadata_body_over_the_cap ), ) - 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, "https://srv.example.com/.well-known/oauth-authorization-server") end def test_run_refuses_a_dynamic_client_registration_body_over_the_cap @@ -1632,6 +1839,7 @@ def test_run_raises_when_as_metadata_is_not_a_json_object headers: { "Content-Type" => "application/json" }, body: "null", ) + stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(status: 404) provider = Provider.new( client_metadata: { @@ -3283,7 +3491,7 @@ def test_token_endpoint_errors_scrub_invalid_utf8_in_both_fields end 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| error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details"))