Fall Back to Legacy 2025-03-26 OAuth Discovery for Servers Without PRM
## Motivation and Context The MCP 2025-03-26 authorization spec predates Protected Resource Metadata: the MCP server itself acted as the authorization base URL, its RFC 8414 metadata was fetched from the server origin, and the spec's "Fallbacks for Servers without Metadata Discovery" section required clients to use the default endpoints `/authorize`, `/token`, and `/register` relative to the authorization base URL when no metadata was published. Both the TypeScript SDK (`discoverOAuthServerInfo` falls back to the server base URL on PRM failure; `startAuthorization` / `executeTokenRequest` / `registerClient` default the endpoint paths) and the Python SDK (`build_oauth_authorization_server_metadata_discovery_urls(None, ...)` plus the same endpoint defaults) keep this as client-side backwards compatibility, and the `auth/2025-03-26-oauth-metadata-backcompat` and `auth/2025-03-26-oauth-endpoint-fallback` conformance scenarios exercise it. The Ruby flow previously raised as soon as PRM discovery failed, so both scenarios were expected conformance failures. This change adds the same fallback: - `Flow#locate_authorization_server` first attempts PRM discovery; on any discovery failure (404s, network errors, malformed documents, matching the TypeScript and Python SDKs' broad fallback) the MCP server's origin becomes the legacy authorization base URL. The Communication Security check (HTTPS or loopback) still applies to that origin, and PRM documents that parse correctly keep their strict shape validation. - `Flow#authorization_server_metadata` fetches RFC 8414 metadata from the base URL. On the legacy path the `issuer` byte-match is skipped: the 2025-03-26 spec predates that requirement, and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from the discovery origin (neither reference SDK validates the issuer on this path). On the modern path the byte-match is unchanged. - When even the metadata document is absent, synthetic metadata carrying the legacy spec's default endpoints is used, with PKCE S256 assumed (the legacy spec mandates PKCE; the TypeScript and Python SDKs hardcode S256 on this path too). Endpoint HTTPS checks still apply. - `run!` and `refresh!` share the new discovery helpers, and both 2025-03-26 scenarios are removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb`: - the metadata-backcompat shape: no PRM, AS metadata at the server origin with `/oauth`-prefixed endpoints and a mismatched `issuer` completes the flow against those endpoints (no issuer error) - the endpoint-fallback shape: no metadata at all registers, authorizes, and exchanges the code at `/register`, `/authorize`, and `/token` on the origin, still sending `code_challenge` (S256) and `code_verifier` - a remote plain-http origin is rejected as the legacy authorization base - strict-mode regression: with PRM present, a mismatched `issuer` still aborts the flow - a malformed PRM (top-level JSON array) now selects the legacy path instead of leaking a `TypeError`, and surfaces a domain error when that path also dead-ends Two existing tests that asserted PRM failure was terminal were updated to dead-end the legacy path explicitly, preserving their original intent (single flow attempt, domain error type). ## Breaking Changes None for spec-compliant 2025-06-18+ servers, whose PRM-based discovery and issuer validation are unchanged. Servers that previously failed hard during PRM discovery now get one legacy discovery attempt before the flow errors, which can only turn previously failing flows into working ones.
This commit is contained in:
parent
95cfe4993b
commit
2c223dd2f0
2 changed files with 246 additions and 21 deletions
|
|
@ -544,14 +544,20 @@ module MCP
|
|||
assert_match(/PRM `resource`|not a valid URI/i, error.message)
|
||||
end
|
||||
|
||||
def test_run_raises_when_prm_is_not_a_json_object
|
||||
# Valid JSON but the wrong shape: indexing into a top-level array
|
||||
# would otherwise raise `TypeError` and leak out of the SDK.
|
||||
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.
|
||||
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/mcp").to_return(status: 404)
|
||||
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
||||
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
|
||||
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
|
||||
stub_request(:post, "https://srv.example.com/register").to_return(status: 404)
|
||||
|
||||
provider = Provider.new(
|
||||
client_metadata: {
|
||||
|
|
@ -569,7 +575,147 @@ module MCP
|
|||
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
end
|
||||
|
||||
assert_match(/not a JSON object/i, error.message)
|
||||
assert_match(/Dynamic client registration failed/i, error.message)
|
||||
assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
||||
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)
|
||||
Provider.new(
|
||||
client_metadata: {
|
||||
redirect_uris: ["http://localhost:0/callback"],
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
},
|
||||
redirect_uri: "http://localhost:0/callback",
|
||||
redirect_handler: ->(url) {
|
||||
holder[:authorization_url] = url
|
||||
holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
|
||||
},
|
||||
callback_handler: -> { ["test-auth-code", holder[:state]] },
|
||||
)
|
||||
end
|
||||
|
||||
def stub_prm_not_found
|
||||
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404)
|
||||
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
||||
end
|
||||
|
||||
def test_run_falls_back_to_server_origin_metadata_without_prm
|
||||
# Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin,
|
||||
# OAuth endpoints under a path prefix whose `issuer` differs from the discovery origin.
|
||||
# The legacy path must not apply the RFC 8414 issuer byte-match (the legacy spec predates it).
|
||||
stub_prm_not_found
|
||||
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(
|
||||
issuer: "https://srv.example.com/oauth",
|
||||
authorization_endpoint: "https://srv.example.com/oauth/authorize",
|
||||
token_endpoint: "https://srv.example.com/oauth/token",
|
||||
registration_endpoint: "https://srv.example.com/oauth/register",
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
),
|
||||
)
|
||||
stub_request(:post, "https://srv.example.com/oauth/register").to_return(
|
||||
status: 201,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(client_id: "legacy-client"),
|
||||
)
|
||||
stub_request(:post, "https://srv.example.com/oauth/token").to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600),
|
||||
)
|
||||
|
||||
holder = {}
|
||||
provider = build_legacy_discovery_provider(holder)
|
||||
|
||||
result = Flow.new(provider: provider).run!(server_url: @server_url)
|
||||
|
||||
assert_equal(:authorized, result)
|
||||
assert_equal("legacy-token", provider.access_token)
|
||||
assert_equal("/oauth/authorize", holder[:authorization_url].path)
|
||||
assert_requested(:post, "https://srv.example.com/oauth/register")
|
||||
assert_requested(:post, "https://srv.example.com/oauth/token")
|
||||
end
|
||||
|
||||
def test_run_falls_back_to_default_endpoints_without_any_metadata
|
||||
# Legacy 2025-03-26 "Fallbacks for Servers without Metadata Discovery": with no PRM and no AS metadata,
|
||||
# the client MUST use /authorize, /token, and /register at the authorization base URL, still sending PKCE S256.
|
||||
stub_prm_not_found
|
||||
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
|
||||
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
|
||||
stub_request(:post, "https://srv.example.com/register").to_return(
|
||||
status: 201,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(client_id: "legacy-client"),
|
||||
)
|
||||
stub_request(:post, "https://srv.example.com/token").to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600),
|
||||
)
|
||||
|
||||
holder = {}
|
||||
provider = build_legacy_discovery_provider(holder)
|
||||
|
||||
result = Flow.new(provider: provider).run!(server_url: @server_url)
|
||||
|
||||
assert_equal(:authorized, result)
|
||||
assert_equal("/authorize", holder[:authorization_url].path)
|
||||
query = URI.decode_www_form(holder[:authorization_url].query).to_h
|
||||
assert_equal("S256", query["code_challenge_method"])
|
||||
refute_empty(query["code_challenge"].to_s)
|
||||
assert_requested(:post, "https://srv.example.com/register")
|
||||
assert_requested(:post, "https://srv.example.com/token") do |req|
|
||||
URI.decode_www_form(req.body).to_h["code_verifier"].to_s != ""
|
||||
end
|
||||
end
|
||||
|
||||
def test_run_legacy_fallback_rejects_insecure_authorization_base
|
||||
# The Communication Security requirement still applies on the legacy path: a remote plain-http origin must not
|
||||
# become the authorization base URL.
|
||||
stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404)
|
||||
stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
||||
|
||||
holder = {}
|
||||
provider = build_legacy_discovery_provider(holder)
|
||||
|
||||
error = assert_raises(Flow::AuthorizationError) do
|
||||
Flow.new(provider: provider).run!(server_url: "http://internal.example.com/mcp")
|
||||
end
|
||||
|
||||
assert_match(/legacy authorization base URL/, error.message)
|
||||
end
|
||||
|
||||
def test_run_keeps_strict_issuer_validation_when_prm_is_present
|
||||
# The legacy issuer-check relaxation must not leak into the modern path: with PRM present,
|
||||
# a mismatched issuer still aborts.
|
||||
stub_request(:get, @as_metadata_url).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(
|
||||
issuer: "https://evil.example.com",
|
||||
authorization_endpoint: "#{@auth_base}/authorize",
|
||||
token_endpoint: "#{@auth_base}/token",
|
||||
registration_endpoint: "#{@auth_base}/register",
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
),
|
||||
)
|
||||
|
||||
holder = {}
|
||||
provider = build_legacy_discovery_provider(holder)
|
||||
|
||||
error = assert_raises(Flow::AuthorizationError) do
|
||||
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
end
|
||||
|
||||
assert_match(/`issuer` does not match/, error.message)
|
||||
end
|
||||
|
||||
def test_run_raises_when_prm_authorization_servers_is_not_an_array
|
||||
|
|
|
|||
|
|
@ -1304,6 +1304,11 @@ module MCP
|
|||
body: "",
|
||||
)
|
||||
|
||||
# With no PRM, discovery falls back to the legacy 2025-03-26 path; dead-end that too so the flow fails exactly once.
|
||||
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
|
||||
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
|
||||
stub_request(:post, "https://srv.example.com/register").to_return(status: 404)
|
||||
|
||||
provider = Provider.new(
|
||||
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
||||
redirect_uri: "http://localhost:0/callback",
|
||||
|
|
|
|||
Loading…
Reference in a new issue