ruby-sdk/test/mcp/client/oauth/id_jag_token_exchange_test.rb
Koichi ITO 02db089038 Bound OAuth response bodies in the client
## 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.
2026-08-16 03:29:15 +09:00

118 lines
4.3 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
require "webmock/minitest"
require "faraday"
require "mcp/client/oauth"
module MCP
class Client
module OAuth
class IDJAGTokenExchangeTest < Minitest::Test
def setup
WebMock.enable!
@token_endpoint = "https://idp.example.com/token"
end
def teardown
WebMock.reset!
end
def request_exchange
IDJAGTokenExchange.request(
token_endpoint: @token_endpoint,
id_token: "idp-id-token",
client_id: "idp-client",
audience: "https://auth.example.com",
resource: "https://srv.example.com/mcp",
)
end
def test_request_sends_rfc8693_token_exchange_parameters
stub_request(:post, @token_endpoint).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
access_token: "id-jag-assertion",
issued_token_type: "urn:ietf:params:oauth:token-type:id-jag",
token_type: "N_A",
),
)
assertion = request_exchange
assert_equal("id-jag-assertion", assertion)
assert_requested(:post, @token_endpoint) do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange" &&
form["subject_token"] == "idp-id-token" &&
form["subject_token_type"] == "urn:ietf:params:oauth:token-type:id_token" &&
form["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag" &&
form["audience"] == "https://auth.example.com" &&
form["resource"] == "https://srv.example.com/mcp" &&
form["client_id"] == "idp-client"
end
end
def test_request_rejects_response_without_id_jag_token_type
# A plain access token is not an ID-JAG; presenting it as a
# jwt-bearer assertion would fail confusingly downstream.
stub_request(:post, @token_endpoint).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
access_token: "ordinary-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
),
)
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/did not issue an ID-JAG/, error.message)
end
def test_request_rejects_missing_access_token
stub_request(:post, @token_endpoint).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(issued_token_type: "urn:ietf:params:oauth:token-type:id-jag"),
)
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/missing `access_token`/, error.message)
end
def test_request_raises_on_non_2xx_response
stub_request(:post, @token_endpoint).to_return(status: 400, body: JSON.generate(error: "invalid_grant"))
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/status 400/, error.message)
end
def test_request_raises_on_unparseable_response
stub_request(:post, @token_endpoint).to_return(status: 200, body: "not json")
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/Failed to parse/, error.message)
end
def test_request_raises_on_non_object_json_response
stub_request(:post, @token_endpoint).to_return(status: 200, body: "[]")
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/not a JSON object/, error.message)
end
def test_request_refuses_a_response_body_over_the_cap
stub_request(:post, @token_endpoint).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: "a" * (4 * 1024 * 1024 + 1),
)
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/exceeds \d+ bytes/, error.message)
end
end
end
end
end