ruby-sdk/lib/mcp/client/oauth/provider.rb
Koichi ITO 5766069923 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.
2026-05-17 00:48:41 +09:00

103 lines
4 KiB
Ruby

# 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