Merge pull request #353 from koic/add_oauth_2_1_client_support_for_mcp_authorization_flow
Add OAuth 2.1 client support for MCP authorization flow
This commit is contained in:
commit
2832bc0984
8 changed files with 4600 additions and 70 deletions
|
|
@ -1,5 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "client/oauth"
|
||||
require_relative "client/stdio"
|
||||
require_relative "client/http"
|
||||
require_relative "client/paginated_result"
|
||||
|
|
|
|||
|
|
@ -17,12 +17,74 @@ module MCP
|
|||
SESSION_ID_HEADER = "Mcp-Session-Id"
|
||||
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
|
||||
|
||||
attr_reader :url, :session_id, :protocol_version, :server_info
|
||||
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
||||
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
||||
# is trivially observed and stolen.
|
||||
class InsecureURLError < ArgumentError; end
|
||||
|
||||
# Faraday request middleware that compares the outgoing request URL
|
||||
# against the URL snapshotted at `MCP::Client::HTTP#initialize` time.
|
||||
# Registered after the user's customizer so it sees `env.url` *after*
|
||||
# any custom middleware has had a chance to rewrite it - closing
|
||||
# the `Faraday env.url = URI("https://attacker...")` bypass that a plain
|
||||
# `client.url_prefix` check would miss. The comparison includes
|
||||
# the query string, so a middleware that rewrites `env.url.query` to
|
||||
# a different tenant (e.g. `?tenant=evil`) is rejected as well; otherwise
|
||||
# the audience-binding check on the OAuth side could be bypassed at
|
||||
# the send step.
|
||||
class OAuthURLGuard
|
||||
def initialize(app, expected_url:)
|
||||
@app = app
|
||||
@expected_url = expected_url
|
||||
end
|
||||
|
||||
def call(env)
|
||||
effective = MCP::Client::OAuth::Discovery.canonicalize_url(env.url.to_s)
|
||||
unless effective == @expected_url
|
||||
# Surface the *canonicalized* form (no userinfo, no fragment) so
|
||||
# credentials like `user:pass@` cannot leak into logs, stack
|
||||
# traces, or exception reporters.
|
||||
raise InsecureURLError,
|
||||
"Effective request URL #{effective.inspect} does not match the URL " \
|
||||
"validated at initialize time (#{@expected_url.inspect}); refusing to send a bearer token."
|
||||
end
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
|
||||
|
||||
def initialize(url:, headers: {}, oauth: nil, &block)
|
||||
if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url)
|
||||
# Mask credentials (userinfo) and query parameters before quoting the URL in the error message
|
||||
# so they cannot leak into logs.
|
||||
safe_url = MCP::Client::OAuth::Discovery.canonicalize_origin_and_path(url)
|
||||
raise InsecureURLError,
|
||||
"MCP URL #{safe_url.inspect} must use https or be a loopback http URL when an oauth provider is set; " \
|
||||
"sending bearer tokens over plain http to a remote host would leak them on the wire."
|
||||
end
|
||||
|
||||
def initialize(url:, headers: {}, &block)
|
||||
@url = url
|
||||
@headers = headers
|
||||
@faraday_customizer = block
|
||||
@oauth = oauth
|
||||
# Snapshot the canonical URL at construction time. This single value
|
||||
# serves two related roles, both of which need to see the query string:
|
||||
#
|
||||
# - As the RFC 8707 `resource` claim sent on the authorization and
|
||||
# token requests (and as the base for PRM discovery URLs) -
|
||||
# matching the TS / Python SDKs' `resourceUrlFromServerUrl` /
|
||||
# `resource_url_from_server_url` so multi-tenant servers that scope
|
||||
# by `?tenant=...` round-trip correctly.
|
||||
# - As the comparison value for the URL guard middleware. Comparing
|
||||
# query strings as well as origin + path is required so a Faraday
|
||||
# middleware that rewrites `env.url.query` to a different tenant
|
||||
# cannot send the bearer token to the wrong audience while
|
||||
# the resource binding on the OAuth side stays correct.
|
||||
#
|
||||
# Saved only when `oauth:` is set so non-OAuth transports keep their
|
||||
# existing behavior.
|
||||
@oauth_server_url = oauth ? MCP::Client::OAuth::Discovery.canonicalize_url(url) : nil
|
||||
@session_id = nil
|
||||
@protocol_version = nil
|
||||
@server_info = nil
|
||||
|
|
@ -30,8 +92,8 @@ module MCP
|
|||
end
|
||||
|
||||
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
||||
# followed by the required `notifications/initialized` notification. The
|
||||
# server's `InitializeResult` (protocol version, capabilities, server
|
||||
# followed by the required `notifications/initialized` notification.
|
||||
# The server's `InitializeResult` (protocol version, capabilities, server
|
||||
# info, instructions) is cached on the transport and returned.
|
||||
#
|
||||
# Idempotent: a second call returns the cached `InitializeResult` without
|
||||
|
|
@ -122,68 +184,80 @@ module MCP
|
|||
def send_request(request:)
|
||||
method = request[:method] || request["method"]
|
||||
params = request[:params] || request["params"]
|
||||
oauth_retried = false
|
||||
|
||||
response = client.post("", request, session_headers)
|
||||
body = parse_response_body(response, method, params)
|
||||
begin
|
||||
response = client.post("", request, session_headers)
|
||||
body = parse_response_body(response, method, params)
|
||||
|
||||
capture_session_info(method, response, body)
|
||||
capture_session_info(method, response, body)
|
||||
|
||||
body
|
||||
rescue Faraday::BadRequestError => e
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is invalid",
|
||||
{ method: method, params: params },
|
||||
error_type: :bad_request,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::UnauthorizedError => e
|
||||
raise RequestHandlerError.new(
|
||||
"You are unauthorized to make #{method} requests",
|
||||
{ method: method, params: params },
|
||||
error_type: :unauthorized,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::ForbiddenError => e
|
||||
raise RequestHandlerError.new(
|
||||
"You are forbidden to make #{method} requests",
|
||||
{ method: method, params: params },
|
||||
error_type: :forbidden,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::ResourceNotFound => e
|
||||
# Per spec, 404 is the session-expired signal only when the request
|
||||
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
|
||||
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
||||
if @session_id
|
||||
clear_session
|
||||
raise SessionExpiredError.new(
|
||||
"The #{method} request is not found",
|
||||
body
|
||||
rescue Faraday::BadRequestError => e
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is invalid",
|
||||
{ method: method, params: params },
|
||||
error_type: :bad_request,
|
||||
original_error: e,
|
||||
)
|
||||
else
|
||||
rescue Faraday::UnauthorizedError => e
|
||||
# Run the OAuth flow at most once per `send_request` invocation.
|
||||
# The `oauth_retried` flag lives outside the `begin` so it survives `retry`,
|
||||
# ensuring a server returning 401 indefinitely raises rather than loops.
|
||||
if @oauth && !oauth_retried
|
||||
oauth_retried = true
|
||||
run_oauth_flow!(unauthorized_error: e)
|
||||
retry
|
||||
end
|
||||
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is not found",
|
||||
"You are unauthorized to make #{method} requests",
|
||||
{ method: method, params: params },
|
||||
error_type: :not_found,
|
||||
error_type: :unauthorized,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::ForbiddenError => e
|
||||
raise RequestHandlerError.new(
|
||||
"You are forbidden to make #{method} requests",
|
||||
{ method: method, params: params },
|
||||
error_type: :forbidden,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::ResourceNotFound => e
|
||||
# Per spec, 404 is the session-expired signal only when the request
|
||||
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
|
||||
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
||||
if @session_id
|
||||
clear_session
|
||||
raise SessionExpiredError.new(
|
||||
"The #{method} request is not found",
|
||||
{ method: method, params: params },
|
||||
original_error: e,
|
||||
)
|
||||
else
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is not found",
|
||||
{ method: method, params: params },
|
||||
error_type: :not_found,
|
||||
original_error: e,
|
||||
)
|
||||
end
|
||||
rescue Faraday::UnprocessableEntityError => e
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is unprocessable",
|
||||
{ method: method, params: params },
|
||||
error_type: :unprocessable_entity,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::Error => e
|
||||
raise RequestHandlerError.new(
|
||||
"Internal error handling #{method} request",
|
||||
{ method: method, params: params },
|
||||
error_type: :internal_error,
|
||||
original_error: e,
|
||||
)
|
||||
end
|
||||
rescue Faraday::UnprocessableEntityError => e
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is unprocessable",
|
||||
{ method: method, params: params },
|
||||
error_type: :unprocessable_entity,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::Error => e # Catch-all
|
||||
raise RequestHandlerError.new(
|
||||
"Internal error handling #{method} request",
|
||||
{ method: method, params: params },
|
||||
error_type: :internal_error,
|
||||
original_error: e,
|
||||
)
|
||||
end
|
||||
|
||||
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
|
||||
|
|
@ -228,20 +302,93 @@ module MCP
|
|||
end
|
||||
|
||||
@faraday_customizer&.call(faraday)
|
||||
|
||||
# Register the URL identity guard *after* the user's customizer
|
||||
# so it sits closest to the adapter in the request stack. That way
|
||||
# the guard sees `env.url` after any customizer-added middleware has had
|
||||
# a chance to rewrite it, closing the `env.url = URI("https://...")`
|
||||
# bypass that a `Faraday::Connection#url_prefix` check cannot detect.
|
||||
faraday.use(OAuthURLGuard, expected_url: @oauth_server_url) if @oauth_server_url
|
||||
end
|
||||
end
|
||||
|
||||
# Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one)
|
||||
# and `MCP-Protocol-Version` on all requests after `initialize`.
|
||||
#
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
|
||||
def session_headers
|
||||
request_headers = {}
|
||||
request_headers[SESSION_ID_HEADER] = @session_id if @session_id
|
||||
request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version
|
||||
if @oauth && (token = @oauth.access_token)
|
||||
request_headers["Authorization"] = "Bearer #{token}"
|
||||
end
|
||||
request_headers
|
||||
end
|
||||
|
||||
# Drives the OAuth orchestrator on a 401 from the MCP endpoint.
|
||||
# The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
|
||||
# URL and an optional `scope` challenge per RFC 9728 Section 5.1.
|
||||
#
|
||||
# If the provider already holds a refresh token, we try to exchange it
|
||||
# for a fresh access token first; only when that fails (e.g., the refresh token was revoked)
|
||||
# do we fall back to the full Authorization Code + PKCE + DCR flow.
|
||||
#
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling
|
||||
def run_oauth_flow!(unauthorized_error:)
|
||||
response = unauthorized_error.response || {}
|
||||
response_headers = response[:headers] || {}
|
||||
www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
|
||||
params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate)
|
||||
|
||||
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
|
||||
if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
|
||||
return
|
||||
end
|
||||
|
||||
# Use the URL snapshotted at `initialize` time so a post-construction
|
||||
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
|
||||
# URL to an attacker-controlled host.
|
||||
flow.run!(
|
||||
server_url: @oauth_server_url,
|
||||
resource_metadata_url: params["resource_metadata"],
|
||||
scope: params["scope"],
|
||||
)
|
||||
end
|
||||
|
||||
# Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy
|
||||
# on success and falsy on either "no refresh token available" or "refresh attempt failed"
|
||||
# (in which case the caller should run the full interactive flow).
|
||||
def attempt_refresh(flow:, resource_metadata_url:)
|
||||
return false unless refresh_token_available?
|
||||
|
||||
# Use the snapshotted URL for the same reason as `run_oauth_flow!` above:
|
||||
# post-construction `@url` mutation must not redirect token-refresh
|
||||
# discovery to an attacker-controlled host. Use the query-bearing form
|
||||
# so the refresh request's RFC 8707 `resource` claim matches
|
||||
# the original authorization request.
|
||||
flow.refresh!(server_url: @oauth_server_url, resource_metadata_url: resource_metadata_url)
|
||||
true
|
||||
rescue MCP::Client::OAuth::Flow::InvalidGrantError
|
||||
# The refresh token has been revoked or expired by the AS. Wipe it so
|
||||
# the full interactive flow runs fresh on the retry.
|
||||
@oauth.clear_tokens!
|
||||
false
|
||||
rescue MCP::Client::OAuth::Flow::AuthorizationError
|
||||
# Transient failure (network, 5xx, AS metadata, etc.). Leave the refresh
|
||||
# token in place; the next attempt may succeed and we avoid forcing
|
||||
# the user through an interactive reauth for a recoverable error.
|
||||
false
|
||||
end
|
||||
|
||||
def refresh_token_available?
|
||||
tokens = @oauth.tokens
|
||||
return false unless tokens.is_a?(Hash)
|
||||
|
||||
!(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty?
|
||||
end
|
||||
|
||||
def capture_session_info(method, response, body)
|
||||
return unless method.to_s == Methods::INITIALIZE
|
||||
|
||||
|
|
|
|||
18
lib/mcp/client/oauth.rb
Normal file
18
lib/mcp/client/oauth.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "oauth/discovery"
|
||||
require_relative "oauth/flow"
|
||||
require_relative "oauth/in_memory_storage"
|
||||
require_relative "oauth/pkce"
|
||||
require_relative "oauth/provider"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
# OAuth client support for the MCP Authorization spec (PRM discovery,
|
||||
# Authorization Server metadata discovery, Dynamic Client Registration,
|
||||
# OAuth 2.1 Authorization Code + PKCE).
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
||||
module OAuth
|
||||
end
|
||||
end
|
||||
end
|
||||
423
lib/mcp/client/oauth/discovery.rb
Normal file
423
lib/mcp/client/oauth/discovery.rb
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
# 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,]+))/
|
||||
|
||||
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.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
587
lib/mcp/client/oauth/flow.rb
Normal file
587
lib/mcp/client/oauth/flow.rb
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "base64"
|
||||
require "json"
|
||||
require "openssl"
|
||||
require "securerandom"
|
||||
require "uri"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow.
|
||||
# Driven by `MCP::Client::HTTP` on a 401 response. The user-facing surface is
|
||||
# `Provider`; this class consumes a Provider plus signal data extracted from
|
||||
# the failing response (resource_metadata URL, scope challenge).
|
||||
class Flow
|
||||
class AuthorizationError < StandardError; end
|
||||
|
||||
# Raised specifically when the token endpoint rejects a grant with
|
||||
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
|
||||
# distinguish "the stored refresh token is dead, discard it" from
|
||||
# transient failures (network, 5xx, other RFC 6749 error codes) that
|
||||
# should leave the refresh token intact.
|
||||
class InvalidGrantError < AuthorizationError; end
|
||||
|
||||
def initialize(provider:, http_client_factory: nil)
|
||||
@provider = provider
|
||||
@http_client_factory = http_client_factory || -> { default_http_client }
|
||||
end
|
||||
|
||||
# Runs the full discovery, registration, authorization, and token exchange flow.
|
||||
# On success, persists tokens via the provider and returns `:authorized`.
|
||||
def run!(server_url:, resource_metadata_url: nil, scope: nil)
|
||||
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
|
||||
# first thing we contact in the OAuth flow, so it has to clear the same
|
||||
# Communication Security bar as the OAuth endpoints downstream.
|
||||
if resource_metadata_url
|
||||
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
||||
end
|
||||
|
||||
prm = fetch_protected_resource_metadata(
|
||||
server_url: server_url,
|
||||
resource_metadata_url: resource_metadata_url,
|
||||
)
|
||||
authorization_server = first_authorization_server(prm)
|
||||
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
||||
|
||||
# Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
|
||||
# both the authorization and token requests. When PRM advertises a `resource`,
|
||||
# it MUST identify the same MCP server we are talking to; otherwise we are
|
||||
# being redirected to credentials minted for a different audience.
|
||||
resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
|
||||
|
||||
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
||||
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
||||
ensure_secure_endpoints!(as_metadata)
|
||||
ensure_pkce_supported!(as_metadata)
|
||||
|
||||
client_info = ensure_client_registered(as_metadata: as_metadata)
|
||||
|
||||
effective_scope = resolve_scope(scope: scope, prm: prm)
|
||||
pkce = PKCE.generate
|
||||
state = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
authorization_url = build_authorization_url(
|
||||
as_metadata: as_metadata,
|
||||
client_id: client_info_required_value(client_info, "client_id"),
|
||||
scope: effective_scope,
|
||||
state: state,
|
||||
code_challenge: pkce[:code_challenge],
|
||||
resource: resource,
|
||||
)
|
||||
|
||||
@provider.redirect_handler.call(authorization_url)
|
||||
code, returned_state = Array(@provider.callback_handler.call)
|
||||
raise AuthorizationError, "Authorization callback did not return an authorization code." unless code
|
||||
|
||||
unless states_match?(returned_state, state)
|
||||
raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
|
||||
end
|
||||
|
||||
tokens = exchange_authorization_code(
|
||||
as_metadata: as_metadata,
|
||||
client_info: client_info,
|
||||
code: code,
|
||||
code_verifier: pkce[:code_verifier],
|
||||
resource: resource,
|
||||
)
|
||||
|
||||
@provider.save_tokens(tokens)
|
||||
:authorized
|
||||
end
|
||||
|
||||
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749
|
||||
# Section 6). Re-discovers PRM and AS metadata so we always pick up a moved
|
||||
# token endpoint, and re-runs the audience / issuer / security checks
|
||||
# before talking to it.
|
||||
#
|
||||
# Returns `:refreshed` on success. Raises `AuthorizationError` when
|
||||
# the provider has no refresh token, no client information, or when
|
||||
# the token endpoint refuses the refresh request.
|
||||
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
||||
def refresh!(server_url:, resource_metadata_url: nil)
|
||||
refresh_token = read_token("refresh_token")
|
||||
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
|
||||
|
||||
client_info = @provider.client_information
|
||||
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
|
||||
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
|
||||
end
|
||||
|
||||
if resource_metadata_url
|
||||
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
||||
end
|
||||
|
||||
prm = fetch_protected_resource_metadata(
|
||||
server_url: server_url,
|
||||
resource_metadata_url: resource_metadata_url,
|
||||
)
|
||||
authorization_server = first_authorization_server(prm)
|
||||
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
||||
|
||||
resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
|
||||
|
||||
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
||||
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
||||
ensure_secure_endpoints!(as_metadata)
|
||||
|
||||
new_tokens = exchange_refresh_token(
|
||||
as_metadata: as_metadata,
|
||||
client_info: client_info,
|
||||
refresh_token: refresh_token,
|
||||
resource: resource,
|
||||
)
|
||||
|
||||
@provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
|
||||
:refreshed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def read_token(key)
|
||||
tokens = @provider.tokens
|
||||
return unless tokens.is_a?(Hash)
|
||||
|
||||
value = tokens[key] || tokens[key.to_sym]
|
||||
value.to_s.empty? ? nil : value
|
||||
end
|
||||
|
||||
# Per RFC 6749 Section 6, the refresh response MAY omit `refresh_token`, in
|
||||
# which case the previous one stays valid. Preserve it explicitly so
|
||||
# downstream refresh attempts still work.
|
||||
def preserve_refresh_token(new_tokens, previous_refresh_token)
|
||||
return new_tokens if new_tokens["refresh_token"] || new_tokens[:refresh_token]
|
||||
|
||||
new_tokens.merge("refresh_token" => previous_refresh_token)
|
||||
end
|
||||
|
||||
def fetch_protected_resource_metadata(server_url:, resource_metadata_url:)
|
||||
urls = Discovery.protected_resource_metadata_urls(
|
||||
server_url: server_url,
|
||||
resource_metadata_url: resource_metadata_url,
|
||||
)
|
||||
fetch_metadata_json(urls, label: "protected resource metadata")
|
||||
end
|
||||
|
||||
def fetch_authorization_server_metadata(issuer_url:)
|
||||
urls = Discovery.authorization_server_metadata_urls(issuer_url)
|
||||
fetch_metadata_json(urls, label: "authorization server metadata")
|
||||
end
|
||||
|
||||
# Reads `authorization_servers` from a PRM document and returns
|
||||
# the first entry, raising `AuthorizationError` for any of the malformed
|
||||
# shapes a non-compliant server could emit (missing field, non-Array
|
||||
# value, empty Array, non-String first entry). Centralizing this lets
|
||||
# both the full flow and the refresh flow share the same defensive
|
||||
# parse instead of each one duplicating a Hash-and-Array check.
|
||||
def first_authorization_server(prm)
|
||||
authorization_servers = prm["authorization_servers"]
|
||||
unless authorization_servers.is_a?(Array)
|
||||
raise AuthorizationError,
|
||||
"Protected resource metadata `authorization_servers` is not an array " \
|
||||
"(got #{authorization_servers.class})."
|
||||
end
|
||||
|
||||
if authorization_servers.empty?
|
||||
raise AuthorizationError, "Protected resource metadata has no authorization_servers."
|
||||
end
|
||||
|
||||
first = authorization_servers.first
|
||||
unless first.is_a?(String) && !first.empty?
|
||||
raise AuthorizationError,
|
||||
"Protected resource metadata `authorization_servers[0]` is not a non-empty string."
|
||||
end
|
||||
|
||||
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.
|
||||
def fetch_metadata_json(urls, label:)
|
||||
last_error = nil
|
||||
urls.each do |url|
|
||||
response = begin
|
||||
http_get(url)
|
||||
rescue Faraday::Error => e
|
||||
last_error = "GET #{url} raised #{e.class}: #{e.message}"
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
last_error = "GET #{url} returned #{response.status}"
|
||||
end
|
||||
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
|
||||
end
|
||||
|
||||
def ensure_pkce_supported!(as_metadata)
|
||||
methods = as_metadata["code_challenge_methods_supported"]
|
||||
return if methods.is_a?(Array) && methods.include?("S256")
|
||||
|
||||
raise AuthorizationError,
|
||||
"Authorization server does not advertise S256 PKCE support; refusing to proceed."
|
||||
end
|
||||
|
||||
# Per the MCP authorization spec's Communication Security requirement,
|
||||
# OAuth endpoints MUST use HTTPS unless the host is a loopback address.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
|
||||
def ensure_secure_url!(url, label:)
|
||||
return if Discovery.secure_url?(url)
|
||||
|
||||
raise AuthorizationError,
|
||||
"#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
|
||||
end
|
||||
|
||||
def ensure_secure_endpoints!(as_metadata)
|
||||
["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
|
||||
endpoint = as_metadata[key]
|
||||
ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
|
||||
end
|
||||
end
|
||||
|
||||
# Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
|
||||
# identical (literal byte-for-byte equality, no normalization) to
|
||||
# the issuer URL the client used to discover that document. This guards
|
||||
# against a CDN/relay returning the metadata of a *different*
|
||||
# authorization server than the one PRM advertised, and against
|
||||
# ambiguities like trailing `/`, fragments, or case differences that
|
||||
# could mask a confused-deputy attempt.
|
||||
# https://www.rfc-editor.org/rfc/rfc8414#section-3.3
|
||||
def ensure_issuer_matches!(expected:, returned:)
|
||||
unless returned
|
||||
raise AuthorizationError, "Authorization server metadata is missing the `issuer` field."
|
||||
end
|
||||
|
||||
return if expected.to_s == returned.to_s
|
||||
|
||||
raise AuthorizationError,
|
||||
"Authorization server metadata `issuer` does not match the discovery URL " \
|
||||
"(expected #{expected.inspect}, got #{returned.inspect})."
|
||||
end
|
||||
|
||||
def ensure_client_registered(as_metadata:)
|
||||
existing = @provider.client_information
|
||||
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
|
||||
|
||||
registration_endpoint = as_metadata["registration_endpoint"]
|
||||
unless registration_endpoint
|
||||
raise AuthorizationError,
|
||||
"Authorization server has no registration_endpoint and no pre-registered client information was provided."
|
||||
end
|
||||
|
||||
response = begin
|
||||
http_post_json(registration_endpoint, @provider.client_metadata)
|
||||
rescue Faraday::Error => e
|
||||
raise AuthorizationError,
|
||||
"Dynamic client registration failed: #{e.class}: #{e.message}."
|
||||
end
|
||||
|
||||
if response.status < 200 || response.status >= 300
|
||||
raise AuthorizationError, "Dynamic client registration failed with status #{response.status}."
|
||||
end
|
||||
|
||||
info = begin
|
||||
JSON.parse(response_body_string(response))
|
||||
rescue JSON::ParserError => e
|
||||
raise AuthorizationError,
|
||||
"Failed to parse dynamic client registration response: #{e.message}."
|
||||
end
|
||||
|
||||
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
|
||||
raise AuthorizationError,
|
||||
"Dynamic client registration response is missing `client_id`."
|
||||
end
|
||||
|
||||
@provider.save_client_information(info)
|
||||
info
|
||||
end
|
||||
|
||||
# Reads `key` from a `client_information` hash that may use either string or
|
||||
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
|
||||
# `{ client_id:, client_secret: }` and have both work.
|
||||
def client_info_value(info, key)
|
||||
info[key] || info[key.to_sym]
|
||||
end
|
||||
|
||||
# Same as `client_info_value` but treats blank strings (`""` or only
|
||||
# whitespace) as absent. Used for fields where empty values are never
|
||||
# meaningful (`client_id`, `client_secret`, `token_endpoint_auth_method`)
|
||||
# and would otherwise let a misbehaving AS or hand-built
|
||||
# `client_information` short-circuit the "is the client registered?"
|
||||
# check, or send a literal `client_secret: " "` to the token endpoint.
|
||||
def client_info_required_value(info, key)
|
||||
value = client_info_value(info, key)
|
||||
return if value.nil?
|
||||
return if value.is_a?(String) && value.strip.empty?
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
# Returns the canonical RFC 8707 `resource` URI to send on authorization and
|
||||
# token requests. When PRM advertises `resource`, that value is
|
||||
# the authorization server's idea of the resource identifier and is preferred.
|
||||
# When PRM omits it, the canonicalized MCP server URL is used.
|
||||
#
|
||||
# Either way, we validate that PRM's `resource` covers the MCP server URL
|
||||
# the client is actually talking to (same origin, with PRM's path as a prefix of
|
||||
# the server URL's path) to prevent a malicious or misconfigured PRM from
|
||||
# redirecting credentials to a different audience.
|
||||
def canonical_resource(server_url:, prm_resource:)
|
||||
server_canonical = safe_canonicalize_url(server_url, label: "MCP server URL")
|
||||
return server_canonical unless prm_resource
|
||||
|
||||
prm_canonical = safe_canonicalize_url(prm_resource, label: "PRM `resource`")
|
||||
unless Discovery.resource_covers?(prm: prm_canonical, server: server_canonical)
|
||||
raise AuthorizationError,
|
||||
"Protected resource metadata `resource` does not match the MCP server URL " \
|
||||
"(server=#{server_canonical}, prm=#{prm_canonical})."
|
||||
end
|
||||
|
||||
prm_canonical
|
||||
end
|
||||
|
||||
# Wraps `Discovery.canonicalize_url` so that any URI parsing failure
|
||||
# caused by malformed input from the server (`PRM.resource`, AS metadata
|
||||
# endpoints, ...) surfaces as `AuthorizationError` instead of leaking
|
||||
# a raw `URI::InvalidURIError` / `ArgumentError`.
|
||||
def safe_canonicalize_url(url, label:)
|
||||
Discovery.canonicalize_url(url)
|
||||
rescue URI::InvalidURIError, ArgumentError => e
|
||||
raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}."
|
||||
end
|
||||
|
||||
# Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery
|
||||
# of the expected value.
|
||||
# `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7
|
||||
# (the project's minimum supported version).
|
||||
# The hand-rolled XOR-sum walks every byte of the equal-length operands, so the running time
|
||||
# does not leak the position of the first mismatching byte.
|
||||
def states_match?(returned, expected)
|
||||
returned = returned.to_s
|
||||
return false unless returned.bytesize == expected.bytesize
|
||||
|
||||
result = 0
|
||||
returned.bytes.zip(expected.bytes) { |a, b| result |= a ^ b }
|
||||
result.zero?
|
||||
end
|
||||
|
||||
# Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
|
||||
# prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
|
||||
# from the Protected Resource Metadata, and falls back to a provider-supplied
|
||||
# scope only if both are absent. The provider-supplied scope must not pre-empt
|
||||
# a server-advertised one.
|
||||
def resolve_scope(scope:, prm:)
|
||||
return scope if scope && !scope.empty?
|
||||
|
||||
supported = prm["scopes_supported"]
|
||||
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
|
||||
|
||||
return @provider.scope if @provider.scope && !@provider.scope.empty?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
||||
authorization_endpoint = as_metadata["authorization_endpoint"]
|
||||
unless authorization_endpoint
|
||||
raise AuthorizationError,
|
||||
"Authorization server metadata is missing `authorization_endpoint`."
|
||||
end
|
||||
|
||||
uri = begin
|
||||
URI.parse(authorization_endpoint)
|
||||
rescue URI::InvalidURIError => e
|
||||
raise AuthorizationError,
|
||||
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
|
||||
end
|
||||
|
||||
params = URI.decode_www_form(uri.query.to_s)
|
||||
params << ["response_type", "code"]
|
||||
params << ["client_id", client_id]
|
||||
params << ["redirect_uri", @provider.redirect_uri]
|
||||
params << ["code_challenge", code_challenge]
|
||||
params << ["code_challenge_method", "S256"]
|
||||
params << ["state", state]
|
||||
params << ["scope", scope] if scope
|
||||
params << ["resource", resource] if resource
|
||||
uri.query = URI.encode_www_form(params)
|
||||
uri
|
||||
end
|
||||
|
||||
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
|
||||
form = {
|
||||
"grant_type" => "authorization_code",
|
||||
"code" => code,
|
||||
"redirect_uri" => @provider.redirect_uri,
|
||||
"code_verifier" => code_verifier,
|
||||
}
|
||||
form["resource"] = resource if resource
|
||||
|
||||
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
||||
end
|
||||
|
||||
def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:)
|
||||
form = {
|
||||
"grant_type" => "refresh_token",
|
||||
"refresh_token" => refresh_token,
|
||||
}
|
||||
form["resource"] = resource if resource
|
||||
|
||||
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
||||
end
|
||||
|
||||
# Submits a form-encoded request to the token endpoint, applying
|
||||
# the client authentication method advertised in `client_information` and
|
||||
# adding `client_id` (and `client_secret` when not using HTTP Basic).
|
||||
def post_to_token_endpoint(as_metadata:, client_info:, form:)
|
||||
client_id = client_info_required_value(client_info, "client_id")
|
||||
unless client_id
|
||||
raise AuthorizationError,
|
||||
"Cannot post to token endpoint: client_information is missing `client_id`."
|
||||
end
|
||||
|
||||
client_secret = client_info_required_value(client_info, "client_secret")
|
||||
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
||||
|
||||
form = form.merge("client_id" => client_id)
|
||||
headers = {}
|
||||
if client_secret
|
||||
case token_endpoint_auth_method
|
||||
when "client_secret_post"
|
||||
form["client_secret"] = client_secret
|
||||
when "none"
|
||||
# Public client; no credential.
|
||||
else
|
||||
# RFC 6749 §2.3.1 recommends Basic for confidential clients and
|
||||
# both Python and TypeScript SDKs default here when
|
||||
# the authentication method is not explicitly stored.
|
||||
headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
|
||||
end
|
||||
end
|
||||
|
||||
token_endpoint = as_metadata["token_endpoint"]
|
||||
unless token_endpoint
|
||||
raise AuthorizationError,
|
||||
"Authorization server metadata is missing `token_endpoint`."
|
||||
end
|
||||
|
||||
response = begin
|
||||
http_post_form(token_endpoint, form, headers: headers)
|
||||
rescue Faraday::Error => e
|
||||
raise AuthorizationError,
|
||||
"Token request to #{token_endpoint} failed: #{e.class}: #{e.message}."
|
||||
end
|
||||
|
||||
if response.status < 200 || response.status >= 300
|
||||
if token_endpoint_error_code(response) == "invalid_grant"
|
||||
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
|
||||
end
|
||||
|
||||
raise AuthorizationError, "Token endpoint returned status #{response.status}."
|
||||
end
|
||||
|
||||
parsed = begin
|
||||
JSON.parse(response_body_string(response))
|
||||
rescue JSON::ParserError => e
|
||||
raise AuthorizationError, "Failed to parse token endpoint response: #{e.message}."
|
||||
end
|
||||
|
||||
# Token responses MUST be a JSON object per RFC 6749 §5.1. Anything
|
||||
# else (`null`, `[]`, a bare string) would otherwise be persisted
|
||||
# as `provider.tokens` and raise raw `NoMethodError` / `TypeError`
|
||||
# the next time `provider.access_token` is read.
|
||||
unless parsed.is_a?(Hash)
|
||||
raise AuthorizationError,
|
||||
"Token endpoint response is not a JSON object (got #{parsed.class})."
|
||||
end
|
||||
|
||||
parsed
|
||||
end
|
||||
|
||||
# Extracts the `error` code from an RFC 6749 §5.2 error response body
|
||||
# when one is parseable. Returns nil on any parse failure or when
|
||||
# the body is not JSON.
|
||||
def token_endpoint_error_code(response)
|
||||
body = response_body_string(response).to_s
|
||||
return if body.empty?
|
||||
|
||||
parsed = JSON.parse(body)
|
||||
parsed["error"] if parsed.is_a?(Hash)
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
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
|
||||
# what prevents credentials containing `:` or other special characters
|
||||
# from being mis-parsed by the authorization server.
|
||||
# https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
|
||||
def basic_auth_credentials(client_id, client_secret)
|
||||
encoded_id = URI.encode_www_form_component(client_id)
|
||||
encoded_secret = URI.encode_www_form_component(client_secret)
|
||||
Base64.strict_encode64("#{encoded_id}:#{encoded_secret}")
|
||||
end
|
||||
|
||||
def http_get(url)
|
||||
http_client.get(url)
|
||||
end
|
||||
|
||||
def http_post_json(url, body)
|
||||
http_client.post(url) do |req|
|
||||
req.headers["Content-Type"] = "application/json"
|
||||
req.headers["Accept"] = "application/json"
|
||||
req.body = JSON.generate(body)
|
||||
end
|
||||
end
|
||||
|
||||
def http_post_form(url, form, headers: {})
|
||||
http_client.post(url) do |req|
|
||||
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
req.headers["Accept"] = "application/json"
|
||||
headers.each { |key, value| req.headers[key] = value }
|
||||
req.body = URI.encode_www_form(form)
|
||||
end
|
||||
end
|
||||
|
||||
def http_client
|
||||
@http_client ||= @http_client_factory.call
|
||||
end
|
||||
|
||||
def default_http_client
|
||||
require "faraday"
|
||||
Faraday.new do |faraday|
|
||||
faraday.headers["Accept"] = "application/json"
|
||||
end
|
||||
end
|
||||
|
||||
def response_body_string(response)
|
||||
body = response.body
|
||||
body.is_a?(String) ? body : body.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
43
lib/mcp/client/oauth/in_memory_storage.rb
Normal file
43
lib/mcp/client/oauth/in_memory_storage.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Default reference implementation of the storage contract that
|
||||
# `Provider` uses to persist OAuth state. Holds the two pieces of data
|
||||
# the flow saves and reads on every request:
|
||||
#
|
||||
# - `tokens`: the hash returned by the token endpoint
|
||||
# (`access_token`, optional `refresh_token`, `expires_in`, `scope`, etc.).
|
||||
# - `client_information`: the hash returned by Dynamic Client Registration
|
||||
# or supplied as pre-registered credentials
|
||||
# (`client_id`, optional `client_secret`, optional
|
||||
# `token_endpoint_auth_method`).
|
||||
#
|
||||
# This class keeps everything in process memory, so the credentials live
|
||||
# only for the lifetime of the Ruby process. Applications that need
|
||||
# persistence across restarts should supply a custom object responding to
|
||||
# the same four-method contract (`tokens`, `save_tokens(t)`,
|
||||
# `client_information`, `save_client_information(info)`) and pass it via
|
||||
# `Provider.new(storage: ...)`. The shape mirrors Python SDK's
|
||||
# `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls
|
||||
# the same responsibilities into a single object.
|
||||
class InMemoryStorage
|
||||
attr_accessor :tokens, :client_information
|
||||
|
||||
def initialize
|
||||
@tokens = nil
|
||||
@client_information = nil
|
||||
end
|
||||
|
||||
def save_tokens(tokens)
|
||||
@tokens = tokens
|
||||
end
|
||||
|
||||
def save_client_information(info)
|
||||
@client_information = info
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
38
lib/mcp/client/oauth/pkce.rb
Normal file
38
lib/mcp/client/oauth/pkce.rb
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "base64"
|
||||
require "digest"
|
||||
require "securerandom"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Generates the Proof Key for Code Exchange (PKCE) pair (RFC 7636) used to
|
||||
# bind an OAuth 2.1 authorization request to the same client that later
|
||||
# redeems the resulting authorization code. Without PKCE, an attacker
|
||||
# who steals an authorization code in transit (e.g. through a logged redirect URI)
|
||||
# can exchange it for an access token; with PKCE, the attacker would also need
|
||||
# the per-request `code_verifier`, which is never sent to the browser or any intermediary.
|
||||
#
|
||||
# MCP authorization mandates the `S256` method, so this module always returns
|
||||
# that method and refuses to expose `plain` as an option.
|
||||
#
|
||||
# The module is stateless: callers ask for a fresh pair with `generate` for
|
||||
# every authorization request and discard the result once the token endpoint has accepted it.
|
||||
#
|
||||
# - https://datatracker.ietf.org/doc/html/rfc7636
|
||||
# - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection
|
||||
module PKCE
|
||||
class << self
|
||||
# Generates a PKCE pair (code_verifier and S256 code_challenge).
|
||||
def generate
|
||||
verifier = SecureRandom.urlsafe_base64(64)
|
||||
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
||||
|
||||
{ code_verifier: verifier, code_challenge: challenge, code_challenge_method: "S256" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
103
lib/mcp/client/oauth/provider.rb
Normal file
103
lib/mcp/client/oauth/provider.rb
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via
|
||||
# the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK
|
||||
# and httpx.Auth-based provider in the Python SDK.
|
||||
#
|
||||
# Required keyword arguments:
|
||||
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
||||
# Registration endpoint. Must include at minimum `redirect_uris`,
|
||||
# `grant_types`, `response_types`, and `token_endpoint_auth_method`.
|
||||
# - `redirect_uri` - String: the redirect URI used for the authorization
|
||||
# request. Must be one of `redirect_uris` in `client_metadata`.
|
||||
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
||||
# URL (a `URI`). Implementations typically open the user's browser.
|
||||
# - `callback_handler` - Callable invoked after `redirect_handler`. Returns
|
||||
# `[code, state]` where `code` is the authorization code and `state` is
|
||||
# the `state` parameter received on the redirect URI.
|
||||
#
|
||||
# Optional keyword arguments:
|
||||
# - `scope` - String of space-separated scopes to request when the server's
|
||||
# `WWW-Authenticate` does not specify one.
|
||||
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
||||
# `client_information`, and `save_client_information(info)`. Defaults to
|
||||
# an `InMemoryStorage`.
|
||||
class Provider
|
||||
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
||||
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
||||
# authorization spec's Communication Security requirement.
|
||||
class InsecureRedirectURIError < ArgumentError; end
|
||||
|
||||
# Raised when the `redirect_uri` argument is not listed in
|
||||
# `client_metadata[:redirect_uris]` / `["redirect_uris"]`. Registering
|
||||
# the URI with the authorization server but then sending a different
|
||||
# one with the authorization request would be rejected by the AS at
|
||||
# runtime; failing at construction surfaces the bug earlier.
|
||||
class UnregisteredRedirectURIError < ArgumentError; end
|
||||
|
||||
attr_reader :client_metadata,
|
||||
:redirect_uri,
|
||||
:scope,
|
||||
:storage,
|
||||
:redirect_handler,
|
||||
:callback_handler
|
||||
|
||||
def initialize(
|
||||
client_metadata:,
|
||||
redirect_uri:,
|
||||
redirect_handler:,
|
||||
callback_handler:,
|
||||
scope: nil,
|
||||
storage: nil
|
||||
)
|
||||
unless Discovery.secure_url?(redirect_uri)
|
||||
raise InsecureRedirectURIError,
|
||||
"redirect_uri #{redirect_uri.inspect} must use https or be a loopback http URL " \
|
||||
"(localhost, 127.0.0.0/8, or ::1) per the MCP authorization Communication Security requirement."
|
||||
end
|
||||
|
||||
registered = Array(client_metadata[:redirect_uris] || client_metadata["redirect_uris"])
|
||||
unless registered.include?(redirect_uri)
|
||||
raise UnregisteredRedirectURIError,
|
||||
"redirect_uri #{redirect_uri.inspect} must be listed in client_metadata[:redirect_uris] " \
|
||||
"(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
|
||||
end
|
||||
|
||||
@client_metadata = client_metadata
|
||||
@redirect_uri = redirect_uri
|
||||
@redirect_handler = redirect_handler
|
||||
@callback_handler = callback_handler
|
||||
@scope = scope
|
||||
@storage = storage || InMemoryStorage.new
|
||||
end
|
||||
|
||||
def access_token
|
||||
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
||||
end
|
||||
|
||||
def tokens
|
||||
@storage.tokens
|
||||
end
|
||||
|
||||
def save_tokens(tokens)
|
||||
@storage.save_tokens(tokens)
|
||||
end
|
||||
|
||||
def client_information
|
||||
@storage.client_information
|
||||
end
|
||||
|
||||
def save_client_information(info)
|
||||
@storage.save_client_information(info)
|
||||
end
|
||||
|
||||
def clear_tokens!
|
||||
@storage.save_tokens(nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue