Re-run OAuth flow on 403 insufficient_scope (step-up)
## Motivation and Context The MCP authorization specification (2025-11-25 scope-selection-strategy) and RFC 6750 Section 3.1 describe OAuth 2.0 step-up authentication: the client already holds a valid access token, but the server returns a 403 with `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."` to demand a higher-privileged scope for a specific operation. The client is expected to run a fresh authorization request with the escalated scope and retry. Until now the Ruby SDK only ran the OAuth flow on 401 responses. A 403 surfaced unchanged as a `RequestHandlerError` and the operation that needed the escalated scope failed permanently, even with a valid token in storage. This lined up with the `auth/scope-step-up` conformance scenario being listed in `conformance/expected_failures.yml`. The change adds: - `MCP::Client::HTTP#send_request` recognises a 403 carrying an `insufficient_scope` Bearer challenge as a step-up signal, re-runs a full authorization request with the scope from the challenge, and retries the original request. Retries are capped at one per `send_request` call so a server that repeatedly demands more scope cannot loop the transport. - `run_step_up_flow!` deliberately bypasses the refresh-token path that `run_oauth_flow!` uses on 401. Refreshing would re-issue the same scope set the existing token already has and the server already rejected. Only a fresh `authorization_code` grant with the escalated scope can succeed. - A plain 403 with no `insufficient_scope` challenge continues to surface as `RequestHandlerError` with `error_type: :forbidden`. - `conformance/client.rb` invokes a tool for the `auth/scope-step-up` scenario; the scenario's authorization middleware only requires the escalated scope on `tools/call`, so the previous `tools/list`-only client could never trigger the second authorization. - `auth/scope-step-up` is removed from `conformance/expected_failures.yml`. Resolves #383. ## How Has This Been Tested? New `HTTPOAuthTest` cases cover: the 403 insufficient_scope path re-authorizes with the escalated scope from the challenge and retries the original request with the new bearer token; a plain 403 without the `insufficient_scope` challenge is surfaced as `error_type: :forbidden` with no OAuth side effects; repeated `insufficient_scope` responses retry at most once and then surface the 403; a provider with a saved `refresh_token` still goes through the full authorization request rather than exchanging the refresh token, because the existing scope set is precisely what was rejected. Conformance: `auth/scope-step-up` now passes 22/22 with no warnings. `bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance` are all green. ## Breaking Changes None. Behaviour on 401 is unchanged. A 403 without the `insufficient_scope` Bearer challenge still raises the same `RequestHandlerError` as before. The new retry only fires when both the WWW-Authenticate header signals step-up and the transport was constructed with an OAuth provider.
This commit is contained in:
parent
ac07f8f3a7
commit
a407cea086
5 changed files with 493 additions and 18 deletions
|
|
@ -1913,6 +1913,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
|
|||
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
|
||||
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
|
||||
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
|
||||
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
|
||||
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
|
||||
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
|
||||
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
|
||||
`scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
|
||||
`offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.
|
||||
|
|
|
|||
|
|
@ -116,7 +116,16 @@ when %r|\Aauth/|
|
|||
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
|
||||
# are observed by the conformance server during `connect` and the subsequent request below.
|
||||
# Listing tools forces a second authenticated MCP request so the bearer token usage check fires.
|
||||
client.tools
|
||||
tools = client.tools
|
||||
|
||||
# `auth/scope-step-up` only fires its escalation 403 on `tools/call`, not `tools/list`,
|
||||
# so the client must actually invoke a tool to drive the second authorization request
|
||||
# the scenario asserts on.
|
||||
if scenario == "auth/scope-step-up"
|
||||
tool = tools.find { |t| t.name == "test-tool" } || tools.first
|
||||
abort("No tool exposed by conformance server for #{scenario}") unless tool
|
||||
client.call_tool(tool: tool, arguments: {})
|
||||
end
|
||||
else
|
||||
abort("Unknown or unsupported scenario: #{scenario}")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ client:
|
|||
# TODO: Elicitation not implemented in Ruby client.
|
||||
- elicitation-sep1034-client-defaults
|
||||
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
|
||||
- auth/scope-step-up
|
||||
- auth/2025-03-26-oauth-metadata-backcompat
|
||||
- auth/2025-03-26-oauth-endpoint-fallback
|
||||
- auth/client-credentials-jwt
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ module MCP
|
|||
method = request[:method] || request["method"]
|
||||
params = request[:params] || request["params"]
|
||||
oauth_retried = false
|
||||
step_up_retried = false
|
||||
|
||||
begin
|
||||
response = client.post("", request, session_headers)
|
||||
|
|
@ -217,6 +218,19 @@ module MCP
|
|||
original_error: e,
|
||||
)
|
||||
rescue Faraday::ForbiddenError => e
|
||||
# OAuth 2.0 step-up: a 403 carrying `error="insufficient_scope"` in
|
||||
# the Bearer challenge means the existing access token is valid
|
||||
# but lacks scopes the server now requires for this operation.
|
||||
# Re-run the full authorization flow with the escalated scope from
|
||||
# the challenge and retry once. A plain 403 without the challenge is
|
||||
# surfaced unchanged.
|
||||
if @oauth && !step_up_retried && insufficient_scope_challenge?(e)
|
||||
step_up_retried = true
|
||||
run_step_up_flow!(forbidden_error: e)
|
||||
|
||||
retry
|
||||
end
|
||||
|
||||
raise RequestHandlerError.new(
|
||||
"You are forbidden to make #{method} requests",
|
||||
{ method: method, params: params },
|
||||
|
|
@ -337,16 +351,63 @@ module MCP
|
|||
#
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling
|
||||
def run_oauth_flow!(unauthorized_error:)
|
||||
response = unauthorized_error.response || {}
|
||||
response_headers = response[:headers] || {}
|
||||
www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
|
||||
params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate)
|
||||
|
||||
params = parse_www_authenticate_from_error(unauthorized_error)
|
||||
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
|
||||
if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
|
||||
return
|
||||
end
|
||||
return if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
|
||||
|
||||
run_full_authorization_flow!(flow: flow, params: params)
|
||||
end
|
||||
|
||||
# Drives a full Authorization Code + PKCE flow without first attempting
|
||||
# to refresh the access token. Used for the MCP scope-selection-strategy
|
||||
# step-up path: the provider already holds a valid access token,
|
||||
# but the server returned a 403 with
|
||||
# `WWW-Authenticate: ... error="insufficient_scope", scope="..."`
|
||||
# per RFC 6750 Section 3.1. Refreshing the existing token would re-issue
|
||||
# the same scope set the server already rejected, so the SDK must run
|
||||
# a fresh authorization request. The request asks for the union of
|
||||
# the currently granted scope and the newly demanded scope; otherwise
|
||||
# the caller would lose previously held scopes and trigger another step-up
|
||||
# on the next operation that needs them.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy
|
||||
def run_step_up_flow!(forbidden_error:)
|
||||
params = parse_www_authenticate_from_error(forbidden_error)
|
||||
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
|
||||
params = params.merge("scope" => escalated_step_up_scope(params["scope"]))
|
||||
|
||||
run_full_authorization_flow!(flow: flow, params: params)
|
||||
end
|
||||
|
||||
# Returns the space-separated union of the currently granted scope (read
|
||||
# from the stored token response per RFC 6749 Section 5.1) and the scope
|
||||
# demanded by the step-up challenge. Duplicates are collapsed; order
|
||||
# follows first appearance so existing scopes precede the newly added
|
||||
# ones. Returns `nil` when neither side carries a scope so
|
||||
# `build_authorization_url` omits the `scope` parameter entirely.
|
||||
def escalated_step_up_scope(challenge_scope)
|
||||
tokens = @oauth.tokens
|
||||
granted = tokens.is_a?(Hash) ? (tokens["scope"] || tokens[:scope]) : nil
|
||||
scopes = [granted, challenge_scope].compact.flat_map { |scope| scope.to_s.split }.uniq
|
||||
|
||||
scopes.empty? ? nil : scopes.join(" ")
|
||||
end
|
||||
|
||||
# True when the response on `forbidden_error` carries a Bearer challenge
|
||||
# with `error="insufficient_scope"` per RFC 6750 Section 3.1 and the MCP
|
||||
# scope-selection-strategy section. A 403 without that signal is not a
|
||||
# step-up challenge and must not trigger re-authorization.
|
||||
def insufficient_scope_challenge?(forbidden_error)
|
||||
parse_www_authenticate_from_error(forbidden_error)["error"] == "insufficient_scope"
|
||||
end
|
||||
|
||||
def parse_www_authenticate_from_error(error)
|
||||
response = error.response || {}
|
||||
response_headers = response[:headers] || {}
|
||||
header = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
|
||||
MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
|
||||
end
|
||||
|
||||
def run_full_authorization_flow!(flow:, params:)
|
||||
# Use the URL snapshotted at `initialize` time so a post-construction
|
||||
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
|
||||
# URL to an attacker-controlled host.
|
||||
|
|
|
|||
|
|
@ -22,15 +22,6 @@ module MCP
|
|||
WebMock.reset!
|
||||
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
|
||||
|
||||
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)
|
||||
|
|
@ -110,6 +101,325 @@ module MCP
|
|||
assert_equal("test-token-after-flow", provider.access_token)
|
||||
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
|
||||
|
|
@ -1080,6 +1390,99 @@ module MCP
|
|||
# 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue