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:
parent
d6b5392831
commit
5766069923
16 changed files with 4600 additions and 70 deletions
107
README.md
107
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "client/oauth"
|
||||
require_relative "client/stdio"
|
||||
require_relative "client/http"
|
||||
require_relative "client/paginated_result"
|
||||
|
|
|
|||
|
|
@ -17,12 +17,74 @@ module MCP
|
|||
SESSION_ID_HEADER = "Mcp-Session-Id"
|
||||
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
|
||||
|
||||
attr_reader :url, :session_id, :protocol_version, :server_info
|
||||
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
||||
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
||||
# is trivially observed and stolen.
|
||||
class InsecureURLError < ArgumentError; end
|
||||
|
||||
# Faraday request middleware that compares the outgoing request URL
|
||||
# against the URL snapshotted at `MCP::Client::HTTP#initialize` time.
|
||||
# Registered after the user's customizer so it sees `env.url` *after*
|
||||
# any custom middleware has had a chance to rewrite it - closing
|
||||
# the `Faraday env.url = URI("https://attacker...")` bypass that a plain
|
||||
# `client.url_prefix` check would miss. The comparison includes
|
||||
# the query string, so a middleware that rewrites `env.url.query` to
|
||||
# a different tenant (e.g. `?tenant=evil`) is rejected as well; otherwise
|
||||
# the audience-binding check on the OAuth side could be bypassed at
|
||||
# the send step.
|
||||
class OAuthURLGuard
|
||||
def initialize(app, expected_url:)
|
||||
@app = app
|
||||
@expected_url = expected_url
|
||||
end
|
||||
|
||||
def call(env)
|
||||
effective = MCP::Client::OAuth::Discovery.canonicalize_url(env.url.to_s)
|
||||
unless effective == @expected_url
|
||||
# Surface the *canonicalized* form (no userinfo, no fragment) so
|
||||
# credentials like `user:pass@` cannot leak into logs, stack
|
||||
# traces, or exception reporters.
|
||||
raise InsecureURLError,
|
||||
"Effective request URL #{effective.inspect} does not match the URL " \
|
||||
"validated at initialize time (#{@expected_url.inspect}); refusing to send a bearer token."
|
||||
end
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
|
||||
|
||||
def initialize(url:, headers: {}, oauth: nil, &block)
|
||||
if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url)
|
||||
# Mask credentials (userinfo) and query parameters before quoting the URL in the error message
|
||||
# so they cannot leak into logs.
|
||||
safe_url = MCP::Client::OAuth::Discovery.canonicalize_origin_and_path(url)
|
||||
raise InsecureURLError,
|
||||
"MCP URL #{safe_url.inspect} must use https or be a loopback http URL when an oauth provider is set; " \
|
||||
"sending bearer tokens over plain http to a remote host would leak them on the wire."
|
||||
end
|
||||
|
||||
def initialize(url:, headers: {}, &block)
|
||||
@url = url
|
||||
@headers = headers
|
||||
@faraday_customizer = block
|
||||
@oauth = oauth
|
||||
# Snapshot the canonical URL at construction time. This single value
|
||||
# serves two related roles, both of which need to see the query string:
|
||||
#
|
||||
# - As the RFC 8707 `resource` claim sent on the authorization and
|
||||
# token requests (and as the base for PRM discovery URLs) -
|
||||
# matching the TS / Python SDKs' `resourceUrlFromServerUrl` /
|
||||
# `resource_url_from_server_url` so multi-tenant servers that scope
|
||||
# by `?tenant=...` round-trip correctly.
|
||||
# - As the comparison value for the URL guard middleware. Comparing
|
||||
# query strings as well as origin + path is required so a Faraday
|
||||
# middleware that rewrites `env.url.query` to a different tenant
|
||||
# cannot send the bearer token to the wrong audience while
|
||||
# the resource binding on the OAuth side stays correct.
|
||||
#
|
||||
# Saved only when `oauth:` is set so non-OAuth transports keep their
|
||||
# existing behavior.
|
||||
@oauth_server_url = oauth ? MCP::Client::OAuth::Discovery.canonicalize_url(url) : nil
|
||||
@session_id = nil
|
||||
@protocol_version = nil
|
||||
@server_info = nil
|
||||
|
|
@ -30,8 +92,8 @@ module MCP
|
|||
end
|
||||
|
||||
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
||||
# followed by the required `notifications/initialized` notification. The
|
||||
# server's `InitializeResult` (protocol version, capabilities, server
|
||||
# followed by the required `notifications/initialized` notification.
|
||||
# The server's `InitializeResult` (protocol version, capabilities, server
|
||||
# info, instructions) is cached on the transport and returned.
|
||||
#
|
||||
# Idempotent: a second call returns the cached `InitializeResult` without
|
||||
|
|
@ -122,68 +184,80 @@ module MCP
|
|||
def send_request(request:)
|
||||
method = request[:method] || request["method"]
|
||||
params = request[:params] || request["params"]
|
||||
oauth_retried = false
|
||||
|
||||
response = client.post("", request, session_headers)
|
||||
body = parse_response_body(response, method, params)
|
||||
begin
|
||||
response = client.post("", request, session_headers)
|
||||
body = parse_response_body(response, method, params)
|
||||
|
||||
capture_session_info(method, response, body)
|
||||
capture_session_info(method, response, body)
|
||||
|
||||
body
|
||||
rescue Faraday::BadRequestError => e
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is invalid",
|
||||
{ method: method, params: params },
|
||||
error_type: :bad_request,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::UnauthorizedError => e
|
||||
raise RequestHandlerError.new(
|
||||
"You are unauthorized to make #{method} requests",
|
||||
{ method: method, params: params },
|
||||
error_type: :unauthorized,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::ForbiddenError => e
|
||||
raise RequestHandlerError.new(
|
||||
"You are forbidden to make #{method} requests",
|
||||
{ method: method, params: params },
|
||||
error_type: :forbidden,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::ResourceNotFound => e
|
||||
# Per spec, 404 is the session-expired signal only when the request
|
||||
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
|
||||
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
||||
if @session_id
|
||||
clear_session
|
||||
raise SessionExpiredError.new(
|
||||
"The #{method} request is not found",
|
||||
body
|
||||
rescue Faraday::BadRequestError => e
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is invalid",
|
||||
{ method: method, params: params },
|
||||
error_type: :bad_request,
|
||||
original_error: e,
|
||||
)
|
||||
else
|
||||
rescue Faraday::UnauthorizedError => e
|
||||
# Run the OAuth flow at most once per `send_request` invocation.
|
||||
# The `oauth_retried` flag lives outside the `begin` so it survives `retry`,
|
||||
# ensuring a server returning 401 indefinitely raises rather than loops.
|
||||
if @oauth && !oauth_retried
|
||||
oauth_retried = true
|
||||
run_oauth_flow!(unauthorized_error: e)
|
||||
retry
|
||||
end
|
||||
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is not found",
|
||||
"You are unauthorized to make #{method} requests",
|
||||
{ method: method, params: params },
|
||||
error_type: :not_found,
|
||||
error_type: :unauthorized,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::ForbiddenError => e
|
||||
raise RequestHandlerError.new(
|
||||
"You are forbidden to make #{method} requests",
|
||||
{ method: method, params: params },
|
||||
error_type: :forbidden,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::ResourceNotFound => e
|
||||
# Per spec, 404 is the session-expired signal only when the request
|
||||
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
|
||||
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
||||
if @session_id
|
||||
clear_session
|
||||
raise SessionExpiredError.new(
|
||||
"The #{method} request is not found",
|
||||
{ method: method, params: params },
|
||||
original_error: e,
|
||||
)
|
||||
else
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is not found",
|
||||
{ method: method, params: params },
|
||||
error_type: :not_found,
|
||||
original_error: e,
|
||||
)
|
||||
end
|
||||
rescue Faraday::UnprocessableEntityError => e
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is unprocessable",
|
||||
{ method: method, params: params },
|
||||
error_type: :unprocessable_entity,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::Error => e
|
||||
raise RequestHandlerError.new(
|
||||
"Internal error handling #{method} request",
|
||||
{ method: method, params: params },
|
||||
error_type: :internal_error,
|
||||
original_error: e,
|
||||
)
|
||||
end
|
||||
rescue Faraday::UnprocessableEntityError => e
|
||||
raise RequestHandlerError.new(
|
||||
"The #{method} request is unprocessable",
|
||||
{ method: method, params: params },
|
||||
error_type: :unprocessable_entity,
|
||||
original_error: e,
|
||||
)
|
||||
rescue Faraday::Error => e # Catch-all
|
||||
raise RequestHandlerError.new(
|
||||
"Internal error handling #{method} request",
|
||||
{ method: method, params: params },
|
||||
error_type: :internal_error,
|
||||
original_error: e,
|
||||
)
|
||||
end
|
||||
|
||||
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
|
||||
|
|
@ -228,20 +302,93 @@ module MCP
|
|||
end
|
||||
|
||||
@faraday_customizer&.call(faraday)
|
||||
|
||||
# Register the URL identity guard *after* the user's customizer
|
||||
# so it sits closest to the adapter in the request stack. That way
|
||||
# the guard sees `env.url` after any customizer-added middleware has had
|
||||
# a chance to rewrite it, closing the `env.url = URI("https://...")`
|
||||
# bypass that a `Faraday::Connection#url_prefix` check cannot detect.
|
||||
faraday.use(OAuthURLGuard, expected_url: @oauth_server_url) if @oauth_server_url
|
||||
end
|
||||
end
|
||||
|
||||
# Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one)
|
||||
# and `MCP-Protocol-Version` on all requests after `initialize`.
|
||||
#
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
|
||||
def session_headers
|
||||
request_headers = {}
|
||||
request_headers[SESSION_ID_HEADER] = @session_id if @session_id
|
||||
request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version
|
||||
if @oauth && (token = @oauth.access_token)
|
||||
request_headers["Authorization"] = "Bearer #{token}"
|
||||
end
|
||||
request_headers
|
||||
end
|
||||
|
||||
# Drives the OAuth orchestrator on a 401 from the MCP endpoint.
|
||||
# The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
|
||||
# URL and an optional `scope` challenge per RFC 9728 Section 5.1.
|
||||
#
|
||||
# If the provider already holds a refresh token, we try to exchange it
|
||||
# for a fresh access token first; only when that fails (e.g., the refresh token was revoked)
|
||||
# do we fall back to the full Authorization Code + PKCE + DCR flow.
|
||||
#
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling
|
||||
def run_oauth_flow!(unauthorized_error:)
|
||||
response = unauthorized_error.response || {}
|
||||
response_headers = response[:headers] || {}
|
||||
www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
|
||||
params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate)
|
||||
|
||||
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
|
||||
if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
|
||||
return
|
||||
end
|
||||
|
||||
# Use the URL snapshotted at `initialize` time so a post-construction
|
||||
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
|
||||
# URL to an attacker-controlled host.
|
||||
flow.run!(
|
||||
server_url: @oauth_server_url,
|
||||
resource_metadata_url: params["resource_metadata"],
|
||||
scope: params["scope"],
|
||||
)
|
||||
end
|
||||
|
||||
# Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy
|
||||
# on success and falsy on either "no refresh token available" or "refresh attempt failed"
|
||||
# (in which case the caller should run the full interactive flow).
|
||||
def attempt_refresh(flow:, resource_metadata_url:)
|
||||
return false unless refresh_token_available?
|
||||
|
||||
# Use the snapshotted URL for the same reason as `run_oauth_flow!` above:
|
||||
# post-construction `@url` mutation must not redirect token-refresh
|
||||
# discovery to an attacker-controlled host. Use the query-bearing form
|
||||
# so the refresh request's RFC 8707 `resource` claim matches
|
||||
# the original authorization request.
|
||||
flow.refresh!(server_url: @oauth_server_url, resource_metadata_url: resource_metadata_url)
|
||||
true
|
||||
rescue MCP::Client::OAuth::Flow::InvalidGrantError
|
||||
# The refresh token has been revoked or expired by the AS. Wipe it so
|
||||
# the full interactive flow runs fresh on the retry.
|
||||
@oauth.clear_tokens!
|
||||
false
|
||||
rescue MCP::Client::OAuth::Flow::AuthorizationError
|
||||
# Transient failure (network, 5xx, AS metadata, etc.). Leave the refresh
|
||||
# token in place; the next attempt may succeed and we avoid forcing
|
||||
# the user through an interactive reauth for a recoverable error.
|
||||
false
|
||||
end
|
||||
|
||||
def refresh_token_available?
|
||||
tokens = @oauth.tokens
|
||||
return false unless tokens.is_a?(Hash)
|
||||
|
||||
!(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty?
|
||||
end
|
||||
|
||||
def capture_session_info(method, response, body)
|
||||
return unless method.to_s == Methods::INITIALIZE
|
||||
|
||||
|
|
|
|||
18
lib/mcp/client/oauth.rb
Normal file
18
lib/mcp/client/oauth.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "oauth/discovery"
|
||||
require_relative "oauth/flow"
|
||||
require_relative "oauth/in_memory_storage"
|
||||
require_relative "oauth/pkce"
|
||||
require_relative "oauth/provider"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
# OAuth client support for the MCP Authorization spec (PRM discovery,
|
||||
# Authorization Server metadata discovery, Dynamic Client Registration,
|
||||
# OAuth 2.1 Authorization Code + PKCE).
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
||||
module OAuth
|
||||
end
|
||||
end
|
||||
end
|
||||
423
lib/mcp/client/oauth/discovery.rb
Normal file
423
lib/mcp/client/oauth/discovery.rb
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "ipaddr"
|
||||
require "uri"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Stateless helpers that map MCP-authorization spec URLs and headers into something
|
||||
# the `Flow` orchestrator and `MCP::Client::HTTP` transport can act on.
|
||||
# The module bundles five concerns that share no state but are closely related to
|
||||
# the spec's "Discovery" and "Communication Security" sections:
|
||||
#
|
||||
# - **`WWW-Authenticate` parsing** (`parse_www_authenticate`): pulls
|
||||
# the Bearer challenge parameters (`resource_metadata`, `scope`, `error`,
|
||||
# ...) out of a header that may carry multiple challenges per RFC 7235
|
||||
# and may use `quoted-pair` escapes per RFC 7230 Section 3.2.6.
|
||||
# - **Discovery URL builders** (`protected_resource_metadata_urls`,
|
||||
# `authorization_server_metadata_urls`): list the candidate well-known
|
||||
# URLs to probe when no explicit metadata URL is supplied,
|
||||
# in the priority order required by RFC 9728 and RFC 8414.
|
||||
# - **Communication Security check** (`secure_url?`): enforces "HTTPS only"
|
||||
# for every OAuth-facing URL, with the loopback carve-out described in
|
||||
# `secure_url?`'s comment.
|
||||
# - **URL canonicalization** (`canonicalize_url`): normalizes scheme,
|
||||
# host, port, path, percent-encoded dot segments, and fragments
|
||||
# so two URLs that *refer to the same resource* compare as equal,
|
||||
# and drops userinfo so credentials never reach the RFC 8707 `resource` claim
|
||||
# or any error message.
|
||||
# - **Resource coverage** (`resource_covers?`): decides whether a PRM `resource` URI
|
||||
# is allowed to govern a given MCP server URL, i.e. whether the MCP endpoint sits
|
||||
# "under" the resource per RFC 8707 audience semantics.
|
||||
#
|
||||
# Every entry point is a class method so it can be called from initializers and
|
||||
# from any thread without synchronization.
|
||||
module Discovery
|
||||
# Matches a single `key=value` pair inside an HTTP auth-scheme challenge.
|
||||
# `value` is either a quoted string (which can contain commas and spaces)
|
||||
# or a bare token, per RFC 7235.
|
||||
WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/
|
||||
|
||||
class << self
|
||||
# Parses a `WWW-Authenticate` header and returns the parameters of
|
||||
# the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`,
|
||||
# `scope`, `error`). Returns `{}` when no Bearer challenge is present.
|
||||
# Handles multiple challenges (e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`)
|
||||
# by extracting only the Bearer parameters.
|
||||
#
|
||||
# - https://www.rfc-editor.org/rfc/rfc9728.html#section-5.1
|
||||
# - https://www.rfc-editor.org/rfc/rfc7235.html#section-4.1
|
||||
def parse_www_authenticate(header)
|
||||
return {} unless header
|
||||
|
||||
# Locate the Bearer challenge: at the start of the header or after a comma.
|
||||
bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
|
||||
return {} unless bearer
|
||||
|
||||
# Walk key=value pairs starting where Bearer's parameters begin.
|
||||
# The loop stops at the first token that is not a key=value pair,
|
||||
# which marks the next challenge (e.g. `, DPoP algs="..."`).
|
||||
cursor = bearer.end(0)
|
||||
params = {}
|
||||
while cursor < header.length
|
||||
prefix = header[cursor..]
|
||||
prefix = prefix.sub(/\A\s*,?\s*/, "")
|
||||
break if prefix.empty?
|
||||
|
||||
match = prefix.match(WWW_AUTH_PARAM_PATTERN)
|
||||
break unless match
|
||||
|
||||
params[match[1].downcase] = match[2] ? unescape_quoted_pair(match[2]) : match[3]
|
||||
cursor = header.length - prefix.length + match.end(0)
|
||||
end
|
||||
params
|
||||
end
|
||||
|
||||
# Returns the candidate Protected Resource Metadata URLs to probe, in priority order.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
|
||||
def protected_resource_metadata_urls(server_url:, resource_metadata_url: nil)
|
||||
urls = []
|
||||
urls << resource_metadata_url if resource_metadata_url
|
||||
|
||||
uri = URI.parse(server_url)
|
||||
path = uri.path == "/" ? "" : uri.path.to_s
|
||||
base = base_url(uri)
|
||||
|
||||
urls << "#{base}/.well-known/oauth-protected-resource#{path}"
|
||||
urls << "#{base}/.well-known/oauth-protected-resource"
|
||||
urls.uniq
|
||||
end
|
||||
|
||||
# Returns the candidate Authorization Server metadata URLs to probe, in priority order.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery
|
||||
def authorization_server_metadata_urls(issuer_url)
|
||||
uri = URI.parse(issuer_url)
|
||||
path = uri.path == "/" ? "" : uri.path.to_s
|
||||
base = base_url(uri)
|
||||
|
||||
if path.empty?
|
||||
["#{base}/.well-known/oauth-authorization-server", "#{base}/.well-known/openid-configuration"]
|
||||
else
|
||||
[
|
||||
"#{base}/.well-known/oauth-authorization-server#{path}",
|
||||
"#{base}/.well-known/openid-configuration#{path}",
|
||||
"#{base}#{path}/.well-known/openid-configuration",
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
# Returns a canonical form of `url` suitable for comparing two URIs
|
||||
# that are meant to identify the same protected resource: lowercased scheme/host,
|
||||
# default port stripped, fragment removed, percent-encoded dot octets normalized
|
||||
# to `.` per RFC 3986 Section 6.2.2.2, dot-segments in the path resolved per
|
||||
# RFC 3986 Section 5.2.4, and a single trailing `/` on the root path normalized away.
|
||||
#
|
||||
# Userinfo is *dropped*. The MCP authorization spec sends the canonicalized URL
|
||||
# on the wire as the RFC 8707 `resource` claim and surfaces it in error messages;
|
||||
# both paths would leak `user:pass@` credentials to the authorization server and
|
||||
# to log destinations if we preserved them. The MCP server URI does not legitimately
|
||||
# carry userinfo, so dropping it is also a no-op for normal traffic.
|
||||
#
|
||||
# Decoding `%2e`/`%2E` *before* dot-segment resolution is what prevents
|
||||
# an attacker-supplied URL like `https://srv.example.com/api/%2e%2e/mcp` from sneaking
|
||||
# past the PRM `resource` check in `resource_covers?`.
|
||||
def canonicalize_url(url)
|
||||
uri = URI.parse(url.to_s)
|
||||
|
||||
uri.fragment = nil
|
||||
# `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
|
||||
# so clear the components individually.
|
||||
if uri.respond_to?(:user) && (uri.user || uri.password)
|
||||
uri.user = nil
|
||||
uri.password = nil
|
||||
end
|
||||
uri.scheme = uri.scheme.downcase if uri.scheme
|
||||
uri.host = uri.host.downcase if uri.host
|
||||
uri.port = nil if uri.port == uri.default_port
|
||||
|
||||
path = uri.path.to_s.gsub(/%2[eE]/, ".")
|
||||
uri.path = remove_dot_segments(path)
|
||||
uri.path = "" if uri.path == "/"
|
||||
|
||||
uri.query = normalize_query(uri.query)
|
||||
|
||||
uri.to_s
|
||||
end
|
||||
|
||||
# Returns true when `url` is safe to use for OAuth communication per
|
||||
# the MCP authorization spec's "Communication Security" requirement:
|
||||
# `https` is always allowed, `http` is permitted only when the host is
|
||||
# a loopback address (`localhost`, `127.0.0.0/8`, or `::1`).
|
||||
#
|
||||
# The loopback exception applies uniformly to every OAuth-related URL
|
||||
# the SDK consumes (PRM URL, AS metadata URL, `authorization_servers`
|
||||
# entries, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
|
||||
# the `redirect_uri`, and the MCP transport URL when `oauth:` is set).
|
||||
# A strict reading of OAuth 2.1 reserves the loopback carve-out for
|
||||
# `redirect_uri` only (per RFC 8252), but neither the Python nor
|
||||
# the TypeScript MCP SDK enforces HTTPS on those endpoints either -
|
||||
# and the official MCP conformance test suite drives its fixtures
|
||||
# over `http://localhost` auth servers, so enforcing HTTPS for everything
|
||||
# except `redirect_uri` would break local development out of the box and
|
||||
# regress 16 conformance scenarios. Operators who run in production are
|
||||
# expected to deploy real HTTPS endpoints; this helper does not enforce
|
||||
# that at the SDK boundary.
|
||||
#
|
||||
# Rejects URLs that fail to parse, lack a host, or whose `http://` host is
|
||||
# something like `127.attacker.com` or `foo.localhost`,
|
||||
# which would otherwise pass a naive `start_with?("127.")` check.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
|
||||
def secure_url?(url)
|
||||
return false if url.nil? || url.to_s.empty?
|
||||
|
||||
uri = URI.parse(url.to_s)
|
||||
return false if uri.host.nil? || uri.host.empty?
|
||||
|
||||
scheme = uri.scheme&.downcase
|
||||
return true if scheme == "https"
|
||||
return loopback_host?(uri.host) if scheme == "http"
|
||||
|
||||
false
|
||||
rescue URI::InvalidURIError
|
||||
false
|
||||
end
|
||||
|
||||
# Like `canonicalize_url` but also strips query string, fragment, and
|
||||
# userinfo. This variant is used for identity comparison against
|
||||
# the request URL Faraday actually sends, which differs from the value
|
||||
# the caller passed in two ways: `Faraday::Connection#url_prefix`
|
||||
# drops query parameters, and Faraday hoists `user:pass@` out of
|
||||
# the URL into an `Authorization: Basic` header before the request goes
|
||||
# out. Including userinfo here would (a) raise a false-positive
|
||||
# `InsecureURLError` on any legitimate URL with credentials in
|
||||
# the authority, and (b) leak `user:pass` through the resulting error
|
||||
# message - both of which would defeat the bearer-token-protection
|
||||
# purpose of the identity check.
|
||||
def canonicalize_origin_and_path(url)
|
||||
uri = URI.parse(url.to_s)
|
||||
|
||||
uri.fragment = nil
|
||||
uri.query = nil
|
||||
# `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
|
||||
# so clear the components individually.
|
||||
if uri.respond_to?(:user) && (uri.user || uri.password)
|
||||
uri.user = nil
|
||||
uri.password = nil
|
||||
end
|
||||
uri.scheme = uri.scheme.downcase if uri.scheme
|
||||
uri.host = uri.host.downcase if uri.host
|
||||
uri.port = nil if uri.port == uri.default_port
|
||||
|
||||
path = uri.path.to_s.gsub(/%2[eE]/, ".")
|
||||
uri.path = remove_dot_segments(path)
|
||||
uri.path = "" if uri.path == "/"
|
||||
|
||||
uri.to_s
|
||||
end
|
||||
|
||||
# Returns true when `prm` (a PRM `resource` URL) covers `server`
|
||||
# (the MCP endpoint URL): same scheme/host/port, with PRM's path being
|
||||
# a prefix of the server's path. When PRM also advertises a query
|
||||
# string, the server's query MUST be identical to it
|
||||
# (otherwise a hijacked PRM that advertises `?tenant=evil` would cover
|
||||
# an MCP server at `?tenant=victim` and let the attacker mint
|
||||
# a different tenant's token for the same origin + path).
|
||||
# PRM with *no* query (URI#query returns `nil`) acts as a generic identifier
|
||||
# over the origin + path prefix and covers any server query.
|
||||
#
|
||||
# An empty query (`prm_url?` -- URI#query returns `""`) is NOT
|
||||
# treated as wildcard: it represents the URI literally `<...>?`,
|
||||
# which is distinct from "no query at all" and from any non-empty query,
|
||||
# so it must match exactly.
|
||||
#
|
||||
# Both arguments must already be canonicalized.
|
||||
def resource_covers?(prm:, server:)
|
||||
prm_uri = URI.parse(prm)
|
||||
server_uri = URI.parse(server)
|
||||
return false unless prm_uri.scheme == server_uri.scheme &&
|
||||
prm_uri.host == server_uri.host &&
|
||||
prm_uri.port == server_uri.port
|
||||
|
||||
prm_path = prm_uri.path.to_s
|
||||
server_path = server_uri.path.to_s
|
||||
prm_path = "" if prm_path == "/"
|
||||
server_path = "" if server_path == "/"
|
||||
path_covers = server_path == prm_path || server_path.start_with?("#{prm_path}/")
|
||||
return false unless path_covers
|
||||
|
||||
prm_query = prm_uri.query
|
||||
return true if prm_query.nil?
|
||||
|
||||
prm_query == server_uri.query
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Unescapes a `quoted-string` value's `quoted-pair` octets per
|
||||
# RFC 7230 Section 3.2.6 (referenced from RFC 7235): `\<char>` becomes `<char>`.
|
||||
# https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6
|
||||
def unescape_quoted_pair(value)
|
||||
value.gsub(/\\(.)/, '\1')
|
||||
end
|
||||
|
||||
# Recognizes the IPv4 loopback range (`127.0.0.0/8`), IPv6 loopback
|
||||
# (`::1`, optionally bracketed by `URI.parse`), and the `localhost`
|
||||
# hostname (matched exactly so that hostnames like `foo.localhost` or
|
||||
# `127.attacker.com` are not treated as loopback).
|
||||
IPV4_LOOPBACK_RANGE = IPAddr.new("127.0.0.0/8")
|
||||
IPV6_LOOPBACK = IPAddr.new("::1")
|
||||
private_constant :IPV4_LOOPBACK_RANGE, :IPV6_LOOPBACK
|
||||
|
||||
def loopback_host?(host)
|
||||
return false if host.nil? || host.empty?
|
||||
|
||||
normalized = host.downcase
|
||||
return true if normalized == "localhost"
|
||||
|
||||
ip_candidate = normalized.delete_prefix("[").delete_suffix("]")
|
||||
address = parse_ip_address(ip_candidate)
|
||||
return false unless address
|
||||
|
||||
return IPV4_LOOPBACK_RANGE.include?(address) if address.ipv4?
|
||||
return address == IPV6_LOOPBACK if address.ipv6?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def parse_ip_address(candidate)
|
||||
IPAddr.new(candidate)
|
||||
rescue IPAddr::Error
|
||||
nil
|
||||
end
|
||||
|
||||
def base_url(uri)
|
||||
port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
|
||||
"#{uri.scheme}://#{uri.host}#{port_part}"
|
||||
end
|
||||
|
||||
# Normalizes a URL query string so two URLs that are equivalent in
|
||||
# OAuth identity terms compare as equal. This is required because
|
||||
# Faraday transparently rewrites `env.url` before sending a request:
|
||||
#
|
||||
# - Parameters get sorted by name (`?b=2&a=1` -> `?a=1&b=2`).
|
||||
# - Percent-encoded hex is uppercased (`?x=%2f` -> `?x=%2F`).
|
||||
# - A trailing `?` with no parameters is dropped (`?` -> no query).
|
||||
# - Same-name keys are collapsed so only the last value survives
|
||||
# (`?a=1&a=2` -> `?a=2`).
|
||||
# - Empty-name pairs and blank `&&` separators are dropped
|
||||
# (`?=v` -> no query, `?&&a=1&&` -> `?a=1`).
|
||||
# - Value-less keys (`?tenant`) and empty-value keys (`?tenant=`)
|
||||
# are kept distinct -- Faraday preserves the `=` exactly as
|
||||
# the caller passed it. `URI.decode_www_form` / `encode_www_form`
|
||||
# would collapse both to `?tenant=`, so this function does
|
||||
# the parsing by hand on `&`-separated segments.
|
||||
#
|
||||
# Without applying the same transformation to our snapshotted URL,
|
||||
# the request-time URL guard would false-positive on every URL that
|
||||
# falls under one of the rules above.
|
||||
#
|
||||
# Returns `nil` when the resulting query is empty
|
||||
# (matching Faraday's drop-empty-query behavior).
|
||||
def normalize_query(query)
|
||||
return if query.nil? || query.empty?
|
||||
|
||||
# Each segment becomes `[decoded_name, has_equals?, decoded_value_or_nil]`.
|
||||
# `has_equals?` is what lets us preserve the `?key` vs `?key=`
|
||||
# distinction that Faraday respects.
|
||||
parsed = query.split("&").filter_map do |segment|
|
||||
next if segment.empty?
|
||||
|
||||
name_raw, separator, value_raw = segment.partition("=")
|
||||
name = URI.decode_www_form_component(name_raw)
|
||||
next if name.empty?
|
||||
|
||||
has_equals = !separator.empty?
|
||||
value = has_equals ? URI.decode_www_form_component(value_raw) : nil
|
||||
[name, has_equals, value]
|
||||
end
|
||||
|
||||
# Keys ending in `[]` (`tenant[]`, `roles[]`) are Faraday's array
|
||||
# notation: the encoder preserves every occurrence in input order
|
||||
# instead of collapsing them. All other keys (`tenant`, `a[b]`,
|
||||
# plain scalars) are collapsed with last-write-wins semantics.
|
||||
# Separating the two avoids a false negative where a hijacked
|
||||
# middleware drops an entry from a `?tenant[]=victim&tenant[]=...`
|
||||
# URL and slips past the guard, and a false positive on
|
||||
# a legitimate `?roles[]=a&roles[]=b` URL.
|
||||
array_segments = []
|
||||
scalar_segments = {}
|
||||
parsed.each do |name, has_equals, value|
|
||||
if name.end_with?("[]")
|
||||
array_segments << [name, has_equals, value]
|
||||
else
|
||||
scalar_segments[name] = [has_equals, value]
|
||||
end
|
||||
end
|
||||
|
||||
scalar_entries = scalar_segments.map { |name, (has_equals, value)| [name, has_equals, value] }
|
||||
combined = scalar_entries + array_segments
|
||||
return if combined.empty?
|
||||
|
||||
# Stable sort by name: array entries that share a name keep their
|
||||
# original order, while scalar names are alphabetized to match
|
||||
# Faraday's deterministic encoding order.
|
||||
combined.each_with_index
|
||||
.sort_by { |(name, _, _), index| [name, index] }
|
||||
.map do |(name, has_equals, value), _index|
|
||||
encoded_name = URI.encode_www_form_component(name)
|
||||
if has_equals
|
||||
"#{encoded_name}=#{URI.encode_www_form_component(value)}"
|
||||
else
|
||||
encoded_name
|
||||
end
|
||||
end.join("&")
|
||||
end
|
||||
|
||||
# Implements RFC 3986 Section 5.2.4 `remove_dot_segments`. Walks the input
|
||||
# buffer one segment at a time, popping the previous output segment
|
||||
# whenever a `..` is encountered, so that `/api/../mcp` collapses to
|
||||
# `/mcp` and `/foo/./bar` collapses to `/foo/bar`.
|
||||
# https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4
|
||||
def remove_dot_segments(path)
|
||||
return path if path.nil? || path.empty?
|
||||
|
||||
input = path.dup
|
||||
output = +""
|
||||
until input.empty?
|
||||
if input.start_with?("../")
|
||||
input = input[3..]
|
||||
elsif input.start_with?("./")
|
||||
input = input[2..]
|
||||
elsif input.start_with?("/./")
|
||||
input = "/#{input[3..]}"
|
||||
elsif input == "/."
|
||||
input = "/"
|
||||
elsif input.start_with?("/../")
|
||||
input = "/#{input[4..]}"
|
||||
output = remove_last_segment(output)
|
||||
elsif input == "/.."
|
||||
input = "/"
|
||||
output = remove_last_segment(output)
|
||||
elsif input == "." || input == ".."
|
||||
input = ""
|
||||
else
|
||||
segment = input.match(%r{\A/?[^/]*})[0]
|
||||
output << segment
|
||||
input = input[segment.length..]
|
||||
end
|
||||
end
|
||||
output
|
||||
end
|
||||
|
||||
def remove_last_segment(output)
|
||||
idx = output.rindex("/")
|
||||
return +"" if idx.nil?
|
||||
|
||||
output[0...idx]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
587
lib/mcp/client/oauth/flow.rb
Normal file
587
lib/mcp/client/oauth/flow.rb
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "base64"
|
||||
require "json"
|
||||
require "openssl"
|
||||
require "securerandom"
|
||||
require "uri"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow.
|
||||
# Driven by `MCP::Client::HTTP` on a 401 response. The user-facing surface is
|
||||
# `Provider`; this class consumes a Provider plus signal data extracted from
|
||||
# the failing response (resource_metadata URL, scope challenge).
|
||||
class Flow
|
||||
class AuthorizationError < StandardError; end
|
||||
|
||||
# Raised specifically when the token endpoint rejects a grant with
|
||||
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
|
||||
# distinguish "the stored refresh token is dead, discard it" from
|
||||
# transient failures (network, 5xx, other RFC 6749 error codes) that
|
||||
# should leave the refresh token intact.
|
||||
class InvalidGrantError < AuthorizationError; end
|
||||
|
||||
def initialize(provider:, http_client_factory: nil)
|
||||
@provider = provider
|
||||
@http_client_factory = http_client_factory || -> { default_http_client }
|
||||
end
|
||||
|
||||
# Runs the full discovery, registration, authorization, and token exchange flow.
|
||||
# On success, persists tokens via the provider and returns `:authorized`.
|
||||
def run!(server_url:, resource_metadata_url: nil, scope: nil)
|
||||
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
|
||||
# first thing we contact in the OAuth flow, so it has to clear the same
|
||||
# Communication Security bar as the OAuth endpoints downstream.
|
||||
if resource_metadata_url
|
||||
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
||||
end
|
||||
|
||||
prm = fetch_protected_resource_metadata(
|
||||
server_url: server_url,
|
||||
resource_metadata_url: resource_metadata_url,
|
||||
)
|
||||
authorization_server = first_authorization_server(prm)
|
||||
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
||||
|
||||
# Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
|
||||
# both the authorization and token requests. When PRM advertises a `resource`,
|
||||
# it MUST identify the same MCP server we are talking to; otherwise we are
|
||||
# being redirected to credentials minted for a different audience.
|
||||
resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
|
||||
|
||||
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
||||
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
||||
ensure_secure_endpoints!(as_metadata)
|
||||
ensure_pkce_supported!(as_metadata)
|
||||
|
||||
client_info = ensure_client_registered(as_metadata: as_metadata)
|
||||
|
||||
effective_scope = resolve_scope(scope: scope, prm: prm)
|
||||
pkce = PKCE.generate
|
||||
state = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
authorization_url = build_authorization_url(
|
||||
as_metadata: as_metadata,
|
||||
client_id: client_info_required_value(client_info, "client_id"),
|
||||
scope: effective_scope,
|
||||
state: state,
|
||||
code_challenge: pkce[:code_challenge],
|
||||
resource: resource,
|
||||
)
|
||||
|
||||
@provider.redirect_handler.call(authorization_url)
|
||||
code, returned_state = Array(@provider.callback_handler.call)
|
||||
raise AuthorizationError, "Authorization callback did not return an authorization code." unless code
|
||||
|
||||
unless states_match?(returned_state, state)
|
||||
raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
|
||||
end
|
||||
|
||||
tokens = exchange_authorization_code(
|
||||
as_metadata: as_metadata,
|
||||
client_info: client_info,
|
||||
code: code,
|
||||
code_verifier: pkce[:code_verifier],
|
||||
resource: resource,
|
||||
)
|
||||
|
||||
@provider.save_tokens(tokens)
|
||||
:authorized
|
||||
end
|
||||
|
||||
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749
|
||||
# Section 6). Re-discovers PRM and AS metadata so we always pick up a moved
|
||||
# token endpoint, and re-runs the audience / issuer / security checks
|
||||
# before talking to it.
|
||||
#
|
||||
# Returns `:refreshed` on success. Raises `AuthorizationError` when
|
||||
# the provider has no refresh token, no client information, or when
|
||||
# the token endpoint refuses the refresh request.
|
||||
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
||||
def refresh!(server_url:, resource_metadata_url: nil)
|
||||
refresh_token = read_token("refresh_token")
|
||||
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
|
||||
|
||||
client_info = @provider.client_information
|
||||
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
|
||||
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
|
||||
end
|
||||
|
||||
if resource_metadata_url
|
||||
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
||||
end
|
||||
|
||||
prm = fetch_protected_resource_metadata(
|
||||
server_url: server_url,
|
||||
resource_metadata_url: resource_metadata_url,
|
||||
)
|
||||
authorization_server = first_authorization_server(prm)
|
||||
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
||||
|
||||
resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
|
||||
|
||||
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
||||
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
||||
ensure_secure_endpoints!(as_metadata)
|
||||
|
||||
new_tokens = exchange_refresh_token(
|
||||
as_metadata: as_metadata,
|
||||
client_info: client_info,
|
||||
refresh_token: refresh_token,
|
||||
resource: resource,
|
||||
)
|
||||
|
||||
@provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
|
||||
:refreshed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def read_token(key)
|
||||
tokens = @provider.tokens
|
||||
return unless tokens.is_a?(Hash)
|
||||
|
||||
value = tokens[key] || tokens[key.to_sym]
|
||||
value.to_s.empty? ? nil : value
|
||||
end
|
||||
|
||||
# Per RFC 6749 Section 6, the refresh response MAY omit `refresh_token`, in
|
||||
# which case the previous one stays valid. Preserve it explicitly so
|
||||
# downstream refresh attempts still work.
|
||||
def preserve_refresh_token(new_tokens, previous_refresh_token)
|
||||
return new_tokens if new_tokens["refresh_token"] || new_tokens[:refresh_token]
|
||||
|
||||
new_tokens.merge("refresh_token" => previous_refresh_token)
|
||||
end
|
||||
|
||||
def fetch_protected_resource_metadata(server_url:, resource_metadata_url:)
|
||||
urls = Discovery.protected_resource_metadata_urls(
|
||||
server_url: server_url,
|
||||
resource_metadata_url: resource_metadata_url,
|
||||
)
|
||||
fetch_metadata_json(urls, label: "protected resource metadata")
|
||||
end
|
||||
|
||||
def fetch_authorization_server_metadata(issuer_url:)
|
||||
urls = Discovery.authorization_server_metadata_urls(issuer_url)
|
||||
fetch_metadata_json(urls, label: "authorization server metadata")
|
||||
end
|
||||
|
||||
# Reads `authorization_servers` from a PRM document and returns
|
||||
# the first entry, raising `AuthorizationError` for any of the malformed
|
||||
# shapes a non-compliant server could emit (missing field, non-Array
|
||||
# value, empty Array, non-String first entry). Centralizing this lets
|
||||
# both the full flow and the refresh flow share the same defensive
|
||||
# parse instead of each one duplicating a Hash-and-Array check.
|
||||
def first_authorization_server(prm)
|
||||
authorization_servers = prm["authorization_servers"]
|
||||
unless authorization_servers.is_a?(Array)
|
||||
raise AuthorizationError,
|
||||
"Protected resource metadata `authorization_servers` is not an array " \
|
||||
"(got #{authorization_servers.class})."
|
||||
end
|
||||
|
||||
if authorization_servers.empty?
|
||||
raise AuthorizationError, "Protected resource metadata has no authorization_servers."
|
||||
end
|
||||
|
||||
first = authorization_servers.first
|
||||
unless first.is_a?(String) && !first.empty?
|
||||
raise AuthorizationError,
|
||||
"Protected resource metadata `authorization_servers[0]` is not a non-empty string."
|
||||
end
|
||||
|
||||
first
|
||||
end
|
||||
|
||||
# Walks candidate metadata URLs and returns the parsed JSON body of
|
||||
# the first 2xx response. Raises `AuthorizationError` for transport
|
||||
# failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
|
||||
# so callers do not have to handle raw Faraday/JSON exceptions.
|
||||
def fetch_metadata_json(urls, label:)
|
||||
last_error = nil
|
||||
urls.each do |url|
|
||||
response = begin
|
||||
http_get(url)
|
||||
rescue Faraday::Error => e
|
||||
last_error = "GET #{url} raised #{e.class}: #{e.message}"
|
||||
next
|
||||
end
|
||||
|
||||
if response.status >= 200 && response.status < 300
|
||||
parsed = begin
|
||||
JSON.parse(response_body_string(response))
|
||||
rescue JSON::ParserError => e
|
||||
raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
|
||||
end
|
||||
|
||||
# Even valid JSON can be the wrong shape (a top-level array,
|
||||
# a bare `null`, a string, ...). The discovery callers index by
|
||||
# name (`prm["authorization_servers"]`, etc.), so anything that
|
||||
# is not a Hash would raise `TypeError` / `NoMethodError`
|
||||
# downstream. Surface that as `AuthorizationError` instead so
|
||||
# callers see a single, documented error type.
|
||||
unless parsed.is_a?(Hash)
|
||||
raise AuthorizationError,
|
||||
"#{label} from #{url} is not a JSON object (got #{parsed.class})."
|
||||
end
|
||||
|
||||
return parsed
|
||||
end
|
||||
|
||||
last_error = "GET #{url} returned #{response.status}"
|
||||
end
|
||||
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
|
||||
end
|
||||
|
||||
def ensure_pkce_supported!(as_metadata)
|
||||
methods = as_metadata["code_challenge_methods_supported"]
|
||||
return if methods.is_a?(Array) && methods.include?("S256")
|
||||
|
||||
raise AuthorizationError,
|
||||
"Authorization server does not advertise S256 PKCE support; refusing to proceed."
|
||||
end
|
||||
|
||||
# Per the MCP authorization spec's Communication Security requirement,
|
||||
# OAuth endpoints MUST use HTTPS unless the host is a loopback address.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
|
||||
def ensure_secure_url!(url, label:)
|
||||
return if Discovery.secure_url?(url)
|
||||
|
||||
raise AuthorizationError,
|
||||
"#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
|
||||
end
|
||||
|
||||
def ensure_secure_endpoints!(as_metadata)
|
||||
["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
|
||||
endpoint = as_metadata[key]
|
||||
ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
|
||||
end
|
||||
end
|
||||
|
||||
# Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
|
||||
# identical (literal byte-for-byte equality, no normalization) to
|
||||
# the issuer URL the client used to discover that document. This guards
|
||||
# against a CDN/relay returning the metadata of a *different*
|
||||
# authorization server than the one PRM advertised, and against
|
||||
# ambiguities like trailing `/`, fragments, or case differences that
|
||||
# could mask a confused-deputy attempt.
|
||||
# https://www.rfc-editor.org/rfc/rfc8414#section-3.3
|
||||
def ensure_issuer_matches!(expected:, returned:)
|
||||
unless returned
|
||||
raise AuthorizationError, "Authorization server metadata is missing the `issuer` field."
|
||||
end
|
||||
|
||||
return if expected.to_s == returned.to_s
|
||||
|
||||
raise AuthorizationError,
|
||||
"Authorization server metadata `issuer` does not match the discovery URL " \
|
||||
"(expected #{expected.inspect}, got #{returned.inspect})."
|
||||
end
|
||||
|
||||
def ensure_client_registered(as_metadata:)
|
||||
existing = @provider.client_information
|
||||
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
|
||||
|
||||
registration_endpoint = as_metadata["registration_endpoint"]
|
||||
unless registration_endpoint
|
||||
raise AuthorizationError,
|
||||
"Authorization server has no registration_endpoint and no pre-registered client information was provided."
|
||||
end
|
||||
|
||||
response = begin
|
||||
http_post_json(registration_endpoint, @provider.client_metadata)
|
||||
rescue Faraday::Error => e
|
||||
raise AuthorizationError,
|
||||
"Dynamic client registration failed: #{e.class}: #{e.message}."
|
||||
end
|
||||
|
||||
if response.status < 200 || response.status >= 300
|
||||
raise AuthorizationError, "Dynamic client registration failed with status #{response.status}."
|
||||
end
|
||||
|
||||
info = begin
|
||||
JSON.parse(response_body_string(response))
|
||||
rescue JSON::ParserError => e
|
||||
raise AuthorizationError,
|
||||
"Failed to parse dynamic client registration response: #{e.message}."
|
||||
end
|
||||
|
||||
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
|
||||
raise AuthorizationError,
|
||||
"Dynamic client registration response is missing `client_id`."
|
||||
end
|
||||
|
||||
@provider.save_client_information(info)
|
||||
info
|
||||
end
|
||||
|
||||
# Reads `key` from a `client_information` hash that may use either string or
|
||||
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
|
||||
# `{ client_id:, client_secret: }` and have both work.
|
||||
def client_info_value(info, key)
|
||||
info[key] || info[key.to_sym]
|
||||
end
|
||||
|
||||
# Same as `client_info_value` but treats blank strings (`""` or only
|
||||
# whitespace) as absent. Used for fields where empty values are never
|
||||
# meaningful (`client_id`, `client_secret`, `token_endpoint_auth_method`)
|
||||
# and would otherwise let a misbehaving AS or hand-built
|
||||
# `client_information` short-circuit the "is the client registered?"
|
||||
# check, or send a literal `client_secret: " "` to the token endpoint.
|
||||
def client_info_required_value(info, key)
|
||||
value = client_info_value(info, key)
|
||||
return if value.nil?
|
||||
return if value.is_a?(String) && value.strip.empty?
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
# Returns the canonical RFC 8707 `resource` URI to send on authorization and
|
||||
# token requests. When PRM advertises `resource`, that value is
|
||||
# the authorization server's idea of the resource identifier and is preferred.
|
||||
# When PRM omits it, the canonicalized MCP server URL is used.
|
||||
#
|
||||
# Either way, we validate that PRM's `resource` covers the MCP server URL
|
||||
# the client is actually talking to (same origin, with PRM's path as a prefix of
|
||||
# the server URL's path) to prevent a malicious or misconfigured PRM from
|
||||
# redirecting credentials to a different audience.
|
||||
def canonical_resource(server_url:, prm_resource:)
|
||||
server_canonical = safe_canonicalize_url(server_url, label: "MCP server URL")
|
||||
return server_canonical unless prm_resource
|
||||
|
||||
prm_canonical = safe_canonicalize_url(prm_resource, label: "PRM `resource`")
|
||||
unless Discovery.resource_covers?(prm: prm_canonical, server: server_canonical)
|
||||
raise AuthorizationError,
|
||||
"Protected resource metadata `resource` does not match the MCP server URL " \
|
||||
"(server=#{server_canonical}, prm=#{prm_canonical})."
|
||||
end
|
||||
|
||||
prm_canonical
|
||||
end
|
||||
|
||||
# Wraps `Discovery.canonicalize_url` so that any URI parsing failure
|
||||
# caused by malformed input from the server (`PRM.resource`, AS metadata
|
||||
# endpoints, ...) surfaces as `AuthorizationError` instead of leaking
|
||||
# a raw `URI::InvalidURIError` / `ArgumentError`.
|
||||
def safe_canonicalize_url(url, label:)
|
||||
Discovery.canonicalize_url(url)
|
||||
rescue URI::InvalidURIError, ArgumentError => e
|
||||
raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}."
|
||||
end
|
||||
|
||||
# Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery
|
||||
# of the expected value.
|
||||
# `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7
|
||||
# (the project's minimum supported version).
|
||||
# The hand-rolled XOR-sum walks every byte of the equal-length operands, so the running time
|
||||
# does not leak the position of the first mismatching byte.
|
||||
def states_match?(returned, expected)
|
||||
returned = returned.to_s
|
||||
return false unless returned.bytesize == expected.bytesize
|
||||
|
||||
result = 0
|
||||
returned.bytes.zip(expected.bytes) { |a, b| result |= a ^ b }
|
||||
result.zero?
|
||||
end
|
||||
|
||||
# Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
|
||||
# prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
|
||||
# from the Protected Resource Metadata, and falls back to a provider-supplied
|
||||
# scope only if both are absent. The provider-supplied scope must not pre-empt
|
||||
# a server-advertised one.
|
||||
def resolve_scope(scope:, prm:)
|
||||
return scope if scope && !scope.empty?
|
||||
|
||||
supported = prm["scopes_supported"]
|
||||
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
|
||||
|
||||
return @provider.scope if @provider.scope && !@provider.scope.empty?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
||||
authorization_endpoint = as_metadata["authorization_endpoint"]
|
||||
unless authorization_endpoint
|
||||
raise AuthorizationError,
|
||||
"Authorization server metadata is missing `authorization_endpoint`."
|
||||
end
|
||||
|
||||
uri = begin
|
||||
URI.parse(authorization_endpoint)
|
||||
rescue URI::InvalidURIError => e
|
||||
raise AuthorizationError,
|
||||
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
|
||||
end
|
||||
|
||||
params = URI.decode_www_form(uri.query.to_s)
|
||||
params << ["response_type", "code"]
|
||||
params << ["client_id", client_id]
|
||||
params << ["redirect_uri", @provider.redirect_uri]
|
||||
params << ["code_challenge", code_challenge]
|
||||
params << ["code_challenge_method", "S256"]
|
||||
params << ["state", state]
|
||||
params << ["scope", scope] if scope
|
||||
params << ["resource", resource] if resource
|
||||
uri.query = URI.encode_www_form(params)
|
||||
uri
|
||||
end
|
||||
|
||||
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
|
||||
form = {
|
||||
"grant_type" => "authorization_code",
|
||||
"code" => code,
|
||||
"redirect_uri" => @provider.redirect_uri,
|
||||
"code_verifier" => code_verifier,
|
||||
}
|
||||
form["resource"] = resource if resource
|
||||
|
||||
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
||||
end
|
||||
|
||||
def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:)
|
||||
form = {
|
||||
"grant_type" => "refresh_token",
|
||||
"refresh_token" => refresh_token,
|
||||
}
|
||||
form["resource"] = resource if resource
|
||||
|
||||
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
||||
end
|
||||
|
||||
# Submits a form-encoded request to the token endpoint, applying
|
||||
# the client authentication method advertised in `client_information` and
|
||||
# adding `client_id` (and `client_secret` when not using HTTP Basic).
|
||||
def post_to_token_endpoint(as_metadata:, client_info:, form:)
|
||||
client_id = client_info_required_value(client_info, "client_id")
|
||||
unless client_id
|
||||
raise AuthorizationError,
|
||||
"Cannot post to token endpoint: client_information is missing `client_id`."
|
||||
end
|
||||
|
||||
client_secret = client_info_required_value(client_info, "client_secret")
|
||||
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
||||
|
||||
form = form.merge("client_id" => client_id)
|
||||
headers = {}
|
||||
if client_secret
|
||||
case token_endpoint_auth_method
|
||||
when "client_secret_post"
|
||||
form["client_secret"] = client_secret
|
||||
when "none"
|
||||
# Public client; no credential.
|
||||
else
|
||||
# RFC 6749 §2.3.1 recommends Basic for confidential clients and
|
||||
# both Python and TypeScript SDKs default here when
|
||||
# the authentication method is not explicitly stored.
|
||||
headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
|
||||
end
|
||||
end
|
||||
|
||||
token_endpoint = as_metadata["token_endpoint"]
|
||||
unless token_endpoint
|
||||
raise AuthorizationError,
|
||||
"Authorization server metadata is missing `token_endpoint`."
|
||||
end
|
||||
|
||||
response = begin
|
||||
http_post_form(token_endpoint, form, headers: headers)
|
||||
rescue Faraday::Error => e
|
||||
raise AuthorizationError,
|
||||
"Token request to #{token_endpoint} failed: #{e.class}: #{e.message}."
|
||||
end
|
||||
|
||||
if response.status < 200 || response.status >= 300
|
||||
if token_endpoint_error_code(response) == "invalid_grant"
|
||||
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
|
||||
end
|
||||
|
||||
raise AuthorizationError, "Token endpoint returned status #{response.status}."
|
||||
end
|
||||
|
||||
parsed = begin
|
||||
JSON.parse(response_body_string(response))
|
||||
rescue JSON::ParserError => e
|
||||
raise AuthorizationError, "Failed to parse token endpoint response: #{e.message}."
|
||||
end
|
||||
|
||||
# Token responses MUST be a JSON object per RFC 6749 §5.1. Anything
|
||||
# else (`null`, `[]`, a bare string) would otherwise be persisted
|
||||
# as `provider.tokens` and raise raw `NoMethodError` / `TypeError`
|
||||
# the next time `provider.access_token` is read.
|
||||
unless parsed.is_a?(Hash)
|
||||
raise AuthorizationError,
|
||||
"Token endpoint response is not a JSON object (got #{parsed.class})."
|
||||
end
|
||||
|
||||
parsed
|
||||
end
|
||||
|
||||
# Extracts the `error` code from an RFC 6749 §5.2 error response body
|
||||
# when one is parseable. Returns nil on any parse failure or when
|
||||
# the body is not JSON.
|
||||
def token_endpoint_error_code(response)
|
||||
body = response_body_string(response).to_s
|
||||
return if body.empty?
|
||||
|
||||
parsed = JSON.parse(body)
|
||||
parsed["error"] if parsed.is_a?(Hash)
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
|
||||
# `application/x-www-form-urlencoded` encoded before they are joined with
|
||||
# `:` and base64-encoded for the `Authorization: Basic` header. This is
|
||||
# what prevents credentials containing `:` or other special characters
|
||||
# from being mis-parsed by the authorization server.
|
||||
# https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
|
||||
def basic_auth_credentials(client_id, client_secret)
|
||||
encoded_id = URI.encode_www_form_component(client_id)
|
||||
encoded_secret = URI.encode_www_form_component(client_secret)
|
||||
Base64.strict_encode64("#{encoded_id}:#{encoded_secret}")
|
||||
end
|
||||
|
||||
def http_get(url)
|
||||
http_client.get(url)
|
||||
end
|
||||
|
||||
def http_post_json(url, body)
|
||||
http_client.post(url) do |req|
|
||||
req.headers["Content-Type"] = "application/json"
|
||||
req.headers["Accept"] = "application/json"
|
||||
req.body = JSON.generate(body)
|
||||
end
|
||||
end
|
||||
|
||||
def http_post_form(url, form, headers: {})
|
||||
http_client.post(url) do |req|
|
||||
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
req.headers["Accept"] = "application/json"
|
||||
headers.each { |key, value| req.headers[key] = value }
|
||||
req.body = URI.encode_www_form(form)
|
||||
end
|
||||
end
|
||||
|
||||
def http_client
|
||||
@http_client ||= @http_client_factory.call
|
||||
end
|
||||
|
||||
def default_http_client
|
||||
require "faraday"
|
||||
Faraday.new do |faraday|
|
||||
faraday.headers["Accept"] = "application/json"
|
||||
end
|
||||
end
|
||||
|
||||
def response_body_string(response)
|
||||
body = response.body
|
||||
body.is_a?(String) ? body : body.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
43
lib/mcp/client/oauth/in_memory_storage.rb
Normal file
43
lib/mcp/client/oauth/in_memory_storage.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Default reference implementation of the storage contract that
|
||||
# `Provider` uses to persist OAuth state. Holds the two pieces of data
|
||||
# the flow saves and reads on every request:
|
||||
#
|
||||
# - `tokens`: the hash returned by the token endpoint
|
||||
# (`access_token`, optional `refresh_token`, `expires_in`, `scope`, etc.).
|
||||
# - `client_information`: the hash returned by Dynamic Client Registration
|
||||
# or supplied as pre-registered credentials
|
||||
# (`client_id`, optional `client_secret`, optional
|
||||
# `token_endpoint_auth_method`).
|
||||
#
|
||||
# This class keeps everything in process memory, so the credentials live
|
||||
# only for the lifetime of the Ruby process. Applications that need
|
||||
# persistence across restarts should supply a custom object responding to
|
||||
# the same four-method contract (`tokens`, `save_tokens(t)`,
|
||||
# `client_information`, `save_client_information(info)`) and pass it via
|
||||
# `Provider.new(storage: ...)`. The shape mirrors Python SDK's
|
||||
# `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls
|
||||
# the same responsibilities into a single object.
|
||||
class InMemoryStorage
|
||||
attr_accessor :tokens, :client_information
|
||||
|
||||
def initialize
|
||||
@tokens = nil
|
||||
@client_information = nil
|
||||
end
|
||||
|
||||
def save_tokens(tokens)
|
||||
@tokens = tokens
|
||||
end
|
||||
|
||||
def save_client_information(info)
|
||||
@client_information = info
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
38
lib/mcp/client/oauth/pkce.rb
Normal file
38
lib/mcp/client/oauth/pkce.rb
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "base64"
|
||||
require "digest"
|
||||
require "securerandom"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Generates the Proof Key for Code Exchange (PKCE) pair (RFC 7636) used to
|
||||
# bind an OAuth 2.1 authorization request to the same client that later
|
||||
# redeems the resulting authorization code. Without PKCE, an attacker
|
||||
# who steals an authorization code in transit (e.g. through a logged redirect URI)
|
||||
# can exchange it for an access token; with PKCE, the attacker would also need
|
||||
# the per-request `code_verifier`, which is never sent to the browser or any intermediary.
|
||||
#
|
||||
# MCP authorization mandates the `S256` method, so this module always returns
|
||||
# that method and refuses to expose `plain` as an option.
|
||||
#
|
||||
# The module is stateless: callers ask for a fresh pair with `generate` for
|
||||
# every authorization request and discard the result once the token endpoint has accepted it.
|
||||
#
|
||||
# - https://datatracker.ietf.org/doc/html/rfc7636
|
||||
# - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection
|
||||
module PKCE
|
||||
class << self
|
||||
# Generates a PKCE pair (code_verifier and S256 code_challenge).
|
||||
def generate
|
||||
verifier = SecureRandom.urlsafe_base64(64)
|
||||
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
||||
|
||||
{ code_verifier: verifier, code_challenge: challenge, code_challenge_method: "S256" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
103
lib/mcp/client/oauth/provider.rb
Normal file
103
lib/mcp/client/oauth/provider.rb
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via
|
||||
# the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK
|
||||
# and httpx.Auth-based provider in the Python SDK.
|
||||
#
|
||||
# Required keyword arguments:
|
||||
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
||||
# Registration endpoint. Must include at minimum `redirect_uris`,
|
||||
# `grant_types`, `response_types`, and `token_endpoint_auth_method`.
|
||||
# - `redirect_uri` - String: the redirect URI used for the authorization
|
||||
# request. Must be one of `redirect_uris` in `client_metadata`.
|
||||
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
||||
# URL (a `URI`). Implementations typically open the user's browser.
|
||||
# - `callback_handler` - Callable invoked after `redirect_handler`. Returns
|
||||
# `[code, state]` where `code` is the authorization code and `state` is
|
||||
# the `state` parameter received on the redirect URI.
|
||||
#
|
||||
# Optional keyword arguments:
|
||||
# - `scope` - String of space-separated scopes to request when the server's
|
||||
# `WWW-Authenticate` does not specify one.
|
||||
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
||||
# `client_information`, and `save_client_information(info)`. Defaults to
|
||||
# an `InMemoryStorage`.
|
||||
class Provider
|
||||
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
||||
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
||||
# authorization spec's Communication Security requirement.
|
||||
class InsecureRedirectURIError < ArgumentError; end
|
||||
|
||||
# Raised when the `redirect_uri` argument is not listed in
|
||||
# `client_metadata[:redirect_uris]` / `["redirect_uris"]`. Registering
|
||||
# the URI with the authorization server but then sending a different
|
||||
# one with the authorization request would be rejected by the AS at
|
||||
# runtime; failing at construction surfaces the bug earlier.
|
||||
class UnregisteredRedirectURIError < ArgumentError; end
|
||||
|
||||
attr_reader :client_metadata,
|
||||
:redirect_uri,
|
||||
:scope,
|
||||
:storage,
|
||||
:redirect_handler,
|
||||
:callback_handler
|
||||
|
||||
def initialize(
|
||||
client_metadata:,
|
||||
redirect_uri:,
|
||||
redirect_handler:,
|
||||
callback_handler:,
|
||||
scope: nil,
|
||||
storage: nil
|
||||
)
|
||||
unless Discovery.secure_url?(redirect_uri)
|
||||
raise InsecureRedirectURIError,
|
||||
"redirect_uri #{redirect_uri.inspect} must use https or be a loopback http URL " \
|
||||
"(localhost, 127.0.0.0/8, or ::1) per the MCP authorization Communication Security requirement."
|
||||
end
|
||||
|
||||
registered = Array(client_metadata[:redirect_uris] || client_metadata["redirect_uris"])
|
||||
unless registered.include?(redirect_uri)
|
||||
raise UnregisteredRedirectURIError,
|
||||
"redirect_uri #{redirect_uri.inspect} must be listed in client_metadata[:redirect_uris] " \
|
||||
"(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
|
||||
end
|
||||
|
||||
@client_metadata = client_metadata
|
||||
@redirect_uri = redirect_uri
|
||||
@redirect_handler = redirect_handler
|
||||
@callback_handler = callback_handler
|
||||
@scope = scope
|
||||
@storage = storage || InMemoryStorage.new
|
||||
end
|
||||
|
||||
def access_token
|
||||
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
||||
end
|
||||
|
||||
def tokens
|
||||
@storage.tokens
|
||||
end
|
||||
|
||||
def save_tokens(tokens)
|
||||
@storage.save_tokens(tokens)
|
||||
end
|
||||
|
||||
def client_information
|
||||
@storage.client_information
|
||||
end
|
||||
|
||||
def save_client_information(info)
|
||||
@storage.save_client_information(info)
|
||||
end
|
||||
|
||||
def clear_tokens!
|
||||
@storage.save_tokens(nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
467
test/mcp/client/oauth/discovery_test.rb
Normal file
467
test/mcp/client/oauth/discovery_test.rb
Normal 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
|
||||
1318
test/mcp/client/oauth/flow_test.rb
Normal file
1318
test/mcp/client/oauth/flow_test.rb
Normal file
File diff suppressed because it is too large
Load diff
1086
test/mcp/client/oauth/http_oauth_test.rb
Normal file
1086
test/mcp/client/oauth/http_oauth_test.rb
Normal file
File diff suppressed because it is too large
Load diff
34
test/mcp/client/oauth/pkce_test.rb
Normal file
34
test/mcp/client/oauth/pkce_test.rb
Normal 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
|
||||
89
test/mcp/client/oauth/provider_test.rb
Normal file
89
test/mcp/client/oauth/provider_test.rb
Normal 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
|
||||
Loading…
Reference in a new issue