Skip to content

Commit cf44475

Browse files
authored
Merge pull request #365 from koic/conformance_client_auth_offline_access_scope
Request offline_access scope when supported (SEP-2207)
2 parents 60416c9 + fdca6f5 commit cf44475

5 files changed

Lines changed: 229 additions & 2 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+
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
1917+
`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
1918+
`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.
19161919

19171920
```ruby
19181921
require "mcp"

conformance/client.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def build_oauth_provider(context, scenario:)
8787
client_metadata: {
8888
client_name: "ruby-sdk-conformance-client",
8989
redirect_uris: [redirect_uri],
90-
grant_types: ["authorization_code"],
90+
grant_types: ["authorization_code", "refresh_token"],
9191
response_types: ["code"],
9292
token_endpoint_auth_method: "none",
9393
},

conformance/expected_failures.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,3 @@ client:
1111
- auth/client-credentials-jwt
1212
- auth/client-credentials-basic
1313
- auth/cross-app-access-complete-flow
14-
- auth/offline-access-scope

lib/mcp/client/oauth/flow.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
5959
client_info = ensure_client_registered(as_metadata: as_metadata)
6060

6161
effective_scope = resolve_scope(scope: scope, prm: prm)
62+
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
6263
pkce = PKCE.generate
6364
state = SecureRandom.urlsafe_base64(32)
6465

@@ -441,6 +442,48 @@ def resolve_scope(scope:, prm:)
441442
nil
442443
end
443444

445+
# Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
446+
#
447+
# - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
448+
# `refresh_token` in its registered `grant_types`) MAY request `offline_access`
449+
# when the authorization server advertises it in metadata `scopes_supported`.
450+
# When the server advertises it and the client opted in, add it if absent.
451+
#
452+
# - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
453+
# strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
454+
# PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
455+
# request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
456+
# or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
457+
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
458+
# own request consistent with the AS's advertisement.
459+
#
460+
# Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
461+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
462+
def normalize_offline_access_scope(scope, as_metadata:)
463+
scopes = scope.to_s.split
464+
465+
if server_supports_offline_access?(as_metadata)
466+
scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
467+
else
468+
scopes.delete("offline_access")
469+
end
470+
471+
scopes.empty? ? nil : scopes.join(" ")
472+
end
473+
474+
def server_supports_offline_access?(as_metadata)
475+
supported = as_metadata["scopes_supported"]
476+
477+
supported.is_a?(Array) && supported.include?("offline_access")
478+
end
479+
480+
def wants_refresh_token?
481+
metadata = @provider.client_metadata
482+
grant_types = metadata[:grant_types] || metadata["grant_types"]
483+
484+
Array(grant_types).include?("refresh_token")
485+
end
486+
444487
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
445488
authorization_endpoint = as_metadata["authorization_endpoint"]
446489
unless authorization_endpoint

test/mcp/client/oauth/flow_test.rb

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,34 @@ def teardown
6868
WebMock.reset!
6969
end
7070

71+
# Runs the full authorization flow and returns the `scope` query parameter
72+
# sent on the authorization request. The caller stubs the AS metadata;
73+
# this helper supplies a provider whose `grant_types` and optional pre-set
74+
# `scope` drive the SEP-2207 offline_access decision.
75+
def capture_authorization_scope(grant_types:, provider_scope: nil)
76+
captured_scope = nil
77+
state_holder = {}
78+
provider = Provider.new(
79+
client_metadata: {
80+
redirect_uris: ["http://localhost:0/callback"],
81+
grant_types: grant_types,
82+
response_types: ["code"],
83+
token_endpoint_auth_method: "none",
84+
},
85+
redirect_uri: "http://localhost:0/callback",
86+
redirect_handler: ->(url) {
87+
query = URI.decode_www_form(url.query).to_h
88+
captured_scope = query["scope"]
89+
state_holder[:state] = query.fetch("state")
90+
},
91+
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
92+
scope: provider_scope,
93+
)
94+
95+
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
96+
captured_scope
97+
end
98+
7199
def test_run_completes_full_authorization_flow
72100
captured_authorization_url = nil
73101
state_value = nil
@@ -112,6 +140,160 @@ def test_run_completes_full_authorization_flow
112140
end
113141
end
114142

143+
def test_run_requests_offline_access_when_advertised_and_refresh_token_grant_declared
144+
# SEP-2207: a client that declares the `refresh_token` grant type requests `offline_access`
145+
# when the AS advertises it, so it can obtain a refresh token.
146+
stub_request(:get, @as_metadata_url).to_return(
147+
status: 200,
148+
headers: { "Content-Type" => "application/json" },
149+
body: JSON.generate(
150+
issuer: @auth_base,
151+
authorization_endpoint: "#{@auth_base}/authorize",
152+
token_endpoint: "#{@auth_base}/token",
153+
registration_endpoint: "#{@auth_base}/register",
154+
response_types_supported: ["code"],
155+
grant_types_supported: ["authorization_code", "refresh_token"],
156+
code_challenge_methods_supported: ["S256"],
157+
token_endpoint_auth_methods_supported: ["none"],
158+
scopes_supported: ["mcp:basic", "offline_access"],
159+
),
160+
)
161+
162+
captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"])
163+
164+
assert_includes(captured.split, "offline_access")
165+
end
166+
167+
def test_run_does_not_request_offline_access_when_refresh_token_grant_not_declared
168+
# The AS advertises offline_access, but the client did not opt into refresh tokens,
169+
# so the scope is not requested.
170+
stub_request(:get, @as_metadata_url).to_return(
171+
status: 200,
172+
headers: { "Content-Type" => "application/json" },
173+
body: JSON.generate(
174+
issuer: @auth_base,
175+
authorization_endpoint: "#{@auth_base}/authorize",
176+
token_endpoint: "#{@auth_base}/token",
177+
registration_endpoint: "#{@auth_base}/register",
178+
response_types_supported: ["code"],
179+
grant_types_supported: ["authorization_code"],
180+
code_challenge_methods_supported: ["S256"],
181+
token_endpoint_auth_methods_supported: ["none"],
182+
scopes_supported: ["mcp:basic", "offline_access"],
183+
),
184+
)
185+
186+
captured = capture_authorization_scope(grant_types: ["authorization_code"])
187+
188+
refute_includes(captured.to_s.split, "offline_access")
189+
end
190+
191+
def test_run_does_not_request_offline_access_when_server_does_not_advertise_it
192+
# SEP-2207 forbids requesting offline_access when the AS does not list it,
193+
# even if the client declared the refresh_token grant type.
194+
stub_request(:get, @as_metadata_url).to_return(
195+
status: 200,
196+
headers: { "Content-Type" => "application/json" },
197+
body: JSON.generate(
198+
issuer: @auth_base,
199+
authorization_endpoint: "#{@auth_base}/authorize",
200+
token_endpoint: "#{@auth_base}/token",
201+
registration_endpoint: "#{@auth_base}/register",
202+
response_types_supported: ["code"],
203+
grant_types_supported: ["authorization_code", "refresh_token"],
204+
code_challenge_methods_supported: ["S256"],
205+
token_endpoint_auth_methods_supported: ["none"],
206+
scopes_supported: ["mcp:basic", "mcp:read"],
207+
),
208+
)
209+
210+
captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"])
211+
212+
refute_includes(captured.to_s.split, "offline_access")
213+
end
214+
215+
def test_run_strips_offline_access_from_provider_scope_when_server_does_not_advertise_it
216+
# SDK policy: even when `offline_access` reaches the resolved scope from a provider-supplied scope
217+
# (or a challenge / PRM scope), do not propagate it to the AS when the AS does not advertise the scope.
218+
# SEP-2207 itself only says clients should not request unsupported scopes; this strip is the SDK's
219+
# defensive layer against misbehaving resource servers and misconfigured PRMs that surface `offline_access`
220+
# even though the AS has not opted in.
221+
stub_request(:get, @as_metadata_url).to_return(
222+
status: 200,
223+
headers: { "Content-Type" => "application/json" },
224+
body: JSON.generate(
225+
issuer: @auth_base,
226+
authorization_endpoint: "#{@auth_base}/authorize",
227+
token_endpoint: "#{@auth_base}/token",
228+
registration_endpoint: "#{@auth_base}/register",
229+
response_types_supported: ["code"],
230+
grant_types_supported: ["authorization_code", "refresh_token"],
231+
code_challenge_methods_supported: ["S256"],
232+
token_endpoint_auth_methods_supported: ["none"],
233+
scopes_supported: ["mcp:basic"],
234+
),
235+
)
236+
237+
captured = capture_authorization_scope(
238+
grant_types: ["authorization_code", "refresh_token"],
239+
provider_scope: "mcp:basic offline_access",
240+
)
241+
242+
refute_includes(captured.to_s.split, "offline_access")
243+
assert_includes(captured.to_s.split, "mcp:basic")
244+
end
245+
246+
def test_run_strips_sole_offline_access_scope_when_server_does_not_advertise_it
247+
# When stripping leaves an empty scope, no `scope` parameter is sent.
248+
stub_request(:get, @as_metadata_url).to_return(
249+
status: 200,
250+
headers: { "Content-Type" => "application/json" },
251+
body: JSON.generate(
252+
issuer: @auth_base,
253+
authorization_endpoint: "#{@auth_base}/authorize",
254+
token_endpoint: "#{@auth_base}/token",
255+
registration_endpoint: "#{@auth_base}/register",
256+
response_types_supported: ["code"],
257+
grant_types_supported: ["authorization_code", "refresh_token"],
258+
code_challenge_methods_supported: ["S256"],
259+
token_endpoint_auth_methods_supported: ["none"],
260+
scopes_supported: ["mcp:basic"],
261+
),
262+
)
263+
264+
captured = capture_authorization_scope(
265+
grant_types: ["authorization_code", "refresh_token"],
266+
provider_scope: "offline_access",
267+
)
268+
269+
assert_nil(captured)
270+
end
271+
272+
def test_run_does_not_duplicate_offline_access_already_in_scope
273+
stub_request(:get, @as_metadata_url).to_return(
274+
status: 200,
275+
headers: { "Content-Type" => "application/json" },
276+
body: JSON.generate(
277+
issuer: @auth_base,
278+
authorization_endpoint: "#{@auth_base}/authorize",
279+
token_endpoint: "#{@auth_base}/token",
280+
registration_endpoint: "#{@auth_base}/register",
281+
response_types_supported: ["code"],
282+
grant_types_supported: ["authorization_code", "refresh_token"],
283+
code_challenge_methods_supported: ["S256"],
284+
token_endpoint_auth_methods_supported: ["none"],
285+
scopes_supported: ["mcp:basic", "offline_access"],
286+
),
287+
)
288+
289+
captured = capture_authorization_scope(
290+
grant_types: ["authorization_code", "refresh_token"],
291+
provider_scope: "mcp:basic offline_access",
292+
)
293+
294+
assert_equal(1, captured.split.count("offline_access"))
295+
end
296+
115297
def test_run_raises_on_state_mismatch
116298
provider = Provider.new(
117299
client_metadata: {

0 commit comments

Comments
 (0)