ruby-sdk/lib/mcp/client/oauth/discovery.rb
Koichi ITO ab4075c8c7 Fix RuboCop offenses detected with rubocop-shopify 3.0.0
## Motivation and Context

This repository does not track Gemfile.lock, so `bundle install` resolves to the latest RuboCop gems.
Upgrading rubocop-shopify from 2.x to 3.0.0 surfaces 8 new offenses because the shared config no longer
disables `Style/MutableConstant`, newly disables `Lint/RescueException` and `Naming/AccessorMethodName`,
and drops `AllowedPatterns` for `Naming/MethodName`. Upgrading rubocop itself to 1.88 surfaces one more offense
from the new `Style/ReduceToHash` cop.

This change fixes them as follows:

- Freeze mutable constants. Regexp constants are also flagged because `TargetRubyVersion` is 2.7,
  where Regexp literals are not frozen.
- Remove inline disable directives that became redundant.
- Restore `AllowedPatterns: ['\Atest_']` for `Naming/MethodName` so `def test_` style test names
  can reference class names such as `NoMethodError`.
- Swap out the frozen `SUPPORTED_STABLE_PROTOCOL_VERSIONS` constant in the protocol version fallback test
  since Mocha cannot stub methods on frozen objects.
- Use `to_h` instead of `each_with_object` in `Server#index_resources_by_uri`.

Ref: https://rubygems.org/gems/rubocop-shopify/versions/3.0.0

## How Has This Been Tested?

`bundle exec rake` passes: 1402 runs, 0 failures, 0 errors, and RuboCop reports no offenses.
The conformance suite also passes against its baseline.

## Breaking Changes

None. Freezing these constants only affects code that mutates them in place, which has never been supported.
2026-07-15 01:46:51 +09:00

508 lines
24 KiB
Ruby

