Merge pull request #560 from koic/let_a_provider_customize_the_oauth_http_client
Let a provider customize the HTTP client the OAuth flow uses
This commit is contained in:
commit
5f429f3fbf
14 changed files with 856 additions and 26 deletions
|
|
@ -97,6 +97,8 @@ Optional keyword arguments:
|
||||||
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
|
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
|
||||||
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
|
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
|
||||||
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
|
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
|
||||||
|
- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard.
|
||||||
|
See [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client).
|
||||||
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
|
- `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. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
|
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
|
||||||
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
|
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
|
||||||
|
|
@ -219,7 +221,7 @@ Keyword arguments:
|
||||||
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
|
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
|
||||||
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
|
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
|
||||||
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
|
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
|
||||||
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`.
|
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
|
||||||
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.
|
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.
|
||||||
|
|
||||||
### Cross-App Access (JWT Bearer) Grant
|
### Cross-App Access (JWT Bearer) Grant
|
||||||
|
|
@ -258,7 +260,39 @@ Keyword arguments:
|
||||||
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
|
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
|
||||||
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
|
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
|
||||||
Passing both through to `IDJAGTokenExchange.request` covers the common case.
|
Passing both through to `IDJAGTokenExchange.request` covers the common case.
|
||||||
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`.
|
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
|
||||||
|
|
||||||
|
### Customizing the OAuth HTTP Client
|
||||||
|
|
||||||
|
The requests the OAuth flow makes (Protected Resource Metadata discovery on the MCP server's origin, authorization server metadata discovery,
|
||||||
|
dynamic client registration, and every token request the flow sends, whether the first exchange, a refresh, or a step-up) go over a Faraday connection of their own,
|
||||||
|
not over the transport's connection: the transport's is bound to the MCP server URL and carries the `headers:` and the customizer block meant for that server.
|
||||||
|
To add middleware to the OAuth flow's connection, or to swap its adapter, pass `http_client_customizer:` to the provider:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
|
||||||
|
client_id: "my-service",
|
||||||
|
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
||||||
|
http_client_customizer: ->(faraday) { faraday.use MyApp::Middleware::HttpRecorder },
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The callable receives the `Faraday::Connection` after the SDK has applied its defaults and registered the middleware that records the requested URL,
|
||||||
|
and before the SDK registers its origin guard, the same position the transport's customizer block has on the MCP server connection.
|
||||||
|
It may be invoked more than once, and from more than one thread at a time, so keep it free of side effects and safe to run concurrently;
|
||||||
|
today it runs once per authorization attempt, but that is not a promise.
|
||||||
|
A few constraints follow from the checks described below:
|
||||||
|
|
||||||
|
- Do not add redirect-following middleware. Every destination check runs against the URL as written, so a request that middleware added by the customizer would send
|
||||||
|
to a different origin after the SDK has recorded the requested URL, whether by following a `3xx` or by rewriting the URL, is refused with `Flow::DestinationMismatchError`
|
||||||
|
before it reaches the adapter, as is a request that reaches the guard without that record. Middleware inserted ahead of the record with `builder.insert(0, ...)`
|
||||||
|
that rewrites the URL first or rebuilds the environment is outside the guard, as is following done inside an adapter, so leave both off.
|
||||||
|
- Leave `Accept-Encoding` unset. The response cap below is measured on decoded bytes, and claiming the header turns Net::HTTP's decoding off.
|
||||||
|
- Do not add Faraday's `raise_error` middleware. The flow reads statuses itself, both to tell an absent metadata document from a failed request
|
||||||
|
and to turn a token endpoint error into `Flow::InvalidGrantError`.
|
||||||
|
- With an adapter that does not stream through `on_data`, the response cap is applied once the body has been buffered rather than as it arrives.
|
||||||
|
- A middleware that records requests sees the client credentials on token requests (`Authorization: Basic`, `client_secret`, `client_assertion`), refresh tokens,
|
||||||
|
and the access tokens in token responses; redact them before they reach a log.
|
||||||
|
|
||||||
### Communication Security
|
### Communication Security
|
||||||
|
|
||||||
|
|
@ -290,8 +324,11 @@ The range check compares IP literals and does not resolve hostnames, so it canno
|
||||||
such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one
|
such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one
|
||||||
the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly.
|
the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly.
|
||||||
|
|
||||||
If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against
|
On the connection the SDK builds, a middleware that would send a request to a different origin, by following a `3xx` or by rewriting the URL, is refused before the request
|
||||||
the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused.
|
goes out (see [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client)). A connection supplied through `MCP::Client::OAuth::Flow.new(http_client_factory:)`
|
||||||
|
replaces that one, the provider's `http_client_customizer` and the guard included, so do not add redirect-following middleware to it: every check above runs against
|
||||||
|
the URL as written, and a connection that follows a `3xx` on its own would reach hosts these rules just refused.
|
||||||
|
A factory that wants to keep them can return `MCP::Client::OAuth::Flow.build_http_client(customizer)`, the connection the SDK builds for itself.
|
||||||
|
|
||||||
The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB,
|
The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB,
|
||||||
measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion.
|
measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion.
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,9 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through
|
||||||
|
the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client).
|
||||||
|
|
||||||
{: .note }
|
{: .note }
|
||||||
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside
|
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside
|
||||||
> the SSE streaming callback of another response, re-entering the connection on the same thread.
|
> the SSE streaming callback of another response, re-entering the connection on the same thread.
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ module MCP
|
||||||
# Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
|
# Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
|
||||||
# `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
|
# `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
|
||||||
# that expands past the cap is refused partway through the expansion rather than after it. That holds only while
|
# that expands past the cap is refused partway through the expansion rather than after it. That holds only while
|
||||||
# the connection leaves `Accept-Encoding` to the adapter; see `Flow#default_http_client`.
|
# the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`.
|
||||||
def on_data
|
def on_data
|
||||||
proc do |chunk, _received_bytes, _env|
|
proc do |chunk, _received_bytes, _env|
|
||||||
@buffer << chunk
|
@buffer << chunk
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ module MCP
|
||||||
# server requires beyond the grant itself (Auth0's `audience`, for example).
|
# server requires beyond the grant itself (Auth0's `audience`, for example).
|
||||||
# A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
|
# A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
|
||||||
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
||||||
|
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
|
||||||
|
# its own requests; see `StorageBackedProvider#http_client_customizer`.
|
||||||
class ClientCredentialsProvider
|
class ClientCredentialsProvider
|
||||||
include StorageBackedProvider
|
include StorageBackedProvider
|
||||||
|
|
||||||
|
|
@ -66,7 +68,8 @@ module MCP
|
||||||
scope: nil,
|
scope: nil,
|
||||||
storage: nil,
|
storage: nil,
|
||||||
authorization_request_validator: nil,
|
authorization_request_validator: nil,
|
||||||
token_request_params: nil
|
token_request_params: nil,
|
||||||
|
http_client_customizer: nil
|
||||||
)
|
)
|
||||||
if blank?(client_id)
|
if blank?(client_id)
|
||||||
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
|
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
|
||||||
|
|
@ -104,6 +107,8 @@ module MCP
|
||||||
client_information["client_secret"] = client_secret
|
client_information["client_secret"] = client_secret
|
||||||
end
|
end
|
||||||
|
|
||||||
|
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
||||||
|
|
||||||
@client_id = client_id
|
@client_id = client_id
|
||||||
@private_key = private_key
|
@private_key = private_key
|
||||||
@signing_algorithm = signing_algorithm
|
@signing_algorithm = signing_algorithm
|
||||||
|
|
@ -111,6 +116,7 @@ module MCP
|
||||||
@storage = storage || InMemoryStorage.new
|
@storage = storage || InMemoryStorage.new
|
||||||
@authorization_request_validator = authorization_request_validator
|
@authorization_request_validator = authorization_request_validator
|
||||||
@token_request_params = frozen_token_request_params(token_request_params)
|
@token_request_params = frozen_token_request_params(token_request_params)
|
||||||
|
@http_client_customizer = http_client_customizer
|
||||||
@storage.save_client_information(client_information)
|
@storage.save_client_information(client_information)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ module MCP
|
||||||
# the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
|
# the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
|
||||||
# `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
|
# `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
|
||||||
# See `StorageBackedProvider#token_request_params`.
|
# See `StorageBackedProvider#token_request_params`.
|
||||||
|
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for its own requests;
|
||||||
|
# see `StorageBackedProvider#http_client_customizer`.
|
||||||
#
|
#
|
||||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
||||||
class CrossAppAccessProvider
|
class CrossAppAccessProvider
|
||||||
|
|
@ -46,7 +48,8 @@ module MCP
|
||||||
scope: nil,
|
scope: nil,
|
||||||
storage: nil,
|
storage: nil,
|
||||||
authorization_request_validator: nil,
|
authorization_request_validator: nil,
|
||||||
token_request_params: nil
|
token_request_params: nil,
|
||||||
|
http_client_customizer: nil
|
||||||
)
|
)
|
||||||
if blank?(client_id)
|
if blank?(client_id)
|
||||||
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
|
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
|
||||||
|
|
@ -60,11 +63,14 @@ module MCP
|
||||||
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
|
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
||||||
|
|
||||||
@assertion_provider = assertion_provider
|
@assertion_provider = assertion_provider
|
||||||
@scope = scope
|
@scope = scope
|
||||||
@storage = storage || InMemoryStorage.new
|
@storage = storage || InMemoryStorage.new
|
||||||
@authorization_request_validator = authorization_request_validator
|
@authorization_request_validator = authorization_request_validator
|
||||||
@token_request_params = frozen_token_request_params(token_request_params)
|
@token_request_params = frozen_token_request_params(token_request_params)
|
||||||
|
@http_client_customizer = http_client_customizer
|
||||||
@storage.save_client_information(
|
@storage.save_client_information(
|
||||||
"client_id" => client_id,
|
"client_id" => client_id,
|
||||||
"client_secret" => client_secret,
|
"client_secret" => client_secret,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,71 @@ module MCP
|
||||||
# a failed refresh as a reason to run the interactive flow.
|
# a failed refresh as a reason to run the interactive flow.
|
||||||
class InvalidTokenRequestParamsError < ArgumentError; end
|
class InvalidTokenRequestParamsError < ArgumentError; end
|
||||||
|
|
||||||
|
# Raised by `RequestedOriginGuard` when middleware added through the provider's `http_client_customizer`
|
||||||
|
# would send a request to an origin other than the one the flow validated, or has dropped the record of
|
||||||
|
# the URL the flow asked for. An `ArgumentError` because the middleware is a configuration mistake,
|
||||||
|
# and deliberately outside `AuthorizationError`, which discovery treats as "nothing published"
|
||||||
|
# and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
|
||||||
|
class DestinationMismatchError < ArgumentError; end
|
||||||
|
|
||||||
|
# Faraday middleware registered on the connection `build_http_client` assembles before the customizer
|
||||||
|
# is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
|
||||||
|
# as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
|
||||||
|
# The guard covers what happens to a request after that record; middleware inserted ahead of it with
|
||||||
|
# `builder.insert(0, ...)` that rewrites the URL before it or rebuilds the environment is outside the guard.
|
||||||
|
# The record lives on the environment, not in `env.request.context`: that slot belongs to the application,
|
||||||
|
# which may fill it on the connection or replace it from a middleware of its own. Only the first URL seen
|
||||||
|
# on an environment is recorded: a middleware inserted ahead of this one that re-enters the stack after
|
||||||
|
# a `3xx` with the same environment, or with its `dup`, which shares the record, cannot replace it with
|
||||||
|
# the redirected URL.
|
||||||
|
class RequestedURLStamp
|
||||||
|
KEY = :mcp_oauth_requested_url
|
||||||
|
|
||||||
|
def initialize(app)
|
||||||
|
@app = app
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
env[KEY] ||= env.url.to_s
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Faraday middleware registered last on that connection, so it sees `env.url` after any customizer-added
|
||||||
|
# middleware has rewritten it or followed a redirect. A request that would leave the origin the flow asked
|
||||||
|
# for is refused before it reaches the adapter, since every destination check ran against the URL as
|
||||||
|
# written; a same-origin change stays with the server those checks admitted. The record survives
|
||||||
|
# the `env.dup` that redirect-following middleware performs, and a request that arrives without it is
|
||||||
|
# refused as well, so a middleware that rebuilds the environment fails closed rather than open.
|
||||||
|
# The origin boundary resembles the one the Python SDK keeps for its own auth requests, which follows
|
||||||
|
# a redirect itself only within the origin; this flow follows none.
|
||||||
|
class RequestedOriginGuard
|
||||||
|
def initialize(app)
|
||||||
|
@app = app
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
requested = env[RequestedURLStamp::KEY]
|
||||||
|
unless requested
|
||||||
|
raise DestinationMismatchError, <<~MESSAGE
|
||||||
|
Request to #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect} carries no record of \
|
||||||
|
the URL the flow asked for; middleware that rebuilds the request environment is refused.
|
||||||
|
MESSAGE
|
||||||
|
end
|
||||||
|
|
||||||
|
unless Discovery.same_origin?(env.url.to_s, requested)
|
||||||
|
raise DestinationMismatchError, <<~MESSAGE
|
||||||
|
Request to #{Discovery.canonicalize_origin_and_path(requested).inspect} would be sent to \
|
||||||
|
#{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect}, on a different origin; \
|
||||||
|
middleware that follows redirects or rewrites URLs is refused.
|
||||||
|
MESSAGE
|
||||||
|
end
|
||||||
|
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_constant :RequestedURLStamp, :RequestedOriginGuard
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
|
# Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
|
||||||
# Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
|
# Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
|
||||||
|
|
@ -82,6 +147,33 @@ module MCP
|
||||||
|
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Builds the connection the flow uses for its own requests: the SDK's defaults, `RequestedURLStamp`,
|
||||||
|
# then `customizer` (a provider's `http_client_customizer`, called with the `Faraday::Connection`),
|
||||||
|
# then `RequestedOriginGuard` last so it sees what the customizer's middleware does to each request
|
||||||
|
# after the stamp recorded it. Every request on the connection passes through both, so a caller using
|
||||||
|
# it directly is held to the same origin rule.
|
||||||
|
#
|
||||||
|
# Deliberately built without redirect-following middleware. Every destination check in this class runs
|
||||||
|
# against the URL as written, before the request goes out, so a connection that transparently followed
|
||||||
|
# a `3xx` would let a server reach a host the checks just refused. The guard turns following at
|
||||||
|
# the middleware level into a refusal; following inside an adapter stays invisible, so a customizer must
|
||||||
|
# not enable it.
|
||||||
|
#
|
||||||
|
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
|
||||||
|
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
|
||||||
|
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
|
||||||
|
# after the check.
|
||||||
|
def build_http_client(customizer = nil)
|
||||||
|
require "faraday"
|
||||||
|
|
||||||
|
Faraday.new do |faraday|
|
||||||
|
faraday.headers["Accept"] = "application/json"
|
||||||
|
faraday.use(RequestedURLStamp)
|
||||||
|
customizer&.call(faraday)
|
||||||
|
faraday.use(RequestedOriginGuard)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(provider:, http_client_factory: nil)
|
def initialize(provider:, http_client_factory: nil)
|
||||||
|
|
@ -1298,22 +1390,18 @@ module MCP
|
||||||
@http_client ||= @http_client_factory.call
|
@http_client ||= @http_client_factory.call
|
||||||
end
|
end
|
||||||
|
|
||||||
# Deliberately built without redirect-following middleware. Every destination check in
|
# A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and
|
||||||
# this class runs against the URL as written, before the request goes out, so a connection
|
# `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on
|
||||||
# that transparently followed a `3xx` would let a server reach a host the checks just refused.
|
# `build_http_client`; `bounded_request` caps its responses all the same.
|
||||||
# A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
|
|
||||||
# and the guards above only cover the first hop.
|
|
||||||
#
|
|
||||||
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
|
|
||||||
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
|
|
||||||
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
|
|
||||||
# after the check.
|
|
||||||
def default_http_client
|
def default_http_client
|
||||||
require "faraday"
|
self.class.build_http_client(provider_http_client_customizer)
|
||||||
|
|
||||||
Faraday.new do |faraday|
|
|
||||||
faraday.headers["Accept"] = "application/json"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# `nil` for a provider that predates the hook or leaves it unset.
|
||||||
|
def provider_http_client_customizer
|
||||||
|
return unless @provider.respond_to?(:http_client_customizer)
|
||||||
|
|
||||||
|
@provider.http_client_customizer
|
||||||
end
|
end
|
||||||
|
|
||||||
def response_body_string(response)
|
def response_body_string(response)
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ module MCP
|
||||||
assertion
|
assertion
|
||||||
end
|
end
|
||||||
|
|
||||||
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`:
|
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow.build_http_client`:
|
||||||
# claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
|
# claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
|
||||||
# onto compressed bytes.
|
# onto compressed bytes.
|
||||||
def default_http_client
|
def default_http_client
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ module MCP
|
||||||
# the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`
|
# the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`
|
||||||
# raises `Flow::InvalidTokenRequestParamsError`.
|
# raises `Flow::InvalidTokenRequestParamsError`.
|
||||||
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
||||||
|
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
|
||||||
|
# its own requests; see `StorageBackedProvider#http_client_customizer`.
|
||||||
class Provider
|
class Provider
|
||||||
include StorageBackedProvider
|
include StorageBackedProvider
|
||||||
|
|
||||||
|
|
@ -93,7 +95,8 @@ module MCP
|
||||||
storage: nil,
|
storage: nil,
|
||||||
client_id_metadata_document_url: nil,
|
client_id_metadata_document_url: nil,
|
||||||
authorization_request_validator: nil,
|
authorization_request_validator: nil,
|
||||||
token_request_params: nil
|
token_request_params: nil,
|
||||||
|
http_client_customizer: nil
|
||||||
)
|
)
|
||||||
unless Discovery.secure_url?(redirect_uri)
|
unless Discovery.secure_url?(redirect_uri)
|
||||||
raise InsecureRedirectURIError,
|
raise InsecureRedirectURIError,
|
||||||
|
|
@ -115,6 +118,8 @@ module MCP
|
||||||
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
||||||
|
|
||||||
@client_metadata = client_metadata
|
@client_metadata = client_metadata
|
||||||
@redirect_uri = redirect_uri
|
@redirect_uri = redirect_uri
|
||||||
@redirect_handler = redirect_handler
|
@redirect_handler = redirect_handler
|
||||||
|
|
@ -124,6 +129,7 @@ module MCP
|
||||||
@client_id_metadata_document_url = client_id_metadata_document_url
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
||||||
@authorization_request_validator = authorization_request_validator
|
@authorization_request_validator = authorization_request_validator
|
||||||
@token_request_params = frozen_token_request_params(token_request_params)
|
@token_request_params = frozen_token_request_params(token_request_params)
|
||||||
|
@http_client_customizer = http_client_customizer
|
||||||
end
|
end
|
||||||
|
|
||||||
# Identifies the OAuth flow this provider drives.
|
# Identifies the OAuth flow this provider drives.
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ module MCP
|
||||||
# with `Flow::InvalidTokenRequestParamsError`.
|
# with `Flow::InvalidTokenRequestParamsError`.
|
||||||
attr_reader :token_request_params
|
attr_reader :token_request_params
|
||||||
|
|
||||||
|
# Optional callable invoked with the `Faraday::Connection` the flow builds for its own requests,
|
||||||
|
# after the SDK's defaults and before its origin guard (see `Flow.build_http_client`), so an application can
|
||||||
|
# add middleware to discovery, registration, and token requests or swap their adapter. `nil` (the default)
|
||||||
|
# keeps the default connection. The transport's own connection and customizer block never serve these requests:
|
||||||
|
# they are bound to the MCP server URL and carry headers meant for that server.
|
||||||
|
attr_reader :http_client_customizer
|
||||||
|
|
||||||
def access_token
|
def access_token
|
||||||
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
||||||
end
|
end
|
||||||
|
|
@ -66,6 +73,14 @@ module MCP
|
||||||
|
|
||||||
params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze
|
params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns `customizer` when it is `nil` or callable and raises otherwise, so a connection object passed
|
||||||
|
# in place of a callable fails at construction rather than at the first `401`.
|
||||||
|
def validated_http_client_customizer(customizer)
|
||||||
|
return customizer if customizer.nil? || customizer.respond_to?(:call)
|
||||||
|
|
||||||
|
raise ArgumentError, "http_client_customizer must respond to call (got #{customizer.class})."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,25 @@ module MCP
|
||||||
assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"])
|
assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_initialize_keeps_the_http_client_customizer
|
||||||
|
customizer = ->(_faraday) {}
|
||||||
|
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", http_client_customizer: customizer)
|
||||||
|
|
||||||
|
assert_same(customizer, provider.http_client_customizer)
|
||||||
|
assert_nil(ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret").http_client_customizer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials
|
||||||
|
storage = InMemoryStorage.new
|
||||||
|
|
||||||
|
error = assert_raises(ArgumentError) do
|
||||||
|
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", storage: storage, http_client_customizer: "recorder")
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal("http_client_customizer must respond to call (got String).", error.message)
|
||||||
|
assert_nil(storage.client_information)
|
||||||
|
end
|
||||||
|
|
||||||
def test_initialize_rejects_missing_client_id
|
def test_initialize_rejects_missing_client_id
|
||||||
["", " ", nil].each do |value|
|
["", " ", nil].each do |value|
|
||||||
assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do
|
assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,36 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_initialize_keeps_the_http_client_customizer
|
||||||
|
customizer = ->(_faraday) {}
|
||||||
|
provider = CrossAppAccessProvider.new(
|
||||||
|
client_id: "xaa-client",
|
||||||
|
client_secret: "xaa-secret",
|
||||||
|
assertion_provider: ->(**) { "id-jag" },
|
||||||
|
http_client_customizer: customizer,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_same(customizer, provider.http_client_customizer)
|
||||||
|
assert_nil(build_provider.http_client_customizer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials
|
||||||
|
storage = InMemoryStorage.new
|
||||||
|
|
||||||
|
error = assert_raises(ArgumentError) do
|
||||||
|
CrossAppAccessProvider.new(
|
||||||
|
client_id: "xaa-client",
|
||||||
|
client_secret: "xaa-secret",
|
||||||
|
assertion_provider: ->(**) { "id-jag" },
|
||||||
|
storage: storage,
|
||||||
|
http_client_customizer: "recorder",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal("http_client_customizer must respond to call (got String).", error.message)
|
||||||
|
assert_nil(storage.client_information)
|
||||||
|
end
|
||||||
|
|
||||||
def test_initialize_rejects_missing_client_id
|
def test_initialize_rejects_missing_client_id
|
||||||
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
|
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
|
||||||
CrossAppAccessProvider.new(
|
CrossAppAccessProvider.new(
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,13 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic", token_request_params: nil)
|
def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic", token_request_params: nil, http_client_customizer: nil)
|
||||||
ClientCredentialsProvider.new(
|
ClientCredentialsProvider.new(
|
||||||
client_id: "cc-client",
|
client_id: "cc-client",
|
||||||
client_secret: "cc-secret",
|
client_secret: "cc-secret",
|
||||||
token_endpoint_auth_method: token_endpoint_auth_method,
|
token_endpoint_auth_method: token_endpoint_auth_method,
|
||||||
token_request_params: token_request_params,
|
token_request_params: token_request_params,
|
||||||
|
http_client_customizer: http_client_customizer,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -141,6 +142,118 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Records `[method, url]` for every request that passes through, standing in for the tracing middleware
|
||||||
|
# an application adds through `http_client_customizer`.
|
||||||
|
class RecordingMiddleware
|
||||||
|
def initialize(app, log)
|
||||||
|
@app = app
|
||||||
|
@log = log
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
@log << [env.method, env.url.to_s]
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind:
|
||||||
|
# the flow asked for one URL and the adapter is handed another.
|
||||||
|
class URLRewritingMiddleware
|
||||||
|
def initialize(app, from:, to:)
|
||||||
|
@app = app
|
||||||
|
@from = from
|
||||||
|
@to = to
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
env.url = URI(@to) if env.url.to_s == @from
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Replaces the per-request context the way instrumentation middleware may; the guard must not depend on it.
|
||||||
|
class ContextReplacingMiddleware
|
||||||
|
def initialize(app)
|
||||||
|
@app = app
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
env.request.context = { tag: "instrumented" }
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever
|
||||||
|
# an earlier middleware recorded on the original.
|
||||||
|
class EnvRebuildingMiddleware
|
||||||
|
def initialize(app)
|
||||||
|
@app = app
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
@app.call(Faraday::Env.from(env.to_h))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Follows a `3xx` the way `faraday-follow_redirects` does: duplicates the environment, points it at the `Location`,
|
||||||
|
# and re-enters the stack from its own position.
|
||||||
|
class RedirectFollowingMiddleware
|
||||||
|
def initialize(app)
|
||||||
|
@app = app
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
response = @app.call(env)
|
||||||
|
location = response.headers["Location"]
|
||||||
|
return response unless location && (300..399).cover?(response.status)
|
||||||
|
|
||||||
|
redirected = env.dup
|
||||||
|
redirected.url = URI(location)
|
||||||
|
redirected.response = nil
|
||||||
|
@app.call(redirected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Replaces the request options wholesale, the way middleware that resets timeouts or contexts might.
|
||||||
|
class RequestOptionsReplacingMiddleware
|
||||||
|
def initialize(app)
|
||||||
|
@app = app
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
env.request = Faraday::RequestOptions.new
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Re-enters the stack once with the same environment after a `5xx`, the way retry middleware does.
|
||||||
|
class RetryingMiddleware
|
||||||
|
def initialize(app)
|
||||||
|
@app = app
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
response = @app.call(env)
|
||||||
|
return response unless response.status >= 500
|
||||||
|
|
||||||
|
env.response = nil
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Records the application's `trace_id` from the per-request context, as a tracing middleware would.
|
||||||
|
class ContextRecordingMiddleware
|
||||||
|
def initialize(app, log)
|
||||||
|
@app = app
|
||||||
|
@log = log
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
@log << env.request.context&.dig(:trace_id)
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Runs the full authorization flow and returns the `scope` query parameter
|
# Runs the full authorization flow and returns the `scope` query parameter
|
||||||
# sent on the authorization request. The caller stubs the AS metadata;
|
# sent on the authorization request. The caller stubs the AS metadata;
|
||||||
# this helper supplies a provider whose `grant_types` and optional pre-set
|
# this helper supplies a provider whose `grant_types` and optional pre-set
|
||||||
|
|
@ -190,6 +303,278 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_run_sends_every_authorization_server_request_through_the_provider_customizer
|
||||||
|
log = []
|
||||||
|
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) })
|
||||||
|
|
||||||
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
assert_equal([[:get, @prm_url], [:get, @as_metadata_url], [:post, "#{@auth_base}/token"]], log)
|
||||||
|
|
||||||
|
# The SDK's defaults are applied before the customizer runs.
|
||||||
|
assert_requested(:get, @prm_url, headers: { "Accept" => "application/json" })
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_refuses_a_customized_connection_that_sends_a_request_off_the_origin
|
||||||
|
stub_request(:get, "https://other.example.com/prm.json").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(
|
||||||
|
http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") },
|
||||||
|
)
|
||||||
|
|
||||||
|
error = assert_raises(Flow::DestinationMismatchError) do
|
||||||
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal(<<~MESSAGE, error.message)
|
||||||
|
Request to \"#{@prm_url}\" would be sent to \"https://other.example.com/prm.json\", on a different origin; \
|
||||||
|
middleware that follows redirects or rewrites URLs is refused.
|
||||||
|
MESSAGE
|
||||||
|
|
||||||
|
# The guard sits before the adapter, so the request never leaves.
|
||||||
|
assert_not_requested(:get, "https://other.example.com/prm.json")
|
||||||
|
assert_not_requested(:post, "#{@auth_base}/token")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_keeps_a_same_origin_rewrite
|
||||||
|
moved_url = "https://srv.example.com/prm-moved.json"
|
||||||
|
stub_request(:get, moved_url).to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(
|
||||||
|
http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: moved_url) },
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
assert_requested(:get, moved_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_context
|
||||||
|
stub_request(:get, "https://other.example.com/prm.json").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(
|
||||||
|
http_client_customizer: ->(faraday) {
|
||||||
|
faraday.use(ContextReplacingMiddleware)
|
||||||
|
faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_raises(Flow::DestinationMismatchError) do
|
||||||
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_not_requested(:get, "https://other.example.com/prm.json")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_ahead_of_the_stamp
|
||||||
|
# `builder.insert(0, ...)` is where middleware that wants to be outermost puts itself, so a follower can
|
||||||
|
# land ahead of the SDK's stamp and re-enter the stack; the record of the first URL must survive that.
|
||||||
|
stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" })
|
||||||
|
stub_request(:get, "https://other.example.com/prm.json").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(
|
||||||
|
http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) },
|
||||||
|
)
|
||||||
|
|
||||||
|
error = assert_raises(Flow::DestinationMismatchError) do
|
||||||
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message)
|
||||||
|
assert_not_requested(:get, "https://other.example.com/prm.json")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_ahead_of_the_stamp
|
||||||
|
moved_url = "https://srv.example.com/prm-moved.json"
|
||||||
|
stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url })
|
||||||
|
stub_request(:get, moved_url).to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(
|
||||||
|
http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) },
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
assert_requested(:get, moved_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_refuses_a_request_whose_environment_was_rebuilt
|
||||||
|
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) })
|
||||||
|
|
||||||
|
error = assert_raises(Flow::DestinationMismatchError) do
|
||||||
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal(<<~MESSAGE, error.message)
|
||||||
|
Request to \"#{@prm_url}\" carries no record of the URL the flow asked for; \
|
||||||
|
middleware that rebuilds the request environment is refused.
|
||||||
|
MESSAGE
|
||||||
|
assert_not_requested(:get, @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_refuses_a_rewritten_token_request
|
||||||
|
stub_request(:post, "https://other.example.com/token").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(access_token: "stolen", token_type: "Bearer"),
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(
|
||||||
|
http_client_customizer: ->(faraday) {
|
||||||
|
faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_raises(Flow::DestinationMismatchError) do
|
||||||
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_not_requested(:post, "https://other.example.com/token")
|
||||||
|
assert_not_requested(:post, "#{@auth_base}/token")
|
||||||
|
assert_nil(provider.access_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_keeps_the_application_request_context_for_every_request
|
||||||
|
trace_ids = []
|
||||||
|
|
||||||
|
result = run_authorization_flow(
|
||||||
|
http_client_customizer: ->(faraday) {
|
||||||
|
faraday.options.context = { trace_id: "incident-123" }
|
||||||
|
faraday.use(ContextRecordingMiddleware, trace_ids)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
# Protected Resource Metadata and authorization server metadata (GET), registration (JSON POST),
|
||||||
|
# and the token exchange (form POST) all carry it.
|
||||||
|
assert_equal(["incident-123"] * 4, trace_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_keeps_the_application_request_context_on_a_factory_connection
|
||||||
|
trace_ids = []
|
||||||
|
factory = -> {
|
||||||
|
Faraday.new do |faraday|
|
||||||
|
faraday.options.context = { trace_id: "incident-123" }
|
||||||
|
faraday.use(ContextRecordingMiddleware, trace_ids)
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Flow.new(provider: client_credentials_provider, http_client_factory: factory).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
assert_equal(["incident-123"] * 3, trace_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_options
|
||||||
|
stub_request(:get, "https://other.example.com/prm.json").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(
|
||||||
|
http_client_customizer: ->(faraday) {
|
||||||
|
faraday.use(RequestOptionsReplacingMiddleware)
|
||||||
|
faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
error = assert_raises(Flow::DestinationMismatchError) do
|
||||||
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The mismatch, not a lost record: replacing the options must leave the record in place.
|
||||||
|
assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message)
|
||||||
|
assert_not_requested(:get, "https://other.example.com/prm.json")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_after_the_stamp
|
||||||
|
stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" })
|
||||||
|
stub_request(:get, "https://other.example.com/prm.json").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) })
|
||||||
|
|
||||||
|
error = assert_raises(Flow::DestinationMismatchError) do
|
||||||
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The mismatch, not a lost record: the duplicate the follower re-enters with shares the record.
|
||||||
|
assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message)
|
||||||
|
assert_not_requested(:get, "https://other.example.com/prm.json")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_after_the_stamp
|
||||||
|
moved_url = "https://srv.example.com/prm-moved.json"
|
||||||
|
stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url })
|
||||||
|
stub_request(:get, moved_url).to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) })
|
||||||
|
|
||||||
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
assert_requested(:get, moved_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_keeps_a_retry_by_middleware_placed_ahead_of_the_stamp
|
||||||
|
# The same environment re-enters the stack with the same URL, so the first record still matches.
|
||||||
|
stub_request(:get, @prm_url).to_return(
|
||||||
|
{ status: 503 },
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.builder.insert(0, RetryingMiddleware) })
|
||||||
|
|
||||||
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
assert_requested(:get, @prm_url, times: 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_http_client_records_each_request_on_its_own
|
||||||
|
# A caller using the SDK-built connection directly may address different origins from one request
|
||||||
|
# to the next; each request gets its own record.
|
||||||
|
stub_request(:get, "https://a.example.com/one").to_return(status: 200, body: "one")
|
||||||
|
stub_request(:get, "https://b.example.com/two").to_return(status: 200, body: "two")
|
||||||
|
connection = Flow.build_http_client
|
||||||
|
|
||||||
|
assert_equal("one", connection.get("https://a.example.com/one").body)
|
||||||
|
assert_equal("two", connection.get("https://b.example.com/two").body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_prefers_an_explicit_http_client_factory_over_the_customizer
|
||||||
|
provider = client_credentials_provider(http_client_customizer: ->(_faraday) { raise "the customizer must not run" })
|
||||||
|
|
||||||
|
result = Flow.new(provider: provider, http_client_factory: -> { Faraday.new }).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
end
|
||||||
|
|
||||||
def test_run_client_credentials_with_client_secret_post_sends_credentials_in_body
|
def test_run_client_credentials_with_client_secret_post_sends_credentials_in_body
|
||||||
# `client_secret_post` puts the credentials in the form body rather
|
# `client_secret_post` puts the credentials in the form body rather
|
||||||
# than an HTTP Basic header (RFC 6749 Section 2.3.1).
|
# than an HTTP Basic header (RFC 6749 Section 2.3.1).
|
||||||
|
|
@ -718,7 +1103,7 @@ module MCP
|
||||||
# the Dynamic Client Registration request body. The default loopback redirect URI
|
# the Dynamic Client Registration request body. The default loopback redirect URI
|
||||||
# exercises SEP-837's `"native"` inference; passing an HTTPS `redirect_uri` exercises
|
# exercises SEP-837's `"native"` inference; passing an HTTPS `redirect_uri` exercises
|
||||||
# the `"web"` inference.
|
# the `"web"` inference.
|
||||||
def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {})
|
def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {}, http_client_customizer: nil)
|
||||||
state_holder = {}
|
state_holder = {}
|
||||||
provider = Provider.new(
|
provider = Provider.new(
|
||||||
client_metadata: {
|
client_metadata: {
|
||||||
|
|
@ -730,6 +1115,7 @@ module MCP
|
||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
|
redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
|
||||||
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
||||||
|
http_client_customizer: http_client_customizer,
|
||||||
)
|
)
|
||||||
|
|
||||||
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,46 @@ module MCP
|
||||||
class Client
|
class Client
|
||||||
module OAuth
|
module OAuth
|
||||||
class HTTPOAuthTest < Minitest::Test
|
class HTTPOAuthTest < Minitest::Test
|
||||||
|
# Records `[method, url]` for every request that passes through, standing in for the tracing
|
||||||
|
# middleware an application adds through `http_client_customizer`.
|
||||||
|
class RecordingMiddleware
|
||||||
|
def initialize(app, log)
|
||||||
|
@app = app
|
||||||
|
@log = log
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
@log << [env.method, env.url.to_s]
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind.
|
||||||
|
class URLRewritingMiddleware
|
||||||
|
def initialize(app, from:, to:)
|
||||||
|
@app = app
|
||||||
|
@from = from
|
||||||
|
@to = to
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
env.url = URI(@to) if env.url.to_s == @from
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever
|
||||||
|
# an earlier middleware recorded on the original.
|
||||||
|
class EnvRebuildingMiddleware
|
||||||
|
def initialize(app)
|
||||||
|
@app = app
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
@app.call(Faraday::Env.from(env.to_h))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def setup
|
def setup
|
||||||
WebMock.enable!
|
WebMock.enable!
|
||||||
@mcp_url = "https://srv.example.com/mcp"
|
@mcp_url = "https://srv.example.com/mcp"
|
||||||
|
|
@ -101,6 +141,91 @@ module MCP
|
||||||
assert_equal("test-token-after-flow", provider.access_token)
|
assert_equal("test-token-after-flow", provider.access_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_send_request_runs_the_oauth_flow_through_the_provider_customizer
|
||||||
|
stub_request(:post, @mcp_url).with { |req|
|
||||||
|
req.headers["Authorization"].nil?
|
||||||
|
}.to_return(
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
|
||||||
|
},
|
||||||
|
body: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:post, @mcp_url).with(
|
||||||
|
headers: { "Authorization" => "Bearer test-token-after-flow" }
|
||||||
|
).to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:get, @prm_url).to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(
|
||||||
|
issuer: @auth_base,
|
||||||
|
authorization_endpoint: "#{@auth_base}/authorize",
|
||||||
|
token_endpoint: "#{@auth_base}/token",
|
||||||
|
registration_endpoint: "#{@auth_base}/register",
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
grant_types_supported: ["authorization_code"],
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
token_endpoint_auth_methods_supported: ["none"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:post, "#{@auth_base}/register").to_return(
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(client_id: "test-client"),
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:post, "#{@auth_base}/token").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(access_token: "test-token-after-flow", token_type: "Bearer", expires_in: 3600),
|
||||||
|
)
|
||||||
|
|
||||||
|
log = []
|
||||||
|
state_holder = {}
|
||||||
|
provider = Provider.new(
|
||||||
|
client_metadata: {
|
||||||
|
client_name: "ruby-sdk-test",
|
||||||
|
redirect_uris: ["http://localhost:0/callback"],
|
||||||
|
grant_types: ["authorization_code"],
|
||||||
|
response_types: ["code"],
|
||||||
|
token_endpoint_auth_method: "none",
|
||||||
|
},
|
||||||
|
redirect_uri: "http://localhost:0/callback",
|
||||||
|
redirect_handler: ->(url) {
|
||||||
|
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
|
||||||
|
},
|
||||||
|
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
||||||
|
http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) },
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
||||||
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
||||||
|
|
||||||
|
assert_equal({ "ok" => true }, response["result"])
|
||||||
|
assert_equal(
|
||||||
|
[
|
||||||
|
[:get, @prm_url],
|
||||||
|
[:get, "#{@auth_base}/.well-known/oauth-authorization-server"],
|
||||||
|
[:post, "#{@auth_base}/register"],
|
||||||
|
[:post, "#{@auth_base}/token"],
|
||||||
|
],
|
||||||
|
log,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def test_send_request_does_not_follow_a_resource_metadata_challenge_off_the_server_origin
|
def test_send_request_does_not_follow_a_resource_metadata_challenge_off_the_server_origin
|
||||||
# End to end over the transport, which is where the header is actually parsed:
|
# End to end over the transport, which is where the header is actually parsed:
|
||||||
# a server that answers 401 must not be able to name an unrelated host in
|
# a server that answers 401 must not be able to name an unrelated host in
|
||||||
|
|
@ -740,6 +865,94 @@ module MCP
|
||||||
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_send_request_surfaces_a_refused_refresh_without_falling_back
|
||||||
|
# A customizer middleware that would carry the refresh request off the origin is refused before
|
||||||
|
# the request leaves, and that refusal is not one of the failures that start the interactive flow
|
||||||
|
# or discard the stored tokens.
|
||||||
|
stub_request(:post, @mcp_url).to_return(
|
||||||
|
status: 401,
|
||||||
|
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
|
||||||
|
body: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:get, @prm_url).to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: JSON.generate(
|
||||||
|
issuer: @auth_base,
|
||||||
|
authorization_endpoint: "#{@auth_base}/authorize",
|
||||||
|
token_endpoint: "#{@auth_base}/token",
|
||||||
|
registration_endpoint: "#{@auth_base}/register",
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
token_endpoint_auth_methods_supported: ["none"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
||||||
|
stub_request(:post, "https://other.example.com/token").to_raise(StandardError.new("the rewritten request must not leave."))
|
||||||
|
|
||||||
|
provider = build_provider(
|
||||||
|
http_client_customizer: ->(faraday) {
|
||||||
|
faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
provider.save_client_information("client_id" => "test-client")
|
||||||
|
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt")
|
||||||
|
|
||||||
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
||||||
|
|
||||||
|
assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do
|
||||||
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_not_requested(:post, "https://other.example.com/token")
|
||||||
|
assert_not_requested(:post, "#{@auth_base}/token")
|
||||||
|
assert_not_requested(:post, "#{@auth_base}/register")
|
||||||
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_send_request_surfaces_a_refresh_whose_environment_was_rebuilt_without_falling_back
|
||||||
|
# The missing-record refusal takes the same route as the origin mismatch: out of `send_request`,
|
||||||
|
# with no interactive fallback and the stored tokens intact.
|
||||||
|
stub_request(:post, @mcp_url).to_return(
|
||||||
|
status: 401,
|
||||||
|
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
|
||||||
|
body: "",
|
||||||
|
)
|
||||||
|
stub_request(:get, @prm_url).to_raise(StandardError.new("the rebuilt request must not leave."))
|
||||||
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
||||||
|
|
||||||
|
interactive_flow_started = false
|
||||||
|
provider = Provider.new(
|
||||||
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
||||||
|
redirect_uri: "http://localhost:0/callback",
|
||||||
|
redirect_handler: ->(_url) { interactive_flow_started = true },
|
||||||
|
callback_handler: -> { ["code", "state"] },
|
||||||
|
http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) },
|
||||||
|
)
|
||||||
|
provider.save_client_information("client_id" => "test-client")
|
||||||
|
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt")
|
||||||
|
|
||||||
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
||||||
|
|
||||||
|
error = assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do
|
||||||
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_match(/carries no record of the URL the flow asked for/, error.message)
|
||||||
|
refute(interactive_flow_started, "the refusal must not fall back to the interactive flow")
|
||||||
|
assert_not_requested(:get, @prm_url)
|
||||||
|
assert_not_requested(:post, "#{@auth_base}/token")
|
||||||
|
assert_not_requested(:post, "#{@auth_base}/register")
|
||||||
|
assert_equal({ "access_token" => "stale-token", "refresh_token" => "saved-rt" }, provider.tokens)
|
||||||
|
end
|
||||||
|
|
||||||
def test_send_request_preserves_refresh_token_when_refresh_hits_a_transient_failure
|
def test_send_request_preserves_refresh_token_when_refresh_hits_a_transient_failure
|
||||||
# A 5xx (or any non-`invalid_grant`) from the token endpoint indicates
|
# A 5xx (or any non-`invalid_grant`) from the token endpoint indicates
|
||||||
# a transient AS outage, NOT that the refresh token is dead.
|
# a transient AS outage, NOT that the refresh token is dead.
|
||||||
|
|
@ -1718,12 +1931,13 @@ module MCP
|
||||||
provider
|
provider
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_provider
|
def build_provider(http_client_customizer: nil)
|
||||||
Provider.new(
|
Provider.new(
|
||||||
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
||||||
redirect_uri: "http://localhost:0/callback",
|
redirect_uri: "http://localhost:0/callback",
|
||||||
redirect_handler: ->(_url) {},
|
redirect_handler: ->(_url) {},
|
||||||
callback_handler: -> { ["code", "state"] },
|
callback_handler: -> { ["code", "state"] },
|
||||||
|
http_client_customizer: http_client_customizer,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,26 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_initialize_defaults_http_client_customizer_to_nil
|
||||||
|
assert_nil(Provider.new(**args_for("https://app.example.com/callback")).http_client_customizer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_keeps_the_http_client_customizer
|
||||||
|
customizer = ->(_faraday) {}
|
||||||
|
provider = Provider.new(**args_for("https://app.example.com/callback"), http_client_customizer: customizer)
|
||||||
|
|
||||||
|
assert_same(customizer, provider.http_client_customizer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_rejects_a_non_callable_http_client_customizer
|
||||||
|
# A connection object in place of a callable is the likely mistake.
|
||||||
|
error = assert_raises(ArgumentError) do
|
||||||
|
Provider.new(**args_for("https://app.example.com/callback"), http_client_customizer: Object.new)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal("http_client_customizer must respond to call (got Object).", error.message)
|
||||||
|
end
|
||||||
|
|
||||||
def test_initialize_rejects_non_loopback_http_redirect_uri
|
def test_initialize_rejects_non_loopback_http_redirect_uri
|
||||||
# Communication Security: a non-loopback `http://` redirect URI would
|
# Communication Security: a non-loopback `http://` redirect URI would
|
||||||
# let an attacker steal the authorization code from a network sniffer,
|
# let an attacker steal the authorization code from a network sniffer,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue