## Motivation and Context The OAuth client checked the scheme of every discovery URL but never checked where it pointed. `Discovery.secure_url?` returns true for any `https` URL regardless of host, and the `resource_metadata` URL from a `WWW-Authenticate` challenge is placed first in the candidate list, so a server answering `401 Bearer resource_metadata="https://169.254.169.254/..."` had the client issue that request during discovery, before the user approves anything. The same gap applied one hop deeper: a server that serves its own Protected Resource Metadata can name any host in `authorization_servers`, and the endpoints in the document fetched from there receive the dynamic client registration and the token request that carries the client credentials. Two rules now apply to those URLs. Both run before the request is sent, and neither is configurable. The `resource_metadata` URL must be on the MCP server's own origin. Protected Resource Metadata describes that server, so a real deployment publishes it there, and requiring it means a challenge cannot aim the first request of the flow at an unrelated host. RFC 9728 does not itself require this, so the SDK is deliberately stricter than the specification: the range check below cannot recognize an internal host that is named rather than addressed, and this is the one URL a server supplies directly. The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be IP literals in a private, loopback, link-local, or unique-local range, which is the precaution RFC 9728 Section 7.7 asks clients to take. That check is skipped when the configured MCP server URL is itself on such an address. Pointing a client at a private network is a deliberate act and its authorization server usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network keep working. Only a server reachable on the public internet is barred from steering the client inward. A range check is only as good as its reading of the address, so the second rule also refuses hosts written in the numeric IPv4 grammar `inet_aton` accepts and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal, octal, or decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and `0x7f000001` all reach 127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address, so reading only the dotted-decimal spelling would refuse `169.254.169.254` while admitting every alias of it. Those hosts are refused outright rather than decoded and range-checked, because their value is resolver-dependent: a leading zero reads as octal on one platform and decimal on another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`. Choosing a reading here would leave whichever one was not taken as a way through, and nothing is legitimately addressed in this notation. Hostnames are not resolved. A lookup here would be a second network request driven by the same untrusted input, and the address it returned need not be the one the HTTP client connects to a moment later, so the check would read as a guarantee it cannot make. `MCP::Client::OAuth::Flow` also documents why its Faraday connection has no redirect-following middleware: every check runs against the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused. ## How Has This Been Tested? - New unit tests in `test/mcp/client/oauth/discovery_test.rb` cover `same_origin?` (case-insensitive scheme and host, explicit default ports, differing port/host/scheme, subdomains, malformed and hostless input) and `private_network_host?` (every blocked range, IPv4-mapped IPv6 spellings, bracketed IPv6, `localhost` matched exactly, and public addresses and names). One test documents that a named internal host reads as public, which is the deliberate limit of the predicate. - Further unit tests cover the numeric spellings: packed integers, hexadecimal, and short forms that reach a blocked range, and the ambiguous leading-zero forms whose value depends on the resolver. Removing the rule leaves them addressable, which is what those tests fail on. - New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/http_oauth_test.rb` drive both rules through `run!`, `refresh!`, and the transport. They assert with `assert_not_requested` that no request was issued rather than only that an error was raised, since nothing downstream can un-send one. One of them names the authorization server `https://0xa9fea9fe`, which without the rule above is fetched. A further test runs a complete flow with the MCP server and the authorization server on two loopback ports, which is what the carve-out exists for. - `rake conformance` passes with all 22 auth scenarios green. Its harness runs the MCP server and the authorization server on separate loopback ports and publishes PRM on the server's own origin, so it exercises exactly the shapes these rules must not break. ## Breaking Changes A client whose MCP server is reachable on the public internet no longer follows a `resource_metadata` URL on a different origin, and no longer contacts an authorization server or OAuth endpoint at a private IP literal or at a host written in the numeric IPv4 notation above. All were previously allowed. Local development, private-network deployments, and the conformance suite are unaffected.
1531 lines
68 KiB
Ruby
1531 lines
68 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
|
|
|
|
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
|