## Motivation and Context `MCP::Client::HTTP` caps a single message from the server at 4 MiB, but the OAuth flow talks to authorization endpoints over its own Faraday connection, and that one had no cap. Discovery, dynamic client registration, and token responses were buffered in full and handed to `JSON.parse`, so a server that answered an OAuth request with an endless body grew the client's memory for as long as it kept sending. `Flow` and `IDJAGTokenExchange` each build their own connection, so both needed the bound. The new `BoundedBody` counts bytes in a Faraday `on_data` callback and refuses the response once it passes `MAX_RESPONSE_BYTES`, which matches the 4 MiB of `MCP::Client::HTTP::MAX_MESSAGE_BYTES` and `MCP::Client::Stdio::MAX_LINE_BYTES`. It hands back the status paired with the bounded body rather than the Faraday response, so no later caller can reach the unbounded `response.body`. Those bytes are counted after decompression. The default Net::HTTP adapter negotiates `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. That holds only while the connection leaves `Accept-Encoding` alone, which is now recorded in a comment on both `default_http_client` definitions. An over-cap response is refused rather than truncated: a partial discovery or token document cannot be validated. Protected resource metadata is the one place where refusing is not fatal, since any PRM discovery failure already selects the legacy authorization path by design. ## How Has This Been Tested? New unit tests in `test/mcp/client/oauth/bounded_body_test.rb` cover the streaming path, the at-limit boundary, and the fallback for adapters that ignore `on_data`. One of them serves a gzip body over a real socket rather than through WebMock, which returns a stubbed body verbatim and never runs the inflater; it fails if the cap ever starts measuring compressed bytes. New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/id_jag_token_exchange_test.rb` drive each of the four request wrappers end to end with an over-cap body; all five fail without the cap in place. ## Breaking Changes An OAuth endpoint response larger than 4 MiB is now rejected instead of parsed.
112 lines
4.7 KiB
Ruby
112 lines
4.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require "uri"
|
|
|
|
module MCP
|
|
class Client
|
|
module OAuth
|
|
# RFC 8693 token exchange against an enterprise identity provider, turning an IdP-issued ID token into
|
|
# an Identity Assertion Authorization Grant (ID-JAG) per the MCP Enterprise Managed Authorization extension (SEP-990).
|
|
# The returned ID-JAG is an opaque assertion the client then presents to the MCP authorization server with
|
|
# the RFC 7523 `jwt-bearer` grant (see `CrossAppAccessProvider`).
|
|
# Mirrors `requestJwtAuthorizationGrant` in the TypeScript SDK.
|
|
#
|
|
# - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
|
# - https://www.rfc-editor.org/rfc/rfc8693
|
|
module IDJAGTokenExchange
|
|
# Raised when the identity provider's token exchange fails or returns something other than an ID-JAG.
|
|
class ExchangeError < StandardError; end
|
|
|
|
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
|
|
ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"
|
|
|
|
class << self
|
|
# Exchanges `id_token` for an ID-JAG at the IdP's token endpoint and returns the assertion string.
|
|
#
|
|
# @param token_endpoint [String] The identity provider's token endpoint.
|
|
# @param id_token [String] The IdP-issued ID token (the subject token).
|
|
# @param client_id [String] The client's identifier at the IdP.
|
|
# @param audience [String] The MCP authorization server's issuer identifier.
|
|
# @param resource [String] The canonical MCP server URL (RFC 8707).
|
|
# @param http_client [Object, nil] Faraday-compatible client; built lazily by default.
|
|
def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
|
|
http_client ||= default_http_client
|
|
|
|
bounded = BoundedBody.new
|
|
|
|
response = begin
|
|
raw_response = http_client.post(token_endpoint) do |req|
|
|
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
req.headers["Accept"] = "application/json"
|
|
req.options.on_data = bounded.on_data
|
|
req.body = URI.encode_www_form(
|
|
"grant_type" => GRANT_TYPE,
|
|
"subject_token" => id_token,
|
|
"subject_token_type" => ID_TOKEN_TYPE,
|
|
"requested_token_type" => ID_JAG_TOKEN_TYPE,
|
|
"audience" => audience,
|
|
"resource" => resource,
|
|
"client_id" => client_id,
|
|
)
|
|
end
|
|
|
|
bounded.response_for(raw_response)
|
|
rescue BoundedBody::TooLargeError => e
|
|
raise ExchangeError, "#{e.message}."
|
|
rescue Faraday::Error => e
|
|
raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
|
|
end
|
|
|
|
if response.status < 200 || response.status >= 300
|
|
raise ExchangeError, "Identity provider token exchange returned status #{response.status}."
|
|
end
|
|
|
|
parse_id_jag(response)
|
|
end
|
|
|
|
private
|
|
|
|
def parse_id_jag(response)
|
|
body = response.body.is_a?(String) ? response.body : response.body.to_s
|
|
parsed = begin
|
|
JSON.parse(body)
|
|
rescue JSON::ParserError => e
|
|
raise ExchangeError, "Failed to parse token exchange response: #{e.message}."
|
|
end
|
|
|
|
unless parsed.is_a?(Hash)
|
|
raise ExchangeError, "Token exchange response is not a JSON object (got #{parsed.class})."
|
|
end
|
|
|
|
issued_token_type = parsed["issued_token_type"]
|
|
unless issued_token_type == ID_JAG_TOKEN_TYPE
|
|
raise ExchangeError,
|
|
"Token exchange did not issue an ID-JAG " \
|
|
"(expected issued_token_type #{ID_JAG_TOKEN_TYPE.inspect}, got #{issued_token_type.inspect})."
|
|
end
|
|
|
|
assertion = parsed["access_token"]
|
|
if assertion.nil? || assertion.to_s.empty?
|
|
raise ExchangeError, "Token exchange response is missing `access_token`."
|
|
end
|
|
|
|
assertion
|
|
end
|
|
|
|
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`:
|
|
# claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
|
|
# onto compressed bytes.
|
|
def default_http_client
|
|
require "faraday"
|
|
|
|
Faraday.new do |faraday|
|
|
faraday.headers["Accept"] = "application/json"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|