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:
Koichi ITO 2026-05-19 11:45:52 +09:00 • committed by GitHub
commit 2832bc0984
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 4600 additions and 70 deletions

View file

@ -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"

View file

@ -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
View 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

View 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

View 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

View 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

View 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

View 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