@@ -51,7 +51,7 @@ def __init__(self):
5151 self ._client_info : OAuthClientInformationFull | None = None
5252
5353 async def get_tokens (self ) -> OAuthToken | None :
54- return self ._tokens # pragma: no cover
54+ return self ._tokens
5555
5656 async def set_tokens (self , tokens : OAuthToken ) -> None :
5757 self ._tokens = tokens
@@ -2833,3 +2833,103 @@ def test_credentials_match_issuer_url_shaped_dcr_id_is_not_portable():
28332833 issuer = "https://as.example.com" ,
28342834 )
28352835 assert credentials_match_issuer (info , "https://other" , "https://client.example/metadata.json" ) is False
2836+
2837+
2838+ @pytest .mark .anyio
2839+ async def test_handle_token_response_backfills_omitted_scope_from_request (
2840+ oauth_provider : OAuthClientProvider , mock_storage : MockTokenStorage
2841+ ):
2842+ """RFC 6749 §5.1: an omitted token-response scope means granted == requested.
2843+
2844+ The token is stored with the requested scope filled in so it remains self-describing
2845+ after a restart, when the SEP-2350 step-up union reads it but ``client_metadata.scope``
2846+ has reverted to its constructor value.
2847+ """
2848+ oauth_provider .context .client_metadata .scope = "read admin"
2849+ response = httpx .Response (
2850+ 200 ,
2851+ json = {"access_token" : "t" , "token_type" : "Bearer" , "expires_in" : 3600 },
2852+ request = httpx .Request ("POST" , "https://auth.example.com/token" ),
2853+ )
2854+ await oauth_provider ._handle_token_response (response )
2855+
2856+ assert oauth_provider .context .current_tokens is not None
2857+ assert oauth_provider .context .current_tokens .scope == "read admin"
2858+ stored = await mock_storage .get_tokens ()
2859+ assert stored is not None
2860+ assert stored .scope == "read admin"
2861+
2862+
2863+ @pytest .mark .anyio
2864+ async def test_handle_refresh_response_carries_prior_scope_when_response_omits_it (
2865+ oauth_provider : OAuthClientProvider , mock_storage : MockTokenStorage
2866+ ):
2867+ """RFC 6749 §6: an omitted refresh-response scope means scope is unchanged from the prior token."""
2868+ oauth_provider .context .current_tokens = OAuthToken (access_token = "old" , scope = "read write" )
2869+ response = httpx .Response (
2870+ 200 ,
2871+ json = {"access_token" : "new" , "token_type" : "Bearer" , "expires_in" : 3600 },
2872+ request = httpx .Request ("POST" , "https://auth.example.com/token" ),
2873+ )
2874+ ok = await oauth_provider ._handle_refresh_response (response )
2875+
2876+ assert ok is True
2877+ assert oauth_provider .context .current_tokens is not None
2878+ assert oauth_provider .context .current_tokens .access_token == "new"
2879+ assert oauth_provider .context .current_tokens .scope == "read write"
2880+ stored = await mock_storage .get_tokens ()
2881+ assert stored is not None
2882+ assert stored .scope == "read write"
2883+
2884+
2885+ @pytest .mark .anyio
2886+ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed (
2887+ oauth_provider : OAuthClientProvider ,
2888+ ):
2889+ """SEP-2352: on the legacy no-PRM path the binding check uses the ASM-discovered issuer.
2890+
2891+ PRM discovery fails (404) so ``auth_server_url`` stays ``None`` and the post-PRM check is
2892+ skipped; when ASM discovery then succeeds via the root well-known fallback, the discovered
2893+ metadata's issuer is compared against the stored credentials' bound issuer and a mismatch
2894+ triggers re-registration.
2895+ """
2896+ oauth_provider .context .current_tokens = None
2897+ oauth_provider .context .token_expiry_time = None
2898+ oauth_provider ._initialized = True
2899+ oauth_provider .context .client_info = OAuthClientInformationFull (
2900+ client_id = "stale-client" ,
2901+ redirect_uris = [AnyUrl ("http://localhost:3030/callback" )],
2902+ issuer = "https://old-as.example.com" ,
2903+ )
2904+
2905+ auth_flow = oauth_provider .async_auth_flow (httpx .Request ("GET" , "https://api.example.com/v1/mcp" ))
2906+ request = await auth_flow .__anext__ ()
2907+ response_401 = httpx .Response (401 , request = request )
2908+
2909+ # PRM discovery: path-based then root, both 404.
2910+ prm_req = await auth_flow .asend (response_401 )
2911+ assert str (prm_req .url ) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
2912+ prm_req = await auth_flow .asend (httpx .Response (404 , request = prm_req ))
2913+ assert str (prm_req .url ) == "https://api.example.com/.well-known/oauth-protected-resource"
2914+
2915+ # ASM discovery via root fallback (no auth_server_url) succeeds with a different issuer.
2916+ asm_req = await auth_flow .asend (httpx .Response (404 , request = prm_req ))
2917+ assert str (asm_req .url ) == "https://api.example.com/.well-known/oauth-authorization-server"
2918+ asm_response = httpx .Response (
2919+ 200 ,
2920+ content = (
2921+ b'{"issuer": "https://api.example.com", '
2922+ b'"authorization_endpoint": "https://api.example.com/authorize", '
2923+ b'"token_endpoint": "https://api.example.com/token", '
2924+ b'"registration_endpoint": "https://api.example.com/register"}'
2925+ ),
2926+ request = asm_req ,
2927+ )
2928+
2929+ # The stale bound credentials are discarded, so the next yield is a DCR request
2930+ # rather than the authorize redirect.
2931+ next_req = await auth_flow .asend (asm_response )
2932+ assert oauth_provider .context .auth_server_url is None
2933+ assert next_req .method == "POST"
2934+ assert str (next_req .url ) == "https://api.example.com/register"
2935+ await auth_flow .aclose ()
0 commit comments