Skip to content

Commit 51cd78a

Browse files
committed
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`. ## 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.
1 parent cf44475 commit 51cd78a

5 files changed

Lines changed: 493 additions & 18 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
19131913
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
19141914
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.
19151915
- 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).
1916+
- 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),
1917+
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.
1918+
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.
19161919
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
19171920
`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
19181921
`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.

conformance/client.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,16 @@ def build_oauth_provider(context, scenario:)
116116
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
117117
# are observed by the conformance server during `connect` and the subsequent request below.
118118
# Listing tools forces a second authenticated MCP request so the bearer token usage check fires.
119-
client.tools
119+
tools = client.tools
120+
121+
# `auth/scope-step-up` only fires its escalation 403 on `tools/call`, not `tools/list`,
122+
# so the client must actually invoke a tool to drive the second authorization request
123+
# the scenario asserts on.
124+
if scenario == "auth/scope-step-up"
125+
tool = tools.find { |t| t.name == "test-tool" } || tools.first
126+
abort("No tool exposed by conformance server for #{scenario}") unless tool
127+
client.call_tool(tool: tool, arguments: {})
128+
end
120129
else
121130
abort("Unknown or unsupported scenario: #{scenario}")
122131
end

conformance/expected_failures.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ client:
55
# TODO: Elicitation not implemented in Ruby client.
66
- elicitation-sep1034-client-defaults
77
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
8-
- auth/scope-step-up
98
- auth/2025-03-26-oauth-metadata-backcompat
109
- auth/2025-03-26-oauth-endpoint-fallback
1110
- auth/client-credentials-jwt

lib/mcp/client/http.rb

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ def send_request(request:)
185185
method = request[:method] || request["method"]
186186
params = request[:params] || request["params"]
187187
oauth_retried = false
188+
step_up_retried = false
188189

189190
begin
190191
response = client.post("", request, session_headers)
@@ -217,6 +218,19 @@ def send_request(request:)
217218
original_error: e,
218219
)
219220
rescue Faraday::ForbiddenError => e
221+
# OAuth 2.0 step-up: a 403 carrying `error="insufficient_scope"` in
222+
# the Bearer challenge means the existing access token is valid
223+
# but lacks scopes the server now requires for this operation.
224+
# Re-run the full authorization flow with the escalated scope from
225+
# the challenge and retry once. A plain 403 without the challenge is
226+
# surfaced unchanged.
227+
if @oauth && !step_up_retried && insufficient_scope_challenge?(e)
228+
step_up_retried = true
229+
run_step_up_flow!(forbidden_error: e)
230+
231+
retry
232+
end
233+
220234
raise RequestHandlerError.new(
221235
"You are forbidden to make #{method} requests",
222236
{ method: method, params: params },
@@ -337,16 +351,63 @@ def session_headers
337351
#
338352
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling
339353
def run_oauth_flow!(unauthorized_error:)
340-
response = unauthorized_error.response || {}
341-
response_headers = response[:headers] || {}
342-
www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
343-
params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate)
354+
params = parse_www_authenticate_from_error(unauthorized_error)
355+
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
356+
return if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
344357

358+
run_full_authorization_flow!(flow: flow, params: params)
359+
end
360+
361+
# Drives a full Authorization Code + PKCE flow without first attempting
362+
# to refresh the access token. Used for the MCP scope-selection-strategy
363+
# step-up path: the provider already holds a valid access token,
364+
# but the server returned a 403 with
365+
# `WWW-Authenticate: ... error="insufficient_scope", scope="..."`
366+
# per RFC 6750 Section 3.1. Refreshing the existing token would re-issue
367+
# the same scope set the server already rejected, so the SDK must run
368+
# a fresh authorization request. The request asks for the union of
369+
# the currently granted scope and the newly demanded scope; otherwise
370+
# the caller would lose previously held scopes and trigger another step-up
371+
# on the next operation that needs them.
372+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy
373+
def run_step_up_flow!(forbidden_error:)
374+
params = parse_www_authenticate_from_error(forbidden_error)
345375
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
346-
if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
347-
return
348-
end
376+
params = params.merge("scope" => escalated_step_up_scope(params["scope"]))
377+
378+
run_full_authorization_flow!(flow: flow, params: params)
379+
end
380+
381+
# Returns the space-separated union of the currently granted scope (read
382+
# from the stored token response per RFC 6749 Section 5.1) and the scope
383+
# demanded by the step-up challenge. Duplicates are collapsed; order
384+
# follows first appearance so existing scopes precede the newly added
385+
# ones. Returns `nil` when neither side carries a scope so
386+
# `build_authorization_url` omits the `scope` parameter entirely.
387+
def escalated_step_up_scope(challenge_scope)
388+
tokens = @oauth.tokens
389+
granted = tokens.is_a?(Hash) ? (tokens["scope"] || tokens[:scope]) : nil
390+
scopes = [granted, challenge_scope].compact.flat_map { |scope| scope.to_s.split }.uniq
391+
392+
scopes.empty? ? nil : scopes.join(" ")
393+
end
394+
395+
# True when the response on `forbidden_error` carries a Bearer challenge
396+
# with `error="insufficient_scope"` per RFC 6750 Section 3.1 and the MCP
397+
# scope-selection-strategy section. A 403 without that signal is not a
398+
# step-up challenge and must not trigger re-authorization.
399+
def insufficient_scope_challenge?(forbidden_error)
400+
parse_www_authenticate_from_error(forbidden_error)["error"] == "insufficient_scope"
401+
end
402+
403+
def parse_www_authenticate_from_error(error)
404+
response = error.response || {}
405+
response_headers = response[:headers] || {}
406+
header = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
407+
MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
408+
end
349409

410+
def run_full_authorization_flow!(flow:, params:)
350411
# Use the URL snapshotted at `initialize` time so a post-construction
351412
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
352413
# URL to an attacker-controlled host.

0 commit comments

Comments
 (0)