# frozen_string_literal: true
require "ipaddr"
require "uri"
module MCP
class Client
module OAuth
# Stateless helpers that map MCP-authorization spec URLs and headers into something
# the `Flow` orchestrator and `MCP::Client::HTTP` transport can act on.
# The module bundles five concerns that share no state but are closely related to
# the spec's "Discovery" and "Communication Security" sections:
#
# - **`WWW-Authenticate` parsing** (`parse_www_authenticate`): pulls
# the Bearer challenge parameters (`resource_metadata`, `scope`, `error`,
# ...) out of a header that may carry multiple challenges per RFC 7235
# and may use `quoted-pair` escapes per RFC 7230 Section 3.2.6.
# - **Discovery URL builders** (`protected_resource_metadata_urls`,
# `authorization_server_metadata_urls`): list the candidate well-known
# URLs to probe when no explicit metadata URL is supplied,
# in the priority order required by RFC 9728 and RFC 8414.
# - **Communication Security check** (`secure_url?`): enforces "HTTPS only"
# for every OAuth-facing URL, with the loopback carve-out described in
# `secure_url?`'s comment.
# - **URL canonicalization** (`canonicalize_url`): normalizes scheme,
# host, port, path, percent-encoded dot segments, and fragments
# so two URLs that *refer to the same resource* compare as equal,
# and drops userinfo so credentials never reach the RFC 8707 `resource` claim
# or any error message.
# - **Resource coverage** (`resource_covers?`): decides whether a PRM `resource` URI
# is allowed to govern a given MCP server URL, i.e. whether the MCP endpoint sits
# "under" the resource per RFC 8707 audience semantics.
#
# Every entry point is a class method so it can be called from initializers and
# from any thread without synchronization.
module Discovery
# Matches a single `key=value` pair inside an HTTP auth-scheme challenge.
# `value` is either a quoted string (which can contain commas and spaces)
# or a bare token, per RFC 7235.
WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
class << self
# Parses a `WWW-Authenticate` header and returns the parameters of
# the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`,
# `scope`, `error`). Returns `{}` when no Bearer challenge is present.
# Handles multiple challenges (e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`)
# by extracting only the Bearer parameters.
#
# - https://www.rfc-editor.org/rfc/rfc9728.html#section-5.1
# - https://www.rfc-editor.org/rfc/rfc7235.html#section-4.1
def parse_www_authenticate(header)
return {} unless header
# Locate the Bearer challenge: at the start of the header or after a comma.
bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
return {} unless bearer
# Walk key=value pairs starting where Bearer's parameters begin.
# The loop stops at the first token that is not a key=value pair,
# which marks the next challenge (e.g. `, DPoP algs="..."`).
cursor = bearer.end(0)
params = {}
while cursor < header.length
prefix = header[cursor..]
prefix = prefix.sub(/\A\s*,?\s*/, "")
break if prefix.empty?
match = prefix.match(WWW_AUTH_PARAM_PATTERN)
break unless match
params[match[1].downcase] = match[2] ? unescape_quoted_pair(match[2]) : match[3]
cursor = header.length - prefix.length + match.end(0)
end
params
end
# Returns the candidate Protected Resource Metadata URLs to probe, in priority order.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
def protected_resource_metadata_urls(server_url:, resource_metadata_url: nil)
urls = []
urls << resource_metadata_url if resource_metadata_url
uri = URI.parse(server_url)
path = uri.path == "/" ? "" : uri.path.to_s
base = base_url(uri)
urls << "#{base}/.well-known/oauth-protected-resource#{path}"
urls << "#{base}/.well-known/oauth-protected-resource"
urls.uniq
end
# Returns the candidate Authorization Server metadata URLs to probe, in priority order.
#
# Per SEP-2351, MCP uses the default `oauth-authorization-server` well-known URI suffix
# registered by RFC 8414 Section 7.3 and defines no application-specific suffix of its own.
# The OAuth candidates below therefore use only that default suffix
# (plus the `openid-configuration` suffix from OpenID Connect Discovery),
# both in the RFC 8414 Section 3.1 path-inserted form for issuers with a path component
# and in the root form for issuers without one.
#
# - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351
# - https://www.rfc-editor.org/rfc/rfc8414#section-3.1
def authorization_server_metadata_urls(issuer_url)
uri = URI.parse(issuer_url)
path = uri.path == "/" ? "" : uri.path.to_s
base = base_url(uri)
if path.empty?
["#{base}/.well-known/oauth-authorization-server", "#{base}/.well-known/openid-configuration"]
else
[
"#{base}/.well-known/oauth-authorization-server#{path}",
"#{base}/.well-known/openid-configuration#{path}",
"#{base}#{path}/.well-known/openid-configuration",
]
end
end
# Returns a canonical form of `url` suitable for comparing two URIs
# that are meant to identify the same protected resource: lowercased scheme/host,
# default port stripped, fragment removed, percent-encoded dot octets normalized
# to `.` per RFC 3986 Section 6.2.2.2, dot-segments in the path resolved per
# RFC 3986 Section 5.2.4, and a single trailing `/` on the root path normalized away.
#
# Userinfo is *dropped*. The MCP authorization spec sends the canonicalized URL
# on the wire as the RFC 8707 `resource` claim and surfaces it in error messages;
# both paths would leak `user:pass@` credentials to the authorization server and
# to log destinations if we preserved them. The MCP server URI does not legitimately
# carry userinfo, so dropping it is also a no-op for normal traffic.
#
# Decoding `%2e`/`%2E` *before* dot-segment resolution is what prevents
# an attacker-supplied URL like `https://srv.example.com/api/%2e%2e/mcp` from sneaking
# past the PRM `resource` check in `resource_covers?`.
def canonicalize_url(url)
uri = URI.parse(url.to_s)
uri.fragment = 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.scheme = uri.scheme.downcase if uri.scheme
uri.host = uri.host.downcase if uri.host
uri.port = nil if uri.port == uri.default_port
path = uri.path.to_s.gsub(/%2[eE]/, ".")
uri.path = remove_dot_segments(path)
uri.path = "" if uri.path == "/"
uri.query = normalize_query(uri.query)
uri.to_s
end
# Returns true when `url` is safe to use for OAuth communication per
# the MCP authorization spec's "Communication Security" requirement:
# `https` is always allowed, `http` is permitted only when the host is
# a loopback address (`localhost`, `127.0.0.0/8`, or `::1`).
#
# The loopback exception applies uniformly to every OAuth-related URL
# the SDK consumes (PRM URL, AS metadata URL, `authorization_servers`
# entries, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
# the `redirect_uri`, and the MCP transport URL when `oauth:` is set).
# A strict reading of OAuth 2.1 reserves the loopback carve-out for
# `redirect_uri` only (per RFC 8252), but neither the Python nor
# the TypeScript MCP SDK enforces HTTPS on those endpoints either -
# and the official MCP conformance test suite drives its fixtures
# over `http://localhost` auth servers, so enforcing HTTPS for everything
# except `redirect_uri` would break local development out of the box and
# regress 16 conformance scenarios. Operators who run in production are
# expected to deploy real HTTPS endpoints; this helper does not enforce
# that at the SDK boundary.
#
# Rejects URLs that fail to parse, lack a host, or whose `http://` host is
# something like `127.attacker.com` or `foo.localhost`,
# which would otherwise pass a naive `start_with?("127.")` check.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
def secure_url?(url)
return false if url.nil? || url.to_s.empty?
uri = URI.parse(url.to_s)
return false if uri.host.nil? || uri.host.empty?
scheme = uri.scheme&.downcase
return true if scheme == "https"
return loopback_host?(uri.host) if scheme == "http"
false
rescue URI::InvalidURIError
false
end
# Returns true when `url` satisfies the structural requirements for
# a Client ID Metadata Document URL per the MCP 2025-11-25
# authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`.
#
# Spec-required:
#
# - scheme MUST be `https` (the loopback-`http` carve-out used for discovery does not apply:
# the document URL is sent verbatim to the authorization server as the OAuth `client_id`
# and travels off-loopback)
# - host MUST be present
# - path MUST be non-empty and MUST NOT be the root (`/`); the document is a discrete resource,
# not the origin
# - URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo
# would leak credentials into every `client_id` log line
# - path MUST be already free of `.` / `..` dot segments after percent-decoding, so two URLs with
# the same effective path do not produce different `client_id` strings
#
# SDK policy (stricter than the draft):
#
# - URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include,
# but different encodings of the same query (`?a=1&b=2` vs `?b=2&a=1`) would yield distinct
# `client_id` values for the same logical document.
def client_id_metadata_document_url?(url)
return false if url.nil? || url.to_s.empty?
uri = URI.parse(url.to_s)
return false unless uri.scheme&.downcase == "https"
return false if uri.host.nil? || uri.host.empty?
return false unless uri.fragment.nil?
return false unless uri.query.nil?
return false if uri.respond_to?(:user) && (uri.user || uri.password)
path = uri.path.to_s
return false if path.empty? || path == "/"
decoded = path.gsub(/%2[eE]/, ".")
segments = decoded.split("/", -1)
return false if segments.any? { |segment| segment == "." || segment == ".." }
true
rescue URI::InvalidURIError
false
end
# Infers the OIDC Dynamic Client Registration `application_type` for a client from its `redirect_uris`.
# Per SEP-837, MCP clients MUST specify an appropriate application type during Dynamic Client Registration
# so the authorization server can apply the matching redirect URI policy.
#
# Returns `"native"` when every redirect URI is a native-app URI: a custom non-http(s) scheme (RFC 8252 Section 7.1)
# or an http(s) URI whose host is a loopback address (`localhost`, `127.0.0.0/8`, or `::1`, RFC 8252 Section 7.3).
# Returns `"web"` otherwise, including when `redirect_uris` is nil, empty, or contains an unparseable URI.
#
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
# - https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
def infer_application_type(redirect_uris)
uris = Array(redirect_uris)
return "web" if uris.empty?
uris.all? { |uri| native_redirect_uri?(uri) } ? "native" : "web"
end
# Like `canonicalize_url` but also strips query string, fragment, and
# userinfo. This variant is used for identity comparison against
# the request URL Faraday actually sends, which differs from the value
# the caller passed in two ways: `Faraday::Connection#url_prefix`
# drops query parameters, and Faraday hoists `user:pass@` out of
# the URL into an `Authorization: Basic` header before the request goes
# out. Including userinfo here would (a) raise a false-positive
# `InsecureURLError` on any legitimate URL with credentials in
# the authority, and (b) leak `user:pass` through the resulting error
# message - both of which would defeat the bearer-token-protection
# purpose of the identity check.
def canonicalize_origin_and_path(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.scheme = uri.scheme.downcase if uri.scheme
uri.host = uri.host.downcase if uri.host
uri.port = nil if uri.port == uri.default_port
path = uri.path.to_s.gsub(/%2[eE]/, ".")
uri.path = remove_dot_segments(path)
uri.path = "" if uri.path == "/"
uri.to_s
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 a hijacked PRM that advertises `?tenant=evil` would cover
# an MCP server at `?tenant=victim` and let the attacker mint
# a different tenant's token for the same origin + path).
# PRM with *no* query (URI#query returns `nil`) acts as a generic identifier
# over the origin + path prefix and covers any server query.
#
# An empty query (`prm_url?` -- URI#query returns `""`) is NOT
# treated as wildcard: it represents the URI literally `<...>?`,
# which is distinct from "no query at all" and from any non-empty query,
# so it must match exactly.
#
# Both arguments must already be canonicalized.
def resource_covers?(prm:, server:)
prm_uri = URI.parse(prm)
server_uri = URI.parse(server)
return false unless prm_uri.scheme == server_uri.scheme &&
prm_uri.host == server_uri.host &&
prm_uri.port == server_uri.port
prm_path = prm_uri.path.to_s
server_path = server_uri.path.to_s
prm_path = "" if prm_path == "/"
server_path = "" if server_path == "/"
path_covers = server_path == prm_path || server_path.start_with?("#{prm_path}/")
return false unless path_covers
prm_query = prm_uri.query
return true if prm_query.nil?
prm_query == server_uri.query
end
private
# Unescapes a `quoted-string` value's `quoted-pair` octets per
# RFC 7230 Section 3.2.6 (referenced from RFC 7235): `\<char>` becomes `<char>`.
# https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6
def unescape_quoted_pair(value)
value.gsub(/\\(.)/, '\1')
end
# Recognizes the IPv4 loopback range (`127.0.0.0/8`), IPv6 loopback
# (`::1`, optionally bracketed by `URI.parse`), and the `localhost`
# hostname (matched exactly so that hostnames like `foo.localhost` or
# `127.attacker.com` are not treated as loopback).
IPV4_LOOPBACK_RANGE = IPAddr.new("127.0.0.0/8")
IPV6_LOOPBACK = IPAddr.new("::1")
private_constant :IPV4_LOOPBACK_RANGE, :IPV6_LOOPBACK
def loopback_host?(host)
return false if host.nil? || host.empty?
normalized = host.downcase
return true if normalized == "localhost"
ip_candidate = normalized.delete_prefix("[").delete_suffix("]")
address = parse_ip_address(ip_candidate)
return false unless address
return IPV4_LOOPBACK_RANGE.include?(address) if address.ipv4?
return address == IPV6_LOOPBACK if address.ipv6?
false
end
def parse_ip_address(candidate)
IPAddr.new(candidate)
rescue IPAddr::Error
nil
end
# A redirect URI counts as native when it uses a custom non-http(s) scheme
# (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is
# a loopback address. A URI without a scheme or one that fails to parse is not native.
def native_redirect_uri?(url)
uri = URI.parse(url.to_s)
scheme = uri.scheme&.downcase
return false if scheme.nil?
return loopback_host?(uri.host) if ["http", "https"].include?(scheme)
true
rescue URI::InvalidURIError
false
end
def base_url(uri)
port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
"#{uri.scheme}://#{uri.host}#{port_part}"
end
# Normalizes a URL query string so two URLs that are equivalent in
# OAuth identity terms compare as equal. This is required because
# Faraday transparently rewrites `env.url` before sending a request:
#
# - Parameters get sorted by name (`?b=2&a=1` -> `?a=1&b=2`).
# - Percent-encoded hex is uppercased (`?x=%2f` -> `?x=%2F`).
# - A trailing `?` with no parameters is dropped (`?` -> no query).
# - Same-name keys are collapsed so only the last value survives
# (`?a=1&a=2` -> `?a=2`).
# - Empty-name pairs and blank `&&` separators are dropped
# (`?=v` -> no query, `?&&a=1&&` -> `?a=1`).
# - Value-less keys (`?tenant`) and empty-value keys (`?tenant=`)
# are kept distinct -- Faraday preserves the `=` exactly as
# the caller passed it. `URI.decode_www_form` / `encode_www_form`
# would collapse both to `?tenant=`, so this function does
# the parsing by hand on `&`-separated segments.
#
# Without applying the same transformation to our snapshotted URL,
# the request-time URL guard would false-positive on every URL that
# falls under one of the rules above.
#
# Returns `nil` when the resulting query is empty
# (matching Faraday's drop-empty-query behavior).
def normalize_query(query)
return if query.nil? || query.empty?
# Each segment becomes `[decoded_name, has_equals?, decoded_value_or_nil]`.
# `has_equals?` is what lets us preserve the `?key` vs `?key=`
# distinction that Faraday respects.
parsed = query.split("&").filter_map do |segment|
next if segment.empty?
name_raw, separator, value_raw = segment.partition("=")
name = URI.decode_www_form_component(name_raw)
next if name.empty?
has_equals = !separator.empty?
value = has_equals ? URI.decode_www_form_component(value_raw) : nil
[name, has_equals, value]
end
# Keys ending in `[]` (`tenant[]`, `roles[]`) are Faraday's array
# notation: the encoder preserves every occurrence in input order
# instead of collapsing them. All other keys (`tenant`, `a[b]`,
# plain scalars) are collapsed with last-write-wins semantics.
# Separating the two avoids a false negative where a hijacked
# middleware drops an entry from a `?tenant[]=victim&tenant[]=...`
# URL and slips past the guard, and a false positive on
# a legitimate `?roles[]=a&roles[]=b` URL.
array_segments = []
scalar_segments = {}
parsed.each do |name, has_equals, value|
if name.end_with?("[]")
array_segments << [name, has_equals, value]
else
scalar_segments[name] = [has_equals, value]
end
end
scalar_entries = scalar_segments.map { |name, (has_equals, value)| [name, has_equals, value] }
combined = scalar_entries + array_segments
return if combined.empty?
# Stable sort by name: array entries that share a name keep their
# original order, while scalar names are alphabetized to match
# Faraday's deterministic encoding order.
combined.each_with_index
.sort_by { |(name, _, _), index| [name, index] }
.map do |(name, has_equals, value), _index|
encoded_name = URI.encode_www_form_component(name)
if has_equals
"#{encoded_name}=#{URI.encode_www_form_component(value)}"
else
encoded_name
end
end.join("&")
end
# Implements RFC 3986 Section 5.2.4 `remove_dot_segments`. Walks the input
# buffer one segment at a time, popping the previous output segment
# whenever a `..` is encountered, so that `/api/../mcp` collapses to
# `/mcp` and `/foo/./bar` collapses to `/foo/bar`.
# https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4
def remove_dot_segments(path)
return path if path.nil? || path.empty?
input = path.dup
output = +""
until input.empty?
if input.start_with?("../")
input = input[3..]
elsif input.start_with?("./")
input = input[2..]
elsif input.start_with?("/./")
input = "/#{input[3..]}"
elsif input == "/."
input = "/"
elsif input.start_with?("/../")
input = "/#{input[4..]}"
output = remove_last_segment(output)
elsif input == "/.."
input = "/"
output = remove_last_segment(output)
elsif input == "." || input == ".."
input = ""
else
segment = input.match(%r{\A/?[^/]*})[0]
output << segment
input = input[segment.length..]
end
end
output
end
def remove_last_segment(output)
idx = output.rindex("/")
return +"" if idx.nil?
output[0...idx]
end
end
end
end
end
end