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:
Koichi ITO 2026-05-28 13:06:32 +09:00
commit a407cea086

View file

@ -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