## Motivation and Context An MCP server chooses the authorization server its users sign in to. It names one in `authorization_servers` in its Protected Resource Metadata, and states the scopes it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. Nothing binds either choice to the server's own identity. No origin rule can settle this, because an authorization server is legitimately a different origin from the resource it protects. Validating that a token was issued for the intended audience is a responsibility the specification places on MCP servers rather than on clients, and there is no portable way for a client to perform it either: access tokens are frequently opaque, and an authorization server is not required to honor the `resource` parameter or to report an audience in the token response. Two things follow from that, and they are separate. The first is a judgement, and it belongs to whoever embeds this SDK, because an application often knows which providers its users deal with. `authorization_request_validator` is handed an `AuthorizationRequest` describing the authorization server, the scopes, the MCP server, and the RFC 8707 resource, and can refuse. It is one object rather than keyword arguments so that later revisions of the specification can add to it without changing the shape implementers wrote. It runs on all three grants, and on the authorization-code grant it runs before dynamic client registration, which is why scope resolution moved above `ensure_client_registered`: a refusal must not leave this client registered at an authorization server the host just rejected. The second is not a judgement and is enforced here rather than asked about. `refresh!` rediscovers the authorization server on every call, so the server named for a session can differ from the one the stored tokens came from. SEP-2352 already binds client credentials to their issuer, but that check passes when the issuer was never recorded and is deliberately skipped for a Client ID Metadata Document URL, which is portable across authorization servers. The issuer that minted the tokens is now recorded with them, and `refresh!` refuses to present them to a different one. The transport answers that refusal by running a full authorization, which brings the new authorization server to the validator above. Tokens stored before this carry no issuer and keep refreshing. The hook decides whether to proceed, not what to ask for. The scopes reach the authorization server unchanged either way, because the specification requires a client to treat the scopes in the challenge as authoritative for the operation, and narrowing them silently would break that. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` assert what the validator receives, that a refusal raises before any registration request is sent and before the redirect handler runs, and that the `client_credentials` and `jwt-bearer` grants consult it too. Others cover the issuer recorded on authorization, the refusal to refresh against a different one, and that tokens stored without an issuer still refresh. A test in `test/mcp/client/oauth/http_oauth_test.rb` drives the two halves together: a stale issuer makes the refresh fail, the transport reauthorizes, and the validator sees the newly named authorization server while no refresh token is ever posted to it. Each of them fails with its half of this change removed. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes Refreshing now fails when the authorization server named for a session differs from the one that issued the stored tokens, and the caller reauthorizes instead. Tokens stored before this change carry no recorded issuer and are unaffected until their next authorization. The validator itself is additive: it defaults to `nil`, and a provider that does not offer it authorizes as before.
1619 lines
72 KiB
Ruby
1619 lines
72 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
|
|
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_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_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
|
|
Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|