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
1 changed files with 493 additions and 18 deletions
|
|
@ -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