Add OAuth 2.1 client support for MCP authorization flow

## Motivation and Context

The MCP Authorization spec (2025-11-25) requires clients to support
Protected Resource Metadata discovery (RFC 9728), Authorization
Server Metadata discovery (RFC 8414), Dynamic Client Registration,
and OAuth 2.1 Authorization Code grant with PKCE (S256). The Ruby SDK
previously had no client-side OAuth support: the HTTP transport raised
`RequestHandlerError` on a 401 with no recovery, and 21 `auth/*` scenarios
were listed as known failures in `conformance/expected_failures.yml`.

This aligns the Ruby SDK with the Python and TypeScript SDKs,
both of which expose a pluggable OAuth client provider that drives
the full discovery + DCR + authorization + token exchange flow.
`MCP::Client::OAuth::Provider` mirrors that surface: callers supply
`client_metadata`, a `redirect_uri`, and pluggable `redirect_handler`
/ `callback_handler` hooks plus a token/client information storage.
`MCP::Client::OAuth::InMemoryStorage` is provided as a default.

`MCP::Client::HTTP` accepts a new optional `oauth:` keyword. When present,
the transport attaches `Authorization: Bearer` to every request,
and on a 401 parses `WWW-Authenticate` for the `resource_metadata` URL
and `scope` challenge, drives the discovery + DCR + authorization code flow
through the provider, and retries the failed request once with the acquired token.

Spec compliance details:

