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