Skip to content

Commit 2c223dd

Browse files
committed
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.
1 parent 95cfe49 commit 2c223dd

5 files changed

Lines changed: 246 additions & 21 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1986,6 +1986,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
19861986
- Send `Authorization: Bearer <access_token>` on every request when a token is available.
19871987
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
19881988
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
1989+
- 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
1990+
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates),
1991+
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
19891992
- 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).
19901993
- 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),
19911994
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.

conformance/expected_failures.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,5 @@ client:
55
# TODO: Elicitation not implemented in Ruby client.
66
- elicitation-sep1034-client-defaults
77
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
8-
- auth/2025-03-26-oauth-metadata-backcompat
9-
- auth/2025-03-26-oauth-endpoint-fallback
108
- auth/client-credentials-jwt
119
- auth/cross-app-access-complete-flow

lib/mcp/client/oauth/flow.rb

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,18 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
3838
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
3939
end
4040

41-
prm = fetch_protected_resource_metadata(
41+
prm, authorization_server = locate_authorization_server(
4242
server_url: server_url,
4343
resource_metadata_url: resource_metadata_url,
4444
)
45-
authorization_server = first_authorization_server(prm)
46-
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
4745

4846
# Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
4947
# both the authorization and token requests. When PRM advertises a `resource`,
5048
# it MUST identify the same MCP server we are talking to; otherwise we are
5149
# being redirected to credentials minted for a different audience.
52-
resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
50+
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
5351

54-
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
55-
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
56-
ensure_secure_endpoints!(as_metadata)
52+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
5753

5854
if provider_authorization_flow == :client_credentials
5955
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
@@ -63,7 +59,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
6359

6460
client_info = ensure_client_registered(as_metadata: as_metadata)
6561

66-
effective_scope = resolve_scope(scope: scope, prm: prm)
62+
effective_scope = resolve_scope(scope: scope, prm: prm || {})
6763
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
6864
pkce = PKCE.generate
6965
state = SecureRandom.urlsafe_base64(32)
@@ -158,18 +154,14 @@ def refresh!(server_url:, resource_metadata_url: nil)
158154
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
159155
end
160156

161-
prm = fetch_protected_resource_metadata(
157+
prm, authorization_server = locate_authorization_server(
162158
server_url: server_url,
163159
resource_metadata_url: resource_metadata_url,
164160
)
165-
authorization_server = first_authorization_server(prm)
166-
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
167161

168-
resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
162+
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
169163

170-
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
171-
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
172-
ensure_secure_endpoints!(as_metadata)
164+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
173165

174166
client_info = if have_stored_client_info
175167
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
@@ -221,6 +213,87 @@ def fetch_protected_resource_metadata(server_url:, resource_metadata_url:)
221213
fetch_metadata_json(urls, label: "protected resource metadata")
222214
end
223215

216+
# Locates the authorization server for `server_url` and returns `[prm, authorization_server]`.
217+
#
218+
# Modern path (2025-06-18+): Protected Resource Metadata names the authorization server in
219+
# `authorization_servers`.
220+
#
221+
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
222+
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
223+
# Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
224+
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
225+
def locate_authorization_server(server_url:, resource_metadata_url:)
226+
prm = begin
227+
fetch_protected_resource_metadata(
228+
server_url: server_url,
229+
resource_metadata_url: resource_metadata_url,
230+
)
231+
rescue AuthorizationError
232+
nil
233+
end
234+
235+
if prm
236+
authorization_server = first_authorization_server(prm)
237+
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
238+
[prm, authorization_server]
239+
else
240+
authorization_base = server_origin!(server_url)
241+
ensure_secure_url!(authorization_base, label: "MCP server origin (legacy authorization base URL)")
242+
[nil, authorization_base]
243+
end
244+
end
245+
246+
# Fetches and validates the authorization server's RFC 8414 metadata.
247+
#
248+
# On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3).
249+
# On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement,
250+
# and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
251+
# the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
252+
# When even the metadata document is absent, the legacy spec's default endpoints are used.
253+
def authorization_server_metadata(authorization_server:, legacy:)
254+
metadata = if legacy
255+
begin
256+
fetch_authorization_server_metadata(issuer_url: authorization_server)
257+
rescue AuthorizationError
258+
default_legacy_metadata(authorization_server)
259+
end
260+
else
261+
fetch_authorization_server_metadata(issuer_url: authorization_server).tap do |fetched|
262+
ensure_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
263+
end
264+
end
265+
266+
ensure_secure_endpoints!(metadata)
267+
metadata
268+
end
269+
270+
# The 2025-03-26 spec's "Fallbacks for Servers without Metadata Discovery": clients MUST use these default endpoint paths
271+
# relative to the authorization base URL. PKCE S256 is assumed because the legacy spec mandates PKCE and there is no metadata
272+
# to advertise it (the TypeScript and Python SDKs hardcode S256 on this path too).
273+
def default_legacy_metadata(authorization_base)
274+
{
275+
"issuer" => authorization_base,
276+
"authorization_endpoint" => "#{authorization_base}/authorize",
277+
"token_endpoint" => "#{authorization_base}/token",
278+
"registration_endpoint" => "#{authorization_base}/register",
279+
"code_challenge_methods_supported" => ["S256"],
280+
}
281+
end
282+
283+
# Returns `scheme://host[:port]` of `server_url`, the legacy 2025-03-26 authorization base URL for servers without PRM.
284+
def server_origin!(server_url)
285+
uri = URI.parse(server_url.to_s)
286+
unless uri.is_a?(URI::HTTP) && uri.host
287+
raise AuthorizationError,
288+
"Cannot derive a legacy authorization base URL from MCP server URL #{server_url.inspect}."
289+
end
290+
291+
port_part = uri.port == uri.default_port ? "" : ":#{uri.port}"
292+
"#{uri.scheme}://#{uri.host}#{port_part}"
293+
rescue URI::InvalidURIError => e
294+
raise AuthorizationError, "MCP server URL #{server_url.inspect} is not a valid URI: #{e.message}."
295+
end
296+
224297
def fetch_authorization_server_metadata(issuer_url:)
225298
urls = Discovery.authorization_server_metadata_urls(issuer_url)
226299
fetch_metadata_json(urls, label: "authorization server metadata")

test/mcp/client/oauth/flow_test.rb

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -544,14 +544,20 @@ def test_run_raises_when_prm_resource_is_malformed_uri
544544
assert_match(/PRM `resource`|not a valid URI/i, error.message)
545545
end
546546

547-
def test_run_raises_when_prm_is_not_a_json_object
548-
# Valid JSON but the wrong shape: indexing into a top-level array
549-
# would otherwise raise `TypeError` and leak out of the SDK.
547+
def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object
548+
# Valid JSON but the wrong shape. Any PRM discovery failure selects the legacy 2025-03-26 path
549+
# (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing
550+
# a domain error rather than a raw `TypeError` from indexing the array.
550551
stub_request(:get, @prm_url).to_return(
551552
status: 200,
552553
headers: { "Content-Type" => "application/json" },
553554
body: "[]",
554555
)
556+
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404)
557+
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
558+
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
559+
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
560+
stub_request(:post, "https://srv.example.com/register").to_return(status: 404)
555561

556562
provider = Provider.new(
557563
client_metadata: {
@@ -569,7 +575,147 @@ def test_run_raises_when_prm_is_not_a_json_object
569575
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
570576
end
571577

572-
assert_match(/not a JSON object/i, error.message)
578+
assert_match(/Dynamic client registration failed/i, error.message)
579+
assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
580+
end
581+
582+
# Builds a provider for the legacy-discovery tests, capturing the authorization URL so tests can assert
583+
# which endpoint was used.
584+
def build_legacy_discovery_provider(holder)
585+
Provider.new(
586+
client_metadata: {
587+
redirect_uris: ["http://localhost:0/callback"],
588+
grant_types: ["authorization_code"],
589+
response_types: ["code"],
590+
token_endpoint_auth_method: "none",
591+
},
592+
redirect_uri: "http://localhost:0/callback",
593+
redirect_handler: ->(url) {
594+
holder[:authorization_url] = url
595+
holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
596+
},
597+
callback_handler: -> { ["test-auth-code", holder[:state]] },
598+
)
599+
end
600+
601+
def stub_prm_not_found
602+
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404)
603+
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
604+
end
605+
606+
def test_run_falls_back_to_server_origin_metadata_without_prm
607+
# Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin,
608+
# OAuth endpoints under a path prefix whose `issuer` differs from the discovery origin.
609+
# The legacy path must not apply the RFC 8414 issuer byte-match (the legacy spec predates it).
610+
stub_prm_not_found
611+
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(
612+
status: 200,
613+
headers: { "Content-Type" => "application/json" },
614+
body: JSON.generate(
615+
issuer: "https://srv.example.com/oauth",
616+
authorization_endpoint: "https://srv.example.com/oauth/authorize",
617+
token_endpoint: "https://srv.example.com/oauth/token",
618+
registration_endpoint: "https://srv.example.com/oauth/register",
619+
response_types_supported: ["code"],
620+
code_challenge_methods_supported: ["S256"],
621+
token_endpoint_auth_methods_supported: ["none"],
622+
),
623+
)
624+
stub_request(:post, "https://srv.example.com/oauth/register").to_return(
625+
status: 201,
626+
headers: { "Content-Type" => "application/json" },
627+
body: JSON.generate(client_id: "legacy-client"),
628+
)
629+
stub_request(:post, "https://srv.example.com/oauth/token").to_return(
630+
status: 200,
631+
headers: { "Content-Type" => "application/json" },
632+
body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600),
633+
)
634+
635+
holder = {}
636+
provider = build_legacy_discovery_provider(holder)
637+
638+
result = Flow.new(provider: provider).run!(server_url: @server_url)
639+
640+
assert_equal(:authorized, result)
641+
assert_equal("legacy-token", provider.access_token)
642+
assert_equal("/oauth/authorize", holder[:authorization_url].path)
643+
assert_requested(:post, "https://srv.example.com/oauth/register")
644+
assert_requested(:post, "https://srv.example.com/oauth/token")
645+
end
646+
647+
def test_run_falls_back_to_default_endpoints_without_any_metadata
648+
# Legacy 2025-03-26 "Fallbacks for Servers without Metadata Discovery": with no PRM and no AS metadata,
649+
# the client MUST use /authorize, /token, and /register at the authorization base URL, still sending PKCE S256.
650+
stub_prm_not_found
651+
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
652+
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
653+
stub_request(:post, "https://srv.example.com/register").to_return(
654+
status: 201,
655+
headers: { "Content-Type" => "application/json" },
656+
body: JSON.generate(client_id: "legacy-client"),
657+
)
658+
stub_request(:post, "https://srv.example.com/token").to_return(
659+
status: 200,
660+
headers: { "Content-Type" => "application/json" },
661+
body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600),
662+
)
663+
664+
holder = {}
665+
provider = build_legacy_discovery_provider(holder)
666+
667+
result = Flow.new(provider: provider).run!(server_url: @server_url)
668+
669+
assert_equal(:authorized, result)
670+
assert_equal("/authorize", holder[:authorization_url].path)
671+
query = URI.decode_www_form(holder[:authorization_url].query).to_h
672+
assert_equal("S256", query["code_challenge_method"])
673+
refute_empty(query["code_challenge"].to_s)
674+
assert_requested(:post, "https://srv.example.com/register")
675+
assert_requested(:post, "https://srv.example.com/token") do |req|
676+
URI.decode_www_form(req.body).to_h["code_verifier"].to_s != ""
677+
end
678+
end
679+
680+
def test_run_legacy_fallback_rejects_insecure_authorization_base
681+
# The Communication Security requirement still applies on the legacy path: a remote plain-http origin must not
682+
# become the authorization base URL.
683+
stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404)
684+
stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
685+
686+
holder = {}
687+
provider = build_legacy_discovery_provider(holder)
688+
689+
error = assert_raises(Flow::AuthorizationError) do
690+
Flow.new(provider: provider).run!(server_url: "http://internal.example.com/mcp")
691+
end
692+
693+
assert_match(/legacy authorization base URL/, error.message)
694+
end
695+
696+
def test_run_keeps_strict_issuer_validation_when_prm_is_present
697+
# The legacy issuer-check relaxation must not leak into the modern path: with PRM present,
698+
# a mismatched issuer still aborts.
699+
stub_request(:get, @as_metadata_url).to_return(
700+
status: 200,
701+
headers: { "Content-Type" => "application/json" },
702+
body: JSON.generate(
703+
issuer: "https://evil.example.com",
704+
authorization_endpoint: "#{@auth_base}/authorize",
705+
token_endpoint: "#{@auth_base}/token",
706+
registration_endpoint: "#{@auth_base}/register",
707+
code_challenge_methods_supported: ["S256"],
708+
),
709+
)
710+
711+
holder = {}
712+
provider = build_legacy_discovery_provider(holder)
713+
714+
error = assert_raises(Flow::AuthorizationError) do
715+
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
716+
end
717+
718+
assert_match(/`issuer` does not match/, error.message)
573719
end
574720

575721
def test_run_raises_when_prm_authorization_servers_is_not_an_array

test/mcp/client/oauth/http_oauth_test.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,11 @@ def test_send_request_does_not_loop_when_oauth_flow_fails
13041304
body: "",
13051305
)
13061306

1307+
# With no PRM, discovery falls back to the legacy 2025-03-26 path; dead-end that too so the flow fails exactly once.
1308+
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
1309+
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
1310+
stub_request(:post, "https://srv.example.com/register").to_return(status: 404)
1311+
13071312
provider = Provider.new(
13081313
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
13091314
redirect_uri: "http://localhost:0/callback",

0 commit comments

Comments
 (0)