1946 lines
86 KiB
Ruby
1946 lines
86 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
require "json"
|
|
require "webmock/minitest"
|
|
require "faraday"
|
|
require "mcp/client/http"
|
|
require "mcp/client/oauth"
|
|
|
|
module MCP
|
|
class Client
|
|
module OAuth
|
|
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
|
|
WebMock.enable!
|
|
@mcp_url = "https://srv.example.com/mcp"
|
|
@prm_url = "https://srv.example.com/.well-known/oauth-protected-resource/mcp"
|
|
@auth_base = "https://auth.example.com"
|
|
end
|
|
|
|
def teardown
|
|
WebMock.reset!
|
|
end
|
|
|
|
def test_send_request_runs_oauth_flow_on_401_and_retries_with_bearer_token
|
|
# First POST: 401 with WWW-Authenticate. Second POST (with Bearer): 200.
|
|
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),
|
|
)
|
|
|
|
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]] },
|
|
)
|
|
|
|
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("test-token-after-flow", provider.access_token)
|
|
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
|
|
# 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
|
|
# `resource_metadata` and have the client fetch it. The request never leaving
|
|
# is the point; the 401 surfacing to the caller is secondary.
|
|
metadata_url = "https://169.254.169.254/latest/meta-data/iam/"
|
|
|
|
stub_request(:post, @mcp_url).to_return(
|
|
status: 401,
|
|
headers: {
|
|
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{metadata_url}"),
|
|
},
|
|
body: "",
|
|
)
|
|
stub_request(:get, metadata_url).to_return(status: 200, body: "{}")
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
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) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
|
|
assert_raises(StandardError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
|
|
assert_not_requested(:get, metadata_url)
|
|
end
|
|
|
|
def test_send_request_runs_step_up_flow_on_403_insufficient_scope
|
|
# First call with the initial token: 403 carrying an `insufficient_scope`
|
|
# Bearer challenge with the escalated scope. The transport must re-run
|
|
# a full authorization (NOT a refresh) with the escalated scope and
|
|
# retry the original request.
|
|
stub_request(:post, @mcp_url).with(
|
|
headers: { "Authorization" => "Bearer initial-token" },
|
|
).to_return(
|
|
status: 403,
|
|
headers: {
|
|
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
|
|
%(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"),
|
|
},
|
|
body: "",
|
|
)
|
|
|
|
stub_request(:post, @mcp_url).with(
|
|
headers: { "Authorization" => "Bearer escalated-token" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
stub_step_up_authorization_server
|
|
provider = build_step_up_provider
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
response = transport.send_request(
|
|
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: { name: "x" } },
|
|
)
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
assert_equal("escalated-token", provider.access_token)
|
|
|
|
# The fresh authorization-code grant ran (refresh path was bypassed).
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
form["grant_type"] == "authorization_code" &&
|
|
form["code"] == "test-auth-code"
|
|
end
|
|
|
|
# And the authorization request the SDK handed to the redirect
|
|
# handler carried the escalated scope from the challenge.
|
|
authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h
|
|
assert_equal("mcp:basic mcp:write", authorization_query["scope"])
|
|
end
|
|
|
|
def test_send_request_step_up_unions_existing_scope_with_challenge_scope
|
|
# MCP step-up: the re-authorization request must ask for the union
|
|
# of the previously granted scope and the newly demanded scope.
|
|
# Otherwise the new token would lack permissions the caller had
|
|
# before, triggering another step-up the next time they are used.
|
|
stub_request(:post, @mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer initial-token" })
|
|
.to_return(
|
|
status: 403,
|
|
headers: {
|
|
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
|
|
%(scope="mcp:write", resource_metadata="#{@prm_url}"),
|
|
},
|
|
body: "",
|
|
)
|
|
|
|
stub_request(:post, @mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer escalated-token" })
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
stub_step_up_authorization_server
|
|
provider = build_step_up_provider
|
|
# The provider already holds a token granted for `mcp:read`.
|
|
provider.save_tokens(
|
|
"access_token" => "initial-token",
|
|
"token_type" => "Bearer",
|
|
"scope" => "mcp:read",
|
|
)
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
transport.send_request(
|
|
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
|
|
)
|
|
|
|
authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h
|
|
requested = authorization_query["scope"].split
|
|
assert_includes(requested, "mcp:read", "must preserve previously granted scope")
|
|
assert_includes(requested, "mcp:write", "must add the newly demanded scope")
|
|
assert_equal(requested.uniq.length, requested.length, "must not duplicate scopes")
|
|
end
|
|
|
|
def test_send_request_does_not_retry_plain_403
|
|
# A 403 without an `insufficient_scope` Bearer challenge is a hard
|
|
# forbidden, not a step-up signal. Surface it as a `RequestHandlerError`
|
|
# rather than tripping the OAuth flow.
|
|
stub_request(:post, @mcp_url).with(
|
|
headers: { "Authorization" => "Bearer initial-token" },
|
|
).to_return(
|
|
status: 403, body: "",
|
|
)
|
|
|
|
provider = build_step_up_provider
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
|
|
error = assert_raises(MCP::Client::RequestHandlerError) do
|
|
transport.send_request(
|
|
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
|
|
)
|
|
end
|
|
assert_equal(:forbidden, error.error_type)
|
|
assert_not_requested(:get, "#{@auth_base}/.well-known/oauth-authorization-server")
|
|
end
|
|
|
|
def test_send_request_does_not_loop_on_repeated_insufficient_scope
|
|
# The server keeps demanding more scope after every authorization.
|
|
# The transport retries the step-up flow at most once per
|
|
# `send_request`, then surfaces the 403 to the caller.
|
|
stub_request(:post, @mcp_url).to_return(
|
|
status: 403,
|
|
headers: {
|
|
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
|
|
%(scope="mcp:everything", resource_metadata="#{@prm_url}"),
|
|
},
|
|
body: "",
|
|
)
|
|
|
|
stub_step_up_authorization_server
|
|
provider = build_step_up_provider
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
|
|
error = assert_raises(MCP::Client::RequestHandlerError) do
|
|
transport.send_request(
|
|
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
|
|
)
|
|
end
|
|
assert_equal(:forbidden, error.error_type)
|
|
end
|
|
|
|
def test_send_request_step_up_bypasses_refresh_token
|
|
# The provider has a refresh_token, but step-up MUST run a fresh
|
|
# authorization request with the escalated scope, not exchange the
|
|
# refresh_token. Refreshing would mint a new access token with the
|
|
# same scopes as the current one, which already failed.
|
|
stub_request(:post, @mcp_url).with(
|
|
headers: { "Authorization" => "Bearer initial-token" },
|
|
).to_return(
|
|
status: 403,
|
|
headers: {
|
|
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
|
|
%(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"),
|
|
},
|
|
body: "",
|
|
)
|
|
|
|
stub_request(:post, @mcp_url).with(
|
|
headers: { "Authorization" => "Bearer escalated-token" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
stub_step_up_authorization_server
|
|
provider = build_step_up_provider
|
|
provider.save_tokens(
|
|
"access_token" => "initial-token",
|
|
"refresh_token" => "saved-rt",
|
|
"token_type" => "Bearer",
|
|
)
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
transport.send_request(
|
|
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
|
|
)
|
|
|
|
# The token endpoint was only called for the fresh
|
|
# `authorization_code` grant, never for `refresh_token`.
|
|
assert_not_requested(:post, "#{@auth_base}/token") do |req|
|
|
URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token"
|
|
end
|
|
end
|
|
|
|
def test_send_request_step_up_appends_offline_access_when_advertised
|
|
# Integration of step-up with the SEP-2207 offline_access logic:
|
|
# the re-authorization request unions the existing and challenge scopes,
|
|
# then -- because the AS advertises `offline_access` and the client
|
|
# declared the `refresh_token` grant -- `offline_access` is appended
|
|
# so the escalated token can itself be refreshed later.
|
|
stub_request(:post, @mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer initial-token" })
|
|
.to_return(
|
|
status: 403,
|
|
headers: {
|
|
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
|
|
%(scope="mcp:write", resource_metadata="#{@prm_url}"),
|
|
},
|
|
body: "",
|
|
)
|
|
|
|
stub_request(:post, @mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer escalated-token" })
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
stub_step_up_authorization_server(
|
|
extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write", "offline_access"] },
|
|
)
|
|
provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"])
|
|
provider.save_tokens(
|
|
"access_token" => "initial-token",
|
|
"token_type" => "Bearer",
|
|
"scope" => "mcp:read",
|
|
)
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
transport.send_request(
|
|
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
|
|
)
|
|
|
|
requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split
|
|
assert_includes(requested, "mcp:read", "must preserve previously granted scope")
|
|
assert_includes(requested, "mcp:write", "must add the challenged scope")
|
|
assert_includes(requested, "offline_access", "must append offline_access when advertised")
|
|
end
|
|
|
|
def test_send_request_step_up_strips_offline_access_when_not_advertised
|
|
# Mirror of the previous test: if the AS does NOT advertise
|
|
# `offline_access`, the step-up request must not carry it even when
|
|
# the existing token's scope happened to include it
|
|
# (SEP-2207 MUST-NOT, enforced by `normalize_offline_access_scope`).
|
|
stub_request(:post, @mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer initial-token" })
|
|
.to_return(
|
|
status: 403,
|
|
headers: {
|
|
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
|
|
%(scope="mcp:write", resource_metadata="#{@prm_url}"),
|
|
},
|
|
body: "",
|
|
)
|
|
|
|
stub_request(:post, @mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer escalated-token" })
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
# AS advertises only resource scopes, no offline_access.
|
|
stub_step_up_authorization_server(
|
|
extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write"] },
|
|
)
|
|
provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"])
|
|
provider.save_tokens(
|
|
"access_token" => "initial-token",
|
|
"token_type" => "Bearer",
|
|
"scope" => "mcp:read offline_access",
|
|
)
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
transport.send_request(
|
|
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
|
|
)
|
|
|
|
requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split
|
|
assert_includes(requested, "mcp:read")
|
|
assert_includes(requested, "mcp:write")
|
|
refute_includes(requested, "offline_access", "must strip offline_access when the AS does not advertise it")
|
|
end
|
|
|
|
def test_send_request_step_up_uses_cimd_client_id
|
|
# Integration of step-up with Client ID Metadata Documents:
|
|
# when the provider is CIMD-configured and the AS advertises CIMD support,
|
|
# the step-up re-authorization uses the CIMD URL as `client_id` and skips
|
|
# Dynamic Client Registration, just like the initial flow.
|
|
cimd_url = "https://app.example.com/client-metadata.json"
|
|
|
|
stub_request(:post, @mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer initial-token" })
|
|
.to_return(
|
|
status: 403,
|
|
headers: {
|
|
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
|
|
%(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"),
|
|
},
|
|
body: "",
|
|
)
|
|
|
|
stub_request(:post, @mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer escalated-token" })
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
stub_step_up_authorization_server(
|
|
extra_as_metadata: { client_id_metadata_document_supported: true },
|
|
)
|
|
provider = build_step_up_provider(client_id_metadata_document_url: cimd_url)
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
transport.send_request(
|
|
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
|
|
)
|
|
|
|
# DCR was skipped and the CIMD URL was used as the client_id on
|
|
# the escalated authorization request.
|
|
assert_not_requested(:post, "#{@auth_base}/register")
|
|
authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h
|
|
assert_equal(cimd_url, authorization_query["client_id"])
|
|
end
|
|
|
|
def test_initialize_rejects_non_secure_mcp_url_when_oauth_is_set
|
|
# A bearer token sent to `http://attacker.example.com/mcp` would leak
|
|
# over the wire. When OAuth is on, the transport URL must clear
|
|
# the same Communication Security bar as every other OAuth-related URL.
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
assert_raises(MCP::Client::HTTP::InsecureURLError) do
|
|
MCP::Client::HTTP.new(url: "http://attacker.example.com/mcp", oauth: provider)
|
|
end
|
|
end
|
|
|
|
def test_initialize_allows_loopback_http_mcp_url_when_oauth_is_set
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
# Loopback HTTP is permitted for local development / conformance.
|
|
MCP::Client::HTTP.new(url: "http://localhost:9292/mcp", oauth: provider)
|
|
MCP::Client::HTTP.new(url: "http://127.0.0.1:9292/mcp", oauth: provider)
|
|
end
|
|
|
|
def test_initialize_allows_non_secure_mcp_url_when_oauth_is_nil
|
|
# Without OAuth, no bearer tokens are sent, so the SDK leaves
|
|
# the scheme decision to the caller.
|
|
MCP::Client::HTTP.new(url: "http://internal.example.com/mcp")
|
|
end
|
|
|
|
def test_refresh_path_preserves_query_string_in_resource_claim
|
|
# The refresh flow MUST send the same canonical `resource` claim as
|
|
# the original authorization request -- otherwise the AS rejects
|
|
# the refresh as a different audience. Verify by giving the transport
|
|
# a query-bearing MCP URL and a saved refresh token, then asserting
|
|
# the refresh POST body carries the full URL.
|
|
tenant_mcp_url = "https://srv.example.com/mcp?tenant=1"
|
|
|
|
stub_request(:post, tenant_mcp_url)
|
|
.with { |req| req.headers["Authorization"] != "Bearer refreshed-tenant-token" }
|
|
.to_return(
|
|
status: 401,
|
|
headers: {
|
|
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
|
|
},
|
|
body: "",
|
|
)
|
|
|
|
stub_request(:post, tenant_mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer refreshed-tenant-token" })
|
|
.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(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}/token")
|
|
.with(body: hash_including("grant_type" => "refresh_token"))
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "refreshed-tenant-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale", "refresh_token" => "valid-rt")
|
|
|
|
transport = HTTP.new(url: tenant_mcp_url, oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
|
|
# The refresh POST body's `resource` MUST keep the query string so it
|
|
# matches the audience the original token was minted for.
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
form["grant_type"] == "refresh_token" &&
|
|
form["resource"] == "https://srv.example.com/mcp?tenant=1"
|
|
end
|
|
end
|
|
|
|
def test_send_request_refreshes_when_refresh_token_is_available
|
|
# On 401 with a saved refresh_token, the transport should swap it for
|
|
# a fresh access token rather than re-running the full interactive
|
|
# Authorization Code flow.
|
|
stub_request(:post, @mcp_url)
|
|
.with { |req| req.headers["Authorization"] != "Bearer refreshed-token" }
|
|
.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 refreshed-token" })
|
|
.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",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
# DCR and /authorize must not be invoked when refresh succeeds.
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
stub_request(:get, "#{@auth_base}/authorize").to_raise(StandardError.new("authorize should not be called."))
|
|
|
|
stub_request(:post, "#{@auth_base}/token")
|
|
.with(body: hash_including("grant_type" => "refresh_token"))
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
provider = build_provider
|
|
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)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
assert_equal("refreshed-token", provider.access_token)
|
|
end
|
|
|
|
def test_send_request_refreshes_for_a_cross_app_access_provider_holding_a_refresh_token
|
|
# Refresh serves every provider, not only the authorization-code one that has a CIMD URL reader.
|
|
stub_request(:post, @mcp_url).with { |req|
|
|
req.headers["Authorization"] != "Bearer refreshed-token"
|
|
}.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 refreshed-token" }
|
|
).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,
|
|
token_endpoint: "#{@auth_base}/token",
|
|
grant_types_supported: ["urn:ietf:params:oauth:grant-type:jwt-bearer", "refresh_token"],
|
|
token_endpoint_auth_methods_supported: ["client_secret_basic"],
|
|
),
|
|
)
|
|
|
|
stub_request(:post, "#{@auth_base}/token").with(
|
|
body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt")
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
assertion_calls = 0
|
|
provider = CrossAppAccessProvider.new(
|
|
client_id: "xaa-client",
|
|
client_secret: "xaa-secret",
|
|
assertion_provider: ->(**) {
|
|
assertion_calls += 1
|
|
"id-jag-assertion"
|
|
},
|
|
)
|
|
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt", "issuer" => @auth_base)
|
|
|
|
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("refreshed-token", provider.access_token)
|
|
assert_equal(0, assertion_calls)
|
|
end
|
|
|
|
def test_send_request_surfaces_a_bad_token_request_params_hook_instead_of_reauthorizing
|
|
# A provider whose `token_request_params` names a reserved key is misconfigured, not unauthorized:
|
|
# the refresh attempt must raise rather than fall through to the interactive flow,
|
|
# which would fail the same way after the user signed in.
|
|
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",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
redirected = false
|
|
provider_class = Class.new(Provider) do
|
|
define_method(:token_request_params) { { "grant_type" => "password" } }
|
|
end
|
|
provider = provider_class.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) { redirected = true },
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
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(Flow::InvalidTokenRequestParamsError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
refute(redirected)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
|
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
|
|
# A 5xx (or any non-`invalid_grant`) from the token endpoint indicates
|
|
# a transient AS outage, NOT that the refresh token is dead.
|
|
# The transport must fall through to the full Authorization Code flow
|
|
# (we cannot serve the MCP request without a fresh access token), but
|
|
# the stored refresh_token must remain so subsequent attempts on
|
|
# the next outage-free request can succeed without interactive reauth.
|
|
stub_request(:post, @mcp_url)
|
|
.with { |req| req.headers["Authorization"] != "Bearer brand-new-token" }
|
|
.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 brand-new-token" })
|
|
.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",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
# Refresh request: transient 503. Auth code exchange: succeeds with
|
|
# a NEW refresh token (so we can prove the live RT survived rather than
|
|
# being silently overwritten by the success path).
|
|
stub_request(:post, "#{@auth_base}/token")
|
|
.with(body: hash_including("grant_type" => "refresh_token"))
|
|
.to_return(status: 503, body: "")
|
|
stub_request(:post, "#{@auth_base}/token")
|
|
.with(body: hash_including("grant_type" => "authorization_code"))
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "brand-new-token", token_type: "Bearer"),
|
|
)
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
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]] },
|
|
)
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale", "refresh_token" => "live-rt")
|
|
|
|
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"])
|
|
# The transient 503 must not wipe the refresh token; the auth-code
|
|
# success path overwrites tokens, but we asserted the prior refresh
|
|
# token survived the refresh-attempt failure by NOT seeing the full
|
|
# flow trigger `clear_tokens!`. To prove this, inspect that
|
|
# the final stored refresh_token is whatever the auth-code response
|
|
# provided (omitted here), not nil from a forced clear.
|
|
stored = provider.tokens || {}
|
|
assert_equal("brand-new-token", stored["access_token"] || stored[:access_token])
|
|
end
|
|
|
|
def test_send_request_falls_back_to_full_flow_when_refresh_fails
|
|
# If the refresh request itself is rejected (refresh token revoked),
|
|
# the transport should clear stale tokens and fall through to
|
|
# the full Authorization Code flow.
|
|
stub_request(:post, @mcp_url)
|
|
.with { |req| req.headers["Authorization"] != "Bearer brand-new-token" }
|
|
.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 brand-new-token" })
|
|
.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",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
# First /token call (refresh) fails; second call (authorization_code) succeeds.
|
|
stub_request(:post, "#{@auth_base}/token")
|
|
.with(body: hash_including("grant_type" => "refresh_token"))
|
|
.to_return(status: 401, body: "")
|
|
stub_request(:post, "#{@auth_base}/token")
|
|
.with(body: hash_including("grant_type" => "authorization_code"))
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "brand-new-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
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]] },
|
|
)
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale", "refresh_token" => "revoked-rt")
|
|
|
|
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("brand-new-token", provider.access_token)
|
|
end
|
|
|
|
def test_oauth_flow_preserves_query_string_in_resource_claim
|
|
# Query parameters on the MCP URL (e.g. `?tenant=...`) must be carried
|
|
# into the RFC 8707 `resource` claim sent on the authorization and
|
|
# token requests, matching how the TS and Python SDKs build
|
|
# the canonical resource URL. The URL guard's snapshot is allowed to drop
|
|
# the query because Faraday does the same at request time, but
|
|
# the OAuth-side snapshot must retain it.
|
|
tenant_mcp_url = "https://srv.example.com/mcp?tenant=1"
|
|
|
|
stub_request(:post, tenant_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, tenant_mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer tenant-token" })
|
|
.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(
|
|
# PRM is allowed to omit `resource`; the client falls back to its
|
|
# canonical MCP URL (query and all).
|
|
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_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: "tenant-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
captured_authorization_url = nil
|
|
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) { captured_authorization_url = url },
|
|
callback_handler: -> {
|
|
query = URI.decode_www_form(captured_authorization_url.query).to_h
|
|
["test-auth-code", query.fetch("state")]
|
|
},
|
|
)
|
|
|
|
transport = HTTP.new(url: tenant_mcp_url, oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
|
|
# The authorization URL's `resource` parameter MUST keep the query.
|
|
query = URI.decode_www_form(captured_authorization_url.query).to_h
|
|
assert_equal("https://srv.example.com/mcp?tenant=1", query["resource"])
|
|
|
|
# The token endpoint POST body's `resource` MUST also keep the query.
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp?tenant=1"
|
|
end
|
|
end
|
|
|
|
def test_oauth_flow_uses_validated_url_after_url_mutation
|
|
# Even if an attacker mutates `@url` after construction (e.g.
|
|
# `instance_variable_set(:@url, "https://attacker.example.com/mcp")`),
|
|
# the OAuth discovery / authorization code flow MUST continue to use
|
|
# the URL snapshotted at initialize time. Otherwise PRM, AS metadata,
|
|
# and the `resource` claim sent to the authorization server would
|
|
# all point at the attacker-controlled host.
|
|
attacker_prm = "https://attacker.example.com/.well-known/oauth-protected-resource/mcp"
|
|
attacker_root_prm = "https://attacker.example.com/.well-known/oauth-protected-resource"
|
|
stub_request(:get, attacker_prm).to_raise(StandardError.new("PRM must not target attacker host."))
|
|
stub_request(:get, attacker_root_prm).to_raise(StandardError.new("PRM must not target attacker host."))
|
|
|
|
stub_request(:post, @mcp_url)
|
|
.with { |req| req.headers["Authorization"] != "Bearer post-mutation-token" }
|
|
.to_return(
|
|
status: 401,
|
|
# No `resource_metadata` to force PRM derivation from
|
|
# the server_url -- this is exactly the path where a mutated
|
|
# `@url` could redirect discovery without the snapshot.
|
|
headers: { "WWW-Authenticate" => 'Bearer error="invalid_token"' },
|
|
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_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: "post-mutation-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
captured_authorization_url = nil
|
|
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) { captured_authorization_url = url },
|
|
callback_handler: -> {
|
|
query = URI.decode_www_form(captured_authorization_url.query).to_h
|
|
["test-auth-code", query.fetch("state")]
|
|
},
|
|
)
|
|
|
|
stub_request(:post, @mcp_url)
|
|
.with(headers: { "Authorization" => "Bearer post-mutation-token" })
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
# Force the lazy Faraday connection to be built against the validated
|
|
# URL before the mutation. After this, `@client.url_prefix` is
|
|
# locked to `srv.example.com` regardless of what `@url` later points
|
|
# at, which is what the URL guard middleware reads.
|
|
transport.send(:client)
|
|
transport.instance_variable_set(:@url, "https://attacker.example.com/mcp")
|
|
|
|
# The first 401 must drive `run_oauth_flow!` against the validated
|
|
# URL, not the mutated `@url`. The OAuth flow succeeds, the retry
|
|
# carries the new bearer token, and Faraday (still pointed at
|
|
# the safe URL via its frozen `url_prefix`) returns the stubbed 200.
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
# Discovery must have aimed at the validated host. The attacker-host
|
|
# PRM stubs would have raised otherwise.
|
|
assert_requested(:get, @prm_url)
|
|
refute_requested(:get, attacker_prm)
|
|
refute_requested(:get, attacker_root_prm)
|
|
# The `resource` claim sent on the authorization URL is built from
|
|
# the validated URL, not the attacker-host mutation.
|
|
query = URI.decode_www_form(captured_authorization_url.query).to_h
|
|
assert_equal("https://srv.example.com/mcp", query["resource"])
|
|
refute_includes(query["resource"], "attacker.example.com")
|
|
end
|
|
|
|
def test_send_request_rejects_url_mutation_after_initialize
|
|
# The constructor secure-URL check alone is not enough: `@url` is
|
|
# a plain instance variable, so a caller can `instance_variable_set` it
|
|
# to a non-secure URL after the transport is built. The send-time
|
|
# re-check must catch this and refuse to send the bearer token.
|
|
provider = build_provider
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider)
|
|
transport.instance_variable_set(:@url, "http://attacker.example.com/mcp")
|
|
|
|
assert_raises(MCP::Client::HTTP::InsecureURLError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
end
|
|
|
|
def test_send_request_rejects_faraday_customizer_url_swap
|
|
# An `&block` Faraday customizer can rewrite `url_prefix` to point at
|
|
# a plaintext attacker host; the constructor URL check would miss this.
|
|
# The send-time effective-URL check must reject it.
|
|
provider = build_provider
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday|
|
|
faraday.url_prefix = "http://attacker.example.com/mcp"
|
|
end
|
|
|
|
assert_raises(MCP::Client::HTTP::InsecureURLError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
end
|
|
|
|
def test_send_request_rejects_https_to_different_host_via_customizer
|
|
# An `https://` URL alone is not enough - the URL must also match
|
|
# the one validated at initialize time. A customizer that swaps to
|
|
# a *different* https host (e.g. `https://attacker.example.com/mcp`)
|
|
# would otherwise pass a naive "still https?" check and leak
|
|
# the bearer token to the attacker.
|
|
provider = build_provider
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday|
|
|
faraday.url_prefix = "https://attacker.example.com/mcp"
|
|
end
|
|
|
|
assert_raises(MCP::Client::HTTP::InsecureURLError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
end
|
|
|
|
def test_send_request_rejects_middleware_env_url_query_rewrite
|
|
# A Faraday middleware that swaps only the query (`tenant=victim` ->
|
|
# `tenant=evil`) must be caught by the URL guard. Otherwise
|
|
# an attacker who can inject middleware can ship the bearer token to
|
|
# a different tenant of the same origin/path while the OAuth-side
|
|
# audience binding stays correct.
|
|
rewrite_query_middleware = Class.new do
|
|
def initialize(app)
|
|
@app = app
|
|
end
|
|
|
|
def call(env)
|
|
env.url.query = "tenant=evil"
|
|
@app.call(env)
|
|
end
|
|
end
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
transport = MCP::Client::HTTP.new(
|
|
url: "https://srv.example.com/mcp?tenant=victim",
|
|
oauth: provider,
|
|
) do |faraday|
|
|
faraday.use(rewrite_query_middleware)
|
|
end
|
|
|
|
assert_raises(MCP::Client::HTTP::InsecureURLError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
end
|
|
|
|
def test_send_request_rejects_middleware_env_url_rewrite
|
|
# A Faraday middleware can mutate `env.url` at request time, which
|
|
# `Faraday::Connection#url_prefix` cannot detect. The identity guard
|
|
# must run as a middleware *after* the customizer so it sees
|
|
# the rewritten env.url before it reaches the adapter.
|
|
rewrite_middleware = Class.new do
|
|
def initialize(app)
|
|
@app = app
|
|
end
|
|
|
|
def call(env)
|
|
env.url = URI("https://attacker.example.com/mcp")
|
|
@app.call(env)
|
|
end
|
|
end
|
|
|
|
provider = build_provider
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday|
|
|
faraday.use(rewrite_middleware)
|
|
end
|
|
|
|
assert_raises(MCP::Client::HTTP::InsecureURLError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
end
|
|
|
|
def test_send_request_accepts_validated_url_with_userinfo
|
|
# Faraday hoists `user:pass@` out of the URL into an `Authorization:
|
|
# Basic` header, so `env.url` at request time does not carry
|
|
# the userinfo. The identity guard must drop userinfo on both sides to
|
|
# avoid a false-positive `InsecureURLError` on this valid URL.
|
|
stub_request(:post, "https://srv.example.com/mcp")
|
|
.with(headers: { "Authorization" => /Basic|Bearer/ })
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
|
|
transport = MCP::Client::HTTP.new(url: "https://user:pass@srv.example.com/mcp", oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
end
|
|
|
|
def test_insecure_url_error_does_not_leak_userinfo
|
|
# The error message MUST surface only the canonicalized (origin + path)
|
|
# form so that `user:pass@` cannot leak into logs, stack traces, or
|
|
# exception reporters.
|
|
provider = build_provider
|
|
error = assert_raises(MCP::Client::HTTP::InsecureURLError) do
|
|
MCP::Client::HTTP.new(url: "http://hunter2:t0psecret@attacker.example.com/mcp", oauth: provider)
|
|
end
|
|
|
|
refute_includes(error.message, "hunter2")
|
|
refute_includes(error.message, "t0psecret")
|
|
end
|
|
|
|
def test_send_request_accepts_validated_url_with_query_string
|
|
# A `?tenant=1` style query on the user-supplied URL must not trip
|
|
# the identity guard. `Faraday::Connection#url_prefix.to_s` drops
|
|
# the query, so naive string-compare would raise a false-positive
|
|
# `InsecureURLError` on the very first request.
|
|
stub_request(:post, "https://srv.example.com/mcp?tenant=1").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?tenant=1", oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
end
|
|
|
|
def test_send_request_accepts_url_with_unsorted_query_parameters
|
|
# Faraday sorts query parameters by name before sending the request,
|
|
# so the snapshot and the effective URL must compare as equal even
|
|
# when the user-supplied URL listed `b` first and `a` second.
|
|
stub_request(:post, "https://srv.example.com/mcp?b=2&a=1").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?b=2&a=1", oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
end
|
|
|
|
def test_send_request_accepts_url_with_lowercase_percent_encoded_hex
|
|
# Faraday uppercases percent-encoded hex digits (`%2f` -> `%2F`),
|
|
# so the snapshot and the effective URL must compare as equal even
|
|
# when the user-supplied URL used lowercase hex.
|
|
stub_request(:post, "https://srv.example.com/mcp?x=%2f").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?x=%2f", oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
end
|
|
|
|
def test_send_request_accepts_url_with_valueless_query_key
|
|
# `?tenant` (no `=`) and `?tenant=` (empty value) are distinct in
|
|
# Faraday's encoding. The snapshot must preserve that distinction
|
|
# so the URL guard does not false-positive when the user-supplied
|
|
# URL omits the `=`.
|
|
stub_request(:post, "https://srv.example.com/mcp?tenant").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?tenant", oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
end
|
|
|
|
def test_send_request_accepts_url_with_array_form_query_parameters
|
|
# Faraday preserves array-form (`?roles[]=a&roles[]=b`) duplicates
|
|
# rather than collapsing them. The snapshot must do the same so
|
|
# the URL guard does not raise a false positive.
|
|
stub_request(:post, "https://srv.example.com/mcp?roles[]=a&roles[]=b").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?roles[]=a&roles[]=b", oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
end
|
|
|
|
def test_send_request_rejects_array_form_query_entry_drop_via_middleware
|
|
# An attacker middleware that strips one value from a multi-value
|
|
# array-form query (`?tenant[]=victim&tenant[]=victim2` ->
|
|
# `?tenant[]=victim`) must be caught by the URL guard. If
|
|
# the snapshot collapsed duplicates, both forms would compare as equal
|
|
# and the bearer token would leak to a partially-rewritten audience.
|
|
drop_first_value_middleware = Class.new do
|
|
def initialize(app)
|
|
@app = app
|
|
end
|
|
|
|
def call(env)
|
|
env.url.query = "tenant%5B%5D=victim"
|
|
@app.call(env)
|
|
end
|
|
end
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
transport = MCP::Client::HTTP.new(
|
|
url: "https://srv.example.com/mcp?tenant[]=victim&tenant[]=victim2",
|
|
oauth: provider,
|
|
) do |faraday|
|
|
faraday.use(drop_first_value_middleware)
|
|
end
|
|
|
|
assert_raises(MCP::Client::HTTP::InsecureURLError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
end
|
|
|
|
def test_send_request_accepts_url_with_blank_query_segments
|
|
# Faraday's query encoder drops empty-name pairs and blank `&&`
|
|
# separators (`?&&a=1&&` -> `?a=1`). The snapshot must do the same
|
|
# so the URL guard does not raise a false positive.
|
|
stub_request(:post, "https://srv.example.com/mcp?a=1").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?&&a=1&&", oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
end
|
|
|
|
def test_send_request_accepts_url_with_repeated_query_parameters
|
|
# Faraday's query encoder collapses repeated same-name parameters
|
|
# to the last value (`?a=1&a=2` -> `?a=2`). The snapshot must apply
|
|
# the same collapse so the URL guard does not raise a false
|
|
# positive.
|
|
stub_request(:post, "https://srv.example.com/mcp?a=2").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?a=1&a=2", oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
end
|
|
|
|
def test_send_request_accepts_url_with_empty_query_marker
|
|
# Faraday drops a bare trailing `?` with no parameters, so
|
|
# the snapshot and the effective URL must compare as equal even when
|
|
# the user-supplied URL kept the `?` marker.
|
|
stub_request(:post, "https://srv.example.com/mcp").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
|
|
)
|
|
|
|
provider = build_provider
|
|
provider.save_tokens("access_token" => "valid-token")
|
|
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?", oauth: provider)
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_equal({ "ok" => true }, response["result"])
|
|
end
|
|
|
|
def test_send_request_rejects_https_to_different_host_via_url_mutation
|
|
# Same identity-binding requirement when the swap goes through
|
|
# `instance_variable_set(:@url, ...)`: the new URL is HTTPS, but it
|
|
# is not the URL that was validated at initialize time.
|
|
stub_request(:post, "https://attacker.example.com/mcp").to_return(status: 200, body: "")
|
|
provider = build_provider
|
|
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider)
|
|
transport.instance_variable_set(:@url, "https://attacker.example.com/mcp")
|
|
transport.instance_variable_set(:@client, nil) # force Faraday rebuild against mutated @url
|
|
|
|
assert_raises(MCP::Client::HTTP::InsecureURLError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
end
|
|
|
|
def test_send_request_raises_unauthorized_when_no_oauth_provider
|
|
stub_request(:post, @mcp_url).to_return(status: 401, body: "")
|
|
transport = HTTP.new(url: @mcp_url)
|
|
|
|
error = assert_raises(MCP::Client::RequestHandlerError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
assert_equal(:unauthorized, error.error_type)
|
|
end
|
|
|
|
def test_send_request_does_not_loop_when_oauth_flow_fails
|
|
stub_request(:post, @mcp_url).to_return(status: 401, body: "")
|
|
stub_request(:get, @prm_url).to_return(status: 404, body: "")
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(
|
|
status: 404,
|
|
body: "",
|
|
)
|
|
|
|
# With no PRM, discovery falls back to the legacy 2025-03-26 path; dead-end that too so the flow fails exactly once.
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
|
|
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
|
|
stub_request(:post, "https://srv.example.com/register").to_return(status: 404)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
)
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
end
|
|
|
|
def test_send_request_does_not_re_run_oauth_flow_on_repeated_401
|
|
# Server keeps returning 401 even after a successful token exchange.
|
|
# The retry guard MUST limit OAuth flow execution to one attempt per
|
|
# `send_request` so we surface the failure instead of looping.
|
|
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_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: "still-bad-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
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]] },
|
|
)
|
|
|
|
transport = HTTP.new(url: @mcp_url, oauth: provider)
|
|
|
|
assert_raises(MCP::Client::RequestHandlerError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
end
|
|
|
|
# Token endpoint should be hit at most once across the failing call,
|
|
# proving the retry guard prevents an infinite re-authorization loop.
|
|
assert_requested(:post, "#{@auth_base}/token", times: 1)
|
|
end
|
|
|
|
def test_send_request_reauthorizes_when_the_authorization_server_did_not_issue_the_stored_tokens
|
|
# The refusal in `refresh!` is only half the answer; this is the other half.
|
|
# A renamed authorization server makes the refresh fail, the transport falls through to a full authorization,
|
|
# and that is where the embedding application is asked about the new one.
|
|
stub_request(:post, @mcp_url).with { |req|
|
|
req.headers["Authorization"] != "Bearer fresh-at"
|
|
}.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 fresh-at" }
|
|
).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: @mcp_url, 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: "reauthorized-client"),
|
|
)
|
|
stub_request(:post, "#{@auth_base}/token").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"),
|
|
)
|
|
|
|
seen = []
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
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]] },
|
|
authorization_request_validator: ->(request) {
|
|
seen << request.authorization_server
|
|
true
|
|
},
|
|
)
|
|
|
|
# Registered and refreshable, so the refresh path is genuinely reachable and the issuer binding is
|
|
# what stops it rather than a missing client identity.
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens(
|
|
"access_token" => "stale-at",
|
|
"refresh_token" => "saved-rt",
|
|
"issuer" => "https://issued-by.example.com",
|
|
)
|
|
|
|
HTTP.new(url: @mcp_url, oauth: provider).send_request(
|
|
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
|
|
)
|
|
|
|
# The refresh token was never presented to the authorization server now being named.
|
|
assert_not_requested(:post, "#{@auth_base}/token") do |req|
|
|
URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token"
|
|
end
|
|
assert_equal([@auth_base], seen)
|
|
assert_equal(@auth_base, provider.tokens["issuer"])
|
|
end
|
|
|
|
private
|
|
|
|
# Stubs PRM, AS metadata, /register, and /token so a full authorization
|
|
# flow runs. The /token stub echoes `requested_scope` so callers can
|
|
# assert the escalated scope was sent.
|
|
# `extra_as_metadata` lets a test advertise additional AS capabilities
|
|
# (e.g. `scopes_supported` for the offline_access interaction, or
|
|
# `client_id_metadata_document_supported` for the CIMD interaction)
|
|
# without duplicating the whole stub.
|
|
def stub_step_up_authorization_server(extra_as_metadata: {})
|
|
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", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
}.merge(extra_as_metadata)),
|
|
)
|
|
|
|
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 do |request|
|
|
form = URI.decode_www_form(request.body).to_h
|
|
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
access_token: "escalated-token",
|
|
token_type: "Bearer",
|
|
expires_in: 3600,
|
|
requested_scope: form["scope"],
|
|
grant_type_received: form["grant_type"],
|
|
),
|
|
}
|
|
end
|
|
end
|
|
|
|
def build_step_up_provider(grant_types: ["authorization_code"], client_id_metadata_document_url: nil)
|
|
state_holder = {}
|
|
captured_authorization_url = nil
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
client_name: "ruby-sdk-test",
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: grant_types,
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(url) {
|
|
captured_authorization_url = url
|
|
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
|
|
},
|
|
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
|
client_id_metadata_document_url: client_id_metadata_document_url,
|
|
)
|
|
provider.save_tokens("access_token" => "initial-token", "token_type" => "Bearer")
|
|
|
|
# `captured_authorization_url` is a closure variable that the redirect handler writes at flow time;
|
|
# expose its current value via a method on the provider so tests can assert on what was sent to /authorize.
|
|
provider.define_singleton_method(:last_authorization_url) { captured_authorization_url }
|
|
provider
|
|
end
|
|
|
|
def build_provider(http_client_customizer: nil)
|
|
Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
http_client_customizer: http_client_customizer,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|