- The `WWW-Authenticate` parser handles multiple challenges
  (e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`), extracting only
  the parameters that belong to the Bearer challenge.
- The PRM `resource` value is required to cover the MCP server URL
  (same scheme/host/port, with the PRM path as a prefix of the server path)
  before the flow continues, preventing redirection of issued tokens to a different audience.
- Both authorization and token requests always include a `resource`
  parameter (RFC 8707), defaulting to a canonicalized form of
  the MCP server URL when PRM does not advertise one.
- `client_secret_basic`, `client_secret_post`, and `none` token endpoint
  authentication methods are supported. When pre-registered client information
  specifies no `token_endpoint_auth_method`, the transport defaults to
  `client_secret_basic` per RFC 6749 §2.3.1 an dthe TS/Python SDK convention.
  Pre-registered client credentials supplied via `Storage#save_client_information`
  with either string or symbol keys are honored, skipping DCR.
- Scope resolution follows the spec order: the `WWW-Authenticate` challenge scope wins first,
  then PRM `scopes_supported`, and `Provider#scope` is the last-resort fallback only
  if neither is supplied (the provider-supplied scope must not pre-empt server-advertised scope).
- Token endpoint error responses (RFC 6749 §5.2) are parsed: a 4x with
  `error: "invalid_grant"` raises `Flow::InvalidGrantError`,
  so `MCP::Client::HTTP` can discard the stored refresh token only when
  the AS truly says it is dead. Generic `AuthorizationError`
  (5xx, network failures, other error codes) leaves the refresh token
  intact, so a transient AS outage does not force interactive reauth
  on the next attempt.
- The OAuth `state` parameter is compared via
  `OpenSSL.fixed_length_secure_compare` (with a `bytesize` guard) to
  prevent timing-based discovery of the expected value.
- The 401 retry guard is a request-scoped local variable, so an MCP server
  that keeps returning 401 is surfaced as an error after one
  retry rather than triggering an infinite re-authorization loop.

## How Has This Been Tested?

Unit tests cover PKCE generation, multi-challenge `WWW-Authenticate` parsing,
PRM and Authorization Server metadata URL builders, the full Flow orchestrator
(success path, state mismatch, missing PKCE support, skipping DCR for both string
and symbol-keyed preregistered client information, `client_secret_basic`
token endpoint authentication, PRM `resource` mismatch rejection, fallback to
the server URL when PRM omits `resource`, scope resolution preferring PRM
`scopes_supported` over `Provider#scope`, default `client_secret_basic` for
confidential clients without an explicit `token_endpoint_auth_method`,
and the new `InvalidGrantError` vs generic `AuthorizationError` split for
token endpoint rejections), and the HTTP transport behavior
(401 recovery + retry, no-provider fallthrough, no infinite loop on repeated 401,
no infinite loop on flow failure, refresh-token retention on transient 5xx from
the token endpoint).

Conformance: `bundle exec rake conformance` newly passes 16 `auth/*`
scenarios beyond the existing baseline, including
`auth/metadata-default`, `metadata-var{1,2,3}`,
`scope-from-www-authenticate`, `scope-from-scopes-supported`,
`scope-omitted-when-undefined`, `scope-retry-limit`,
`token-endpoint-auth-{basic,post,none}`, `pre-registration`,
`resource-mismatch`, and `offline-access-not-supported`. Remaining
`auth/*` scenarios stay in `expected_failures.yml`.
`bundle exec rake test rubocop` is clean.

## Breaking Changes

None. `MCP::Client::HTTP#initialize` accepts a new optional `oauth:`
keyword that defaults to `nil`, preserving existing behavior. When unset,
the transport raises `MCP::Client::RequestHandlerError` on a 401 exactly as before.
This commit is contained in:
Koichi ITO 2026-05-10 23:47:22 +09:00
commit 5766069923
16 changed files with 4600 additions and 70 deletions

107
README.md
View file

@ -1885,6 +1885,113 @@ client.tools # will make the call using Bearer auth
You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request.
#### OAuth 2.1 Authorization
When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization),
pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will:
- Send `Authorization: Bearer <access_token>` on every request when a token is available.
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
```ruby
require "mcp"
provider = MCP::Client::OAuth::Provider.new(
client_metadata: {
client_name: "My MCP App",
redirect_uris: ["http://localhost:3030/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:3030/callback",
redirect_handler: ->(authorization_url) {
# Send the user to the authorization URL - typically `Launchy.open(authorization_url)`
# or a manual `puts authorization_url` in CLI tools.
},
callback_handler: -> {
# Capture the redirect (for example, by running a small HTTP listener on
# `redirect_uri`) and return [code, state] from the query string.
},
)
transport = MCP::Client::HTTP.new(
url: "https://api.example.com/mcp",
oauth: provider,
)
client = MCP::Client.new(transport: transport)
client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token
client.tools
```
Required keyword arguments to `Provider.new`:
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
- `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`.
Optional keyword arguments:
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
which keeps credentials in process memory only.
To persist credentials across restarts, supply your own storage:
```ruby
class FileTokenStorage
def initialize(path)
@path = path
end
def tokens
read["tokens"]
end
def save_tokens(value)
write("tokens" => value)
end
def client_information
read["client"]
end
def save_client_information(value)
write("client" => value)
end
private
def read
File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
end
def write(updates)
File.write(@path, JSON.dump(read.merge(updates)))
end
end
provider = MCP::Client::OAuth::Provider.new(
# ... required keywords ...
storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")),
)
```
##### Communication Security
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host.
The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through
a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter,
whether the swap was triggered by
`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time,
and whether the new URL is `http://` *or* `https://` to a different host or tenant.
#### Customizing the Faraday Connection
You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection.

View file

@ -8,6 +8,8 @@
# The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable,
# which is set automatically by the conformance test runner.
require "faraday"
require "json"
require_relative "../lib/mcp"
scenario = ENV["MCP_CONFORMANCE_SCENARIO"]
@ -17,7 +19,82 @@ unless scenario && server_url
abort("Usage: MCP_CONFORMANCE_SCENARIO=<scenario> ruby conformance/client.rb <server-url>")
end
transport = MCP::Client::HTTP.new(url: server_url)
# The conformance harness optionally injects scenario-specific data via
# the `MCP_CONFORMANCE_CONTEXT` environment variable as a JSON document. The shape is
# defined by the harness, not the MCP spec, and has varied between versions:
#
# - Newer (`@modelcontextprotocol/conformance` >= 0.x): scenario fields are
# spread at the top level alongside `name`, e.g.
# `{"name":"auth/pre-registration","client_id":"...","client_secret":"..."}`.
# - Older: a nested `context` object: `{"name":"...","context":{...}}`.
#
# Both shapes are accepted so the client conforms to whichever harness version
# the developer has on hand.
def conformance_context
raw = ENV["MCP_CONFORMANCE_CONTEXT"]
return {} if raw.nil? || raw.empty?
parsed = JSON.parse(raw)
return {} unless parsed.is_a?(Hash)
if parsed["context"].is_a?(Hash)
parsed["context"]
else
parsed.reject { |key, _| key == "name" }
end
rescue JSON::ParserError
{}
end
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
# non-interactively against the conformance test's auth server. The conformance
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
# `code=test-auth-code`, so we follow it manually instead of opening a browser.
def build_oauth_provider(context)
callback_holder = {}
redirect_uri = "http://localhost:0/callback"
redirect_handler = ->(authorization_url) do
response = Faraday.new.get(authorization_url) do |req|
req.options.params_encoder = nil
end
location = response.headers["location"] || response.headers["Location"]
abort("Authorization request did not redirect: #{response.status}.") unless location
callback_holder[:url] = URI.parse(location)
end
callback_handler = -> do
query = URI.decode_www_form(callback_holder.fetch(:url).query).to_h
[query["code"], query["state"]]
end
storage = MCP::Client::OAuth::InMemoryStorage.new
if context["client_id"]
storage.save_client_information(
"client_id" => context["client_id"],
"client_secret" => context["client_secret"],
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
)
end
MCP::Client::OAuth::Provider.new(
client_metadata: {
client_name: "ruby-sdk-conformance-client",
redirect_uris: [redirect_uri],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: redirect_uri,
redirect_handler: redirect_handler,
callback_handler: callback_handler,
storage: storage,
)
end
oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context) : nil
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
client = MCP::Client.new(transport: transport)
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })
@ -29,6 +106,11 @@ when "tools_call"
add_numbers = tools.find { |t| t.name == "add_numbers" }
abort("Tool add_numbers not found") unless add_numbers
client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 })
when %r|\Aauth/|
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
# are observed by the conformance server during `connect` and the subsequent request below.
# Listing tools forces a second authenticated MCP request so the bearer token usage check fires.
client.tools
else
abort("Unknown or unsupported scenario: #{scenario}")
end

View file

@ -4,25 +4,12 @@ client:
- sse-retry
# TODO: Elicitation not implemented in Ruby client.
- elicitation-sep1034-client-defaults
# TODO: OAuth/auth not implemented in Ruby client.
- auth/metadata-default
- auth/metadata-var1
- auth/metadata-var2
- auth/metadata-var3
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
- auth/basic-cimd
- auth/scope-from-www-authenticate
- auth/scope-from-scopes-supported
- auth/scope-omitted-when-undefined
- auth/scope-step-up
- auth/scope-retry-limit
- auth/token-endpoint-auth-basic
- auth/token-endpoint-auth-post
- auth/token-endpoint-auth-none
- auth/pre-registration
- auth/2025-03-26-oauth-metadata-backcompat
- auth/2025-03-26-oauth-endpoint-fallback
- auth/client-credentials-jwt
- auth/client-credentials-basic
- auth/cross-app-access-complete-flow
- auth/offline-access-scope
- auth/offline-access-not-supported

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

View file

@ -0,0 +1,467 @@
# frozen_string_literal: true
require "test_helper"
require "mcp/client/oauth/discovery"
module MCP
class Client
module OAuth
class DiscoveryTest < Minitest::Test
def test_parse_www_authenticate_with_quoted_resource_metadata
header = 'Bearer error="invalid_token", error_description="Missing", ' \
'resource_metadata="https://srv.example.com/.well-known/oauth-protected-resource/mcp"'
params = Discovery.parse_www_authenticate(header)
assert_equal("invalid_token", params["error"])
assert_equal("Missing", params["error_description"])
assert_equal("https://srv.example.com/.well-known/oauth-protected-resource/mcp", params["resource_metadata"])
end
def test_parse_www_authenticate_returns_empty_for_other_schemes
assert_empty(Discovery.parse_www_authenticate('Basic realm="x"'))
end
def test_parse_www_authenticate_returns_empty_for_nil
assert_empty(Discovery.parse_www_authenticate(nil))
end
def test_parse_www_authenticate_finds_bearer_after_other_scheme
header = 'Basic realm="x", Bearer error="invalid_token", resource_metadata="https://srv/x"'
params = Discovery.parse_www_authenticate(header)
assert_equal("invalid_token", params["error"])
assert_equal("https://srv/x", params["resource_metadata"])
end
def test_parse_www_authenticate_stops_at_following_scheme
header = 'Bearer error="invalid_token", resource_metadata="https://srv/x", DPoP algs="ES256"'
params = Discovery.parse_www_authenticate(header)
assert_equal("invalid_token", params["error"])
assert_equal("https://srv/x", params["resource_metadata"])
refute(params.key?("algs"))
end
def test_parse_www_authenticate_returns_empty_when_bearer_absent
assert_empty(Discovery.parse_www_authenticate('DPoP algs="ES256"'))
end
def test_parse_www_authenticate_handles_bare_bearer
assert_empty(Discovery.parse_www_authenticate("Bearer"))
end
def test_parse_www_authenticate_handles_tab_separator
params = Discovery.parse_www_authenticate(%(Bearer\terror="invalid_token",\tresource_metadata="https://srv/x"))
assert_equal("invalid_token", params["error"])
assert_equal("https://srv/x", params["resource_metadata"])
end
def test_parse_www_authenticate_tolerates_trailing_comma
params = Discovery.parse_www_authenticate('Bearer error="invalid_token", resource_metadata="https://srv/x",')
assert_equal("invalid_token", params["error"])
assert_equal("https://srv/x", params["resource_metadata"])
end
def test_parse_www_authenticate_handles_quoted_value_with_comma_and_spaces
params = Discovery.parse_www_authenticate('Bearer error_description="missing, expired, or revoked token"')
assert_equal("missing, expired, or revoked token", params["error_description"])
end
def test_parse_www_authenticate_unescapes_quoted_pair
# Per RFC 7230 Section 3.2.6, a backslash inside a quoted-string escapes
# the next character. The parser must surface the unescaped form.
header = 'Bearer error_description="value with \"quoted\" word and a back\\\\slash"'
params = Discovery.parse_www_authenticate(header)
assert_equal('value with "quoted" word and a back\\slash', params["error_description"])
end
def test_protected_resource_metadata_urls_uses_explicit_url_first
urls = Discovery.protected_resource_metadata_urls(
server_url: "https://api.example.com/mcp",
resource_metadata_url: "https://api.example.com/.well-known/oauth-protected-resource/mcp",
)
assert_equal("https://api.example.com/.well-known/oauth-protected-resource/mcp", urls.first)
assert_includes(urls, "https://api.example.com/.well-known/oauth-protected-resource")
end
def test_protected_resource_metadata_urls_falls_back_to_path_then_root
urls = Discovery.protected_resource_metadata_urls(server_url: "https://api.example.com/mcp")
assert_equal(
[
"https://api.example.com/.well-known/oauth-protected-resource/mcp",
"https://api.example.com/.well-known/oauth-protected-resource",
],
urls,
)
end
def test_authorization_server_metadata_urls_for_root_issuer
urls = Discovery.authorization_server_metadata_urls("https://auth.example.com")
assert_equal(
[
"https://auth.example.com/.well-known/oauth-authorization-server",
"https://auth.example.com/.well-known/openid-configuration",
],
urls,
)
end
def test_authorization_server_metadata_urls_for_path_issuer
urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1")
assert_includes(urls, "https://auth.example.com/.well-known/oauth-authorization-server/tenant1")
assert_includes(urls, "https://auth.example.com/.well-known/openid-configuration/tenant1")
assert_includes(urls, "https://auth.example.com/tenant1/.well-known/openid-configuration")
end
def test_canonicalize_url_normalizes_scheme_host_port_and_path
assert_equal(
"https://srv.example.com/mcp",
Discovery.canonicalize_url("HTTPS://Srv.Example.COM:443/mcp#frag"),
)
assert_equal(
"http://srv.example.com",
Discovery.canonicalize_url("http://Srv.Example.COM:80/"),
)
assert_equal(
"https://srv.example.com:8443/mcp",
Discovery.canonicalize_url("https://srv.example.com:8443/mcp"),
)
end
def test_canonicalize_url_resolves_dot_segments
# Critical for audience binding: an attacker-supplied URL like
# `/api/../mcp` must collapse to `/mcp` so it cannot pass a PRM check
# whose `resource` is the more privileged `/api` path.
assert_equal(
"https://srv.example.com/mcp",
Discovery.canonicalize_url("https://srv.example.com/api/../mcp"),
)
assert_equal(
"https://srv.example.com/api/mcp",
Discovery.canonicalize_url("https://srv.example.com/api/./mcp"),
)
assert_equal(
"https://srv.example.com/bar",
Discovery.canonicalize_url("https://srv.example.com/foo/../../bar"),
)
assert_equal(
"https://srv.example.com",
Discovery.canonicalize_url("https://srv.example.com/api/.."),
)
end
def test_canonicalize_url_resolves_percent_encoded_dot_segments
# `%2e` and `%2E` are percent-encoded forms of `.` (RFC 3986 Section 6.2.2.2)
# and must be decoded *before* dot-segment resolution. Otherwise,
# an attacker-supplied URL like `/api/%2e%2e/mcp` would slip past
# `remove_dot_segments` and escape the PRM `resource=/api` audience
# binding.
assert_equal(
"https://srv.example.com/mcp",
Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp"),
)
assert_equal(
"https://srv.example.com/mcp",
Discovery.canonicalize_url("https://srv.example.com/api/%2E%2E/mcp"),
)
assert_equal(
"https://srv.example.com/api/mcp",
Discovery.canonicalize_url("https://srv.example.com/api/%2e/mcp"),
)
end
def test_canonicalize_url_drops_userinfo
# The canonicalized URL is sent on the wire as the RFC 8707 `resource`
# claim and is surfaced in error messages, so credentials in
# the authority must never round-trip: dropping `user:pass@` keeps them
# from leaking to the authorization server or to log destinations.
assert_equal(
"https://srv.example.com/mcp",
Discovery.canonicalize_url("https://user:pass@Srv.Example.COM:443/api/../mcp"),
)
end
def test_canonicalize_url_normalizes_query_to_match_faraday
# Faraday rewrites `env.url` before sending a request: it sorts
# parameters by name, uppercases percent-encoded hex, and drops
# a bare `?` with no parameters. The canonical form must apply
# the same transformation so the URL guard does not raise a false
# positive on every valid request with a query string.
assert_equal(
"https://srv.example.com/mcp?a=1&b=2",
Discovery.canonicalize_url("https://srv.example.com/mcp?b=2&a=1"),
)
assert_equal(
"https://srv.example.com/mcp?x=%2F",
Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f"),
)
assert_equal(
"https://srv.example.com/mcp",
Discovery.canonicalize_url("https://srv.example.com/mcp?"),
)
end
def test_canonicalize_url_collapses_same_name_query_parameters
# Faraday's internal query encoder uses a Hash, so repeated keys
# collapse to the last value. The canonical form must apply
# the same collapse, otherwise the URL guard false-positives on URLs
# like `?a=1&a=2` (Faraday rewrites to `?a=2`).
assert_equal(
"https://srv.example.com/mcp?a=2",
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&a=2"),
)
assert_equal(
"https://srv.example.com/mcp?a=3&b=2",
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b=2&a=3"),
)
assert_equal(
"https://srv.example.com/mcp?x=%2F",
Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f&x=%2F"),
)
end
def test_canonicalize_url_preserves_valueless_vs_empty_value_distinction
# Faraday preserves the difference between `?key` (no `=`) and
# `?key=` (empty value) exactly as the caller passed it. A naive
# `URI.decode_www_form` round-trip would collapse both to `?key=`
# and false-positive the URL guard / RFC 8707 `resource` check for
# any URL that uses the value-less form.
assert_equal(
"https://srv.example.com/mcp?tenant",
Discovery.canonicalize_url("https://srv.example.com/mcp?tenant"),
)
assert_equal(
"https://srv.example.com/mcp?tenant=",
Discovery.canonicalize_url("https://srv.example.com/mcp?tenant="),
)
assert_equal(
"https://srv.example.com/mcp?a=1&b",
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b"),
)
# Collapse with last-write-wins also keeps the `=` form of
# the surviving segment: `flag=1&flag` -> last is `flag` (no `=`), so
# the canonical form is `?flag`, matching Faraday.
assert_equal(
"https://srv.example.com/mcp?flag",
Discovery.canonicalize_url("https://srv.example.com/mcp?flag=1&flag"),
)
end
def test_canonicalize_url_preserves_array_form_repeated_keys
# Keys ending in `[]` are Faraday's array notation: the encoder
# preserves every occurrence in input order instead of collapsing
# them. The canonical form must do the same; otherwise a hijacked
# middleware that drops one entry from `?tenant[]=victim&tenant[]=...`
# would slip past the URL guard, and a legitimate
# `?roles[]=a&roles[]=b` URL would false-positive on the first
# request.
assert_equal(
"https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2",
Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2"),
)
assert_equal(
"https://srv.example.com/mcp?tenant%5B%5D=evil&tenant%5B%5D=victim",
Discovery.canonicalize_url("https://srv.example.com/mcp?tenant[]=evil&tenant[]=victim"),
)
assert_equal(
"https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2&b=3",
Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2&b=3"),
)
# Nested keys like `a[b]` are not array notation. They collapse with
# last-write-wins, matching Faraday's `NestedParamsEncoder`.
assert_equal(
"https://srv.example.com/mcp?a%5Bb%5D=2",
Discovery.canonicalize_url("https://srv.example.com/mcp?a[b]=1&a[b]=2"),
)
end
def test_canonicalize_url_drops_empty_name_and_blank_query_segments
# Faraday's encoder treats pairs with an empty name and blank `&&`
# separators as no parameter at all. The canonical form must do
# the same; otherwise URLs like `?=v` or `?&&a=1&&` produce a snapshot
# that disagrees with the Faraday-rewritten effective URL and
# the request-time guard false-positives.
assert_equal(
"https://srv.example.com/mcp",
Discovery.canonicalize_url("https://srv.example.com/mcp?=v"),
)
assert_equal(
"https://srv.example.com/mcp?a=1",
Discovery.canonicalize_url("https://srv.example.com/mcp?&&a=1&&"),
)
assert_equal(
"https://srv.example.com/mcp?a=2",
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&&a=2"),
)
assert_equal(
"https://srv.example.com/mcp?a=1",
Discovery.canonicalize_url("https://srv.example.com/mcp?=&=&a=1"),
)
assert_equal(
"https://srv.example.com/mcp",
Discovery.canonicalize_url("https://srv.example.com/mcp?&"),
)
end
def test_resource_covers_blocks_dot_segment_bypass_after_canonicalization
server = Discovery.canonicalize_url("https://srv.example.com/api/../mcp")
prm = Discovery.canonicalize_url("https://srv.example.com/api")
refute(
Discovery.resource_covers?(prm: prm, server: server),
"PRM `resource=/api` must not cover `/api/../mcp` after canonicalization",
)
end
def test_secure_url_accepts_https
assert(Discovery.secure_url?("https://srv.example.com/mcp"))
assert(Discovery.secure_url?("HTTPS://Srv.Example.COM/mcp"))
end
def test_secure_url_accepts_loopback_http
assert(Discovery.secure_url?("http://localhost/x"))
assert(Discovery.secure_url?("http://localhost:8080/x"))
assert(Discovery.secure_url?("http://127.0.0.1/x"))
assert(Discovery.secure_url?("http://127.42.0.5/x"))
assert(Discovery.secure_url?("http://127.255.255.255/x"))
assert(Discovery.secure_url?("http://[::1]/x"))
end
def test_secure_url_rejects_non_loopback_http
refute(Discovery.secure_url?("http://srv.example.com/mcp"))
refute(Discovery.secure_url?("http://192.168.1.1/x"))
refute(Discovery.secure_url?("http://10.0.0.1/x"))
end
def test_secure_url_rejects_hostname_tricks_that_resemble_loopback
# Naive `start_with?("127.")` would let these slip through; IPAddr-based
# checks correctly classify them as non-loopback hostnames.
refute(Discovery.secure_url?("http://127.attacker.com/x"))
refute(Discovery.secure_url?("http://127.0.0.1.evil.com/x"))
refute(Discovery.secure_url?("http://127./x"))
# `localhost` MUST be matched exactly - `foo.localhost` is just a regular
# hostname that happens to share the suffix.
refute(Discovery.secure_url?("http://foo.localhost/x"))
end
def test_secure_url_rejects_non_http_schemes
refute(Discovery.secure_url?("ftp://srv.example.com/mcp"))
refute(Discovery.secure_url?("file:///etc/passwd"))
refute(Discovery.secure_url?("javascript:alert(1)"))
refute(Discovery.secure_url?(""))
refute(Discovery.secure_url?(nil))
end
def test_secure_url_rejects_malformed_or_hostless_urls
refute(Discovery.secure_url?("https:/missing-host"))
refute(Discovery.secure_url?("https:///missing-host"))
refute(Discovery.secure_url?("https://"))
refute(Discovery.secure_url?("not a url"))
end
def test_resource_covers_blocks_percent_encoded_dot_segment_bypass
server = Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp")
prm = Discovery.canonicalize_url("https://srv.example.com/api")
refute(
Discovery.resource_covers?(prm: prm, server: server),
"PRM `resource=/api` must not cover `/api/%2e%2e/mcp` after canonicalization",
)
end
def test_resource_covers_blocks_cross_tenant_query_bypass
# An attacker-controlled PRM that advertises a different tenant's
# resource URI MUST NOT be treated as covering the server URL. If it
# were, the client would mint a token for `tenant=evil` while
# actually talking to `tenant=victim`, defeating audience binding.
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
prm_evil = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil")
refute(
Discovery.resource_covers?(prm: prm_evil, server: server),
"PRM `resource=?tenant=evil` must not cover server `?tenant=victim`",
)
end
def test_resource_covers_allows_matching_query
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
assert(Discovery.resource_covers?(prm: prm, server: server))
end
def test_resource_covers_allows_prm_without_query_to_cover_server_with_query
# A query-less PRM acts as a generic identifier over the origin +
# path prefix and is allowed to cover server URLs that scope by query.
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=1")
prm = Discovery.canonicalize_url("https://srv.example.com/mcp")
assert(Discovery.resource_covers?(prm: prm, server: server))
end
def test_resource_covers_blocks_prm_with_query_when_server_has_none
# PRM that names a specific tenant cannot cover an unscoped server
# URL; otherwise a hijacked PRM could re-bind the token to a tenant
# the user did not select.
server = Discovery.canonicalize_url("https://srv.example.com/mcp")
prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil")
refute(Discovery.resource_covers?(prm: prm, server: server))
end
def test_resource_covers_does_not_treat_empty_query_as_wildcard
# `https://srv.example.com/mcp?` parses with `URI#query == ""`, which
# is distinct from "no query at all" (`URI#query == nil`). A naive
# `prm_query.nil? || prm_query.empty?` would let an attacker-supplied
# PRM with a trailing `?` slip past the cross-tenant audience check.
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
prm_empty_query = "https://srv.example.com/mcp?"
refute(
Discovery.resource_covers?(prm: prm_empty_query, server: server),
"PRM with a literal empty query (`?`) must not act as a wildcard cover",
)
end
def test_resource_covers_returns_true_for_same_origin_prefix
assert(Discovery.resource_covers?(
prm: "https://srv.example.com",
server: "https://srv.example.com/mcp",
))
assert(Discovery.resource_covers?(
prm: "https://srv.example.com/mcp",
server: "https://srv.example.com/mcp",
))
assert(Discovery.resource_covers?(
prm: "https://srv.example.com/api",
server: "https://srv.example.com/api/v2/mcp",
))
end
def test_resource_covers_returns_false_for_different_origin_or_path_mismatch
refute(Discovery.resource_covers?(
prm: "https://evil.example.com/mcp",
server: "https://srv.example.com/mcp",
))
refute(Discovery.resource_covers?(
prm: "https://srv.example.com:8443/mcp",
server: "https://srv.example.com/mcp",
))
# Path "api" must not be considered a prefix of "/apiv2".
refute(Discovery.resource_covers?(
prm: "https://srv.example.com/api",
server: "https://srv.example.com/apiv2",
))
end
end
end
end
end

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
require "test_helper"
require "base64"
require "digest"
require "mcp/client/oauth/pkce"
module MCP
class Client
module OAuth
class PKCETest < Minitest::Test
def test_generate_returns_verifier_and_s256_challenge
result = PKCE.generate
assert_kind_of(String, result[:code_verifier])
assert_operator(result[:code_verifier].length, :>=, 43)
assert_operator(result[:code_verifier].length, :<=, 128)
assert_equal("S256", result[:code_challenge_method])
expected = Base64.urlsafe_encode64(Digest::SHA256.digest(result[:code_verifier]), padding: false)
assert_equal(expected, result[:code_challenge])
end
def test_generate_produces_distinct_values
a = PKCE.generate
b = PKCE.generate
refute_equal(a[:code_verifier], b[:code_verifier])
refute_equal(a[:code_challenge], b[:code_challenge])
end
end
end
end
end

View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
require "test_helper"
require "mcp/client/oauth"
module MCP
class Client
module OAuth
class ProviderTest < Minitest::Test
# Builds a complete Provider argument hash whose `client_metadata`
# registers the same `redirect_uri` that is being asserted on.
# The registered-URI check in `Provider#initialize` requires the two to
# match, so accept-path tests need them paired.
def args_for(redirect_uri)
{
client_metadata: {
redirect_uris: [redirect_uri],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: redirect_uri,
redirect_handler: ->(_url) {},
callback_handler: -> { ["code", "state"] },
}
end
def test_initialize_accepts_https_redirect_uri
provider = Provider.new(**args_for("https://app.example.com/callback"))
assert_equal("https://app.example.com/callback", provider.redirect_uri)
end
def test_initialize_accepts_loopback_http_redirect_uri
["http://localhost/callback", "http://127.0.0.1:3000/cb", "http://[::1]/cb"].each do |uri|
provider = Provider.new(**args_for(uri))
assert_equal(uri, provider.redirect_uri)
end
end
def test_initialize_rejects_non_loopback_http_redirect_uri
# Communication Security: a non-loopback `http://` redirect URI would
# let an attacker steal the authorization code from a network sniffer,
# so the provider must refuse to construct.
assert_raises(Provider::InsecureRedirectURIError) do
Provider.new(**args_for("http://app.example.com/callback"))
end
end
def test_initialize_rejects_non_http_scheme_redirect_uri
assert_raises(Provider::InsecureRedirectURIError) do
Provider.new(**args_for("ftp://app.example.com/callback"))
end
end
def test_initialize_rejects_hostname_tricks_that_resemble_loopback
["http://127.attacker.com/cb", "http://127.0.0.1.evil.com/cb", "http://foo.localhost/cb"].each do |uri|
assert_raises(Provider::InsecureRedirectURIError, "should reject #{uri}") do
Provider.new(**args_for(uri))
end
end
end
def test_initialize_rejects_redirect_uri_not_listed_in_client_metadata
# Per the OAuth Dynamic Client Registration response, the AS will
# bind the client_id to a specific set of redirect_uris and reject any
# authorization request that uses something else. Detect that mismatch
# at Provider construction so the failure surfaces immediately.
args = args_for("http://localhost:0/callback")
args[:redirect_uri] = "http://localhost:0/different"
assert_raises(Provider::UnregisteredRedirectURIError) do
Provider.new(**args)
end
end
def test_initialize_accepts_redirect_uri_with_string_keys_in_client_metadata
# `client_metadata` is hand-built by callers, so accept either symbol
# or string keys for `redirect_uris`.
args = args_for("http://localhost:0/callback")
args[:client_metadata] = { "redirect_uris" => ["http://localhost:0/callback"] }
provider = Provider.new(**args)
assert_equal("http://localhost:0/callback", provider.redirect_uri)
end
end
end
end
end