@@ -48,6 +48,14 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
4848 private string ? _tokenEndpointAuthMethod ;
4949 private ITokenCache _tokenCache ;
5050 private AuthorizationServerMetadata ? _authServerMetadata ;
51+ // The accumulated scope set lives for this provider's lifetime and is intentionally not keyed by
52+ // resource or authorization server. This is safe today because one ClientOAuthProvider is created
53+ // per HttpClientTransport, i.e. per endpoint/resource. If a provider were ever reused across
54+ // multiple resources or auth servers, accumulated scopes could be sent to a server that rejects
55+ // them (invalid_scope). Accumulation is scoped per "resource and operation" combination (SEP-2350).
56+ private readonly HashSet < string > _accumulatedScopes = new ( StringComparer . Ordinal ) ;
57+ private readonly object _scopeAccumulatorLock = new ( ) ;
58+ private bool _hasAttemptedStepUp ;
5159
5260 /// <summary>
5361 /// Initializes a new instance of the <see cref="ClientOAuthProvider"/> class using the specified options.
@@ -245,6 +253,28 @@ private async Task<string> GetAccessTokenAsync(HttpResponseMessage response, boo
245253 ThrowFailedToHandleUnauthorizedResponse ( "No authorization servers found in authentication challenge" ) ;
246254 }
247255
256+ // SEP-2350: A step-up may legitimately introduce new scopes, so at least one interactive
257+ // (re-)authorization attempt is always allowed. However, once a step-up has already been
258+ // attempted, a subsequent insufficient_scope challenge that introduces no scope beyond those
259+ // already requested cannot make progress by re-running authorization. Treat that repeated,
260+ // unproductive challenge as a permanent authorization failure instead of prompting the user
261+ // again for the same resource and operation combination.
262+ if ( response . StatusCode == System . Net . HttpStatusCode . Forbidden )
263+ {
264+ bool introducesNewScopes = ChallengeIntroducesNewScopes ( protectedResourceMetadata ) ;
265+ lock ( _scopeAccumulatorLock )
266+ {
267+ if ( _hasAttemptedStepUp && ! introducesNewScopes )
268+ {
269+ ThrowFailedToHandleUnauthorizedResponse (
270+ "A repeated insufficient_scope challenge added no scope beyond those already requested, " +
271+ "so step-up authorization cannot satisfy the request." ) ;
272+ }
273+
274+ _hasAttemptedStepUp = true ;
275+ }
276+ }
277+
248278 // Convert string URIs to Uri objects for the selector
249279 List < Uri > authServerUris = [ ] ;
250280 foreach ( var serverUriString in availableAuthorizationServers )
@@ -729,17 +759,93 @@ private async Task PerformDynamicClientRegistrationAsync(
729759 }
730760
731761 private string ? GetScopeParameter ( ProtectedResourceMetadata protectedResourceMetadata )
762+ {
763+ // Determine the scopes for the current operation from the challenge or metadata.
764+ var currentOperationScopes = GetCurrentOperationScopes ( protectedResourceMetadata ) ;
765+
766+ if ( currentOperationScopes . Count == 0 )
767+ {
768+ lock ( _scopeAccumulatorLock )
769+ {
770+ // If we have previously requested scopes but nothing new, return the accumulated set.
771+ return _accumulatedScopes . Count > 0
772+ ? string . Join ( " " , _accumulatedScopes . OrderBy ( s => s , StringComparer . Ordinal ) )
773+ : null ;
774+ }
775+ }
776+
777+ // Per SEP-2350: Compute the union of previously requested scopes and newly challenged scopes
778+ // to avoid losing permissions needed for other operations during step-up authorization.
779+ // Note: the accumulator stores only server-challenged / scopes_supported / configured scopes.
780+ // offline_access (AugmentScopeWithOfflineAccess) and any ScopeSelector are applied per request
781+ // in ComputeEffectiveScope and are intentionally not accumulated, so the selector always sees
782+ // the full union and the operation stays idempotent.
783+ lock ( _scopeAccumulatorLock )
784+ {
785+ foreach ( var scope in currentOperationScopes )
786+ {
787+ _accumulatedScopes . Add ( scope ) ;
788+ }
789+
790+ // Sort scopes for stable, deterministic output (scopes are unordered per RFC 6749 §3.3).
791+ return string . Join ( " " , _accumulatedScopes . OrderBy ( s => s , StringComparer . Ordinal ) ) ;
792+ }
793+ }
794+
795+ /// <summary>
796+ /// Determines the scopes required for the current operation, preferring the <c>WWW-Authenticate</c>
797+ /// challenge scope, then <c>scopes_supported</c> from the protected resource metadata, then the
798+ /// configured scopes. Returns the individual scope tokens so callers can compare and accumulate them
799+ /// without re-joining and re-splitting. This does not mutate the accumulated scope set.
800+ /// </summary>
801+ private IReadOnlyList < string > GetCurrentOperationScopes ( ProtectedResourceMetadata protectedResourceMetadata )
732802 {
733803 if ( ! string . IsNullOrEmpty ( protectedResourceMetadata . WwwAuthenticateScope ) )
734804 {
735- return protectedResourceMetadata . WwwAuthenticateScope ;
805+ return SplitScopes ( protectedResourceMetadata . WwwAuthenticateScope ! ) ;
736806 }
737- else if ( protectedResourceMetadata . ScopesSupported . Count > 0 )
807+
808+ var scopesSupported = protectedResourceMetadata . ScopesSupported ;
809+ if ( scopesSupported . Count > 0 )
738810 {
739- return string . Join ( " " , protectedResourceMetadata . ScopesSupported ) ;
811+ // scopes_supported is already a list of individual scopes; avoid join/split round-tripping.
812+ return scopesSupported as IReadOnlyList < string > ?? [ .. scopesSupported ] ;
740813 }
741814
742- return _configuredScopes ;
815+ return _configuredScopes is null ? [ ] : SplitScopes ( _configuredScopes ) ;
816+ }
817+
818+ private static string [ ] SplitScopes ( string scopes ) =>
819+ scopes . Split ( new [ ] { ' ' } , StringSplitOptions . RemoveEmptyEntries ) ;
820+
821+ /// <summary>
822+ /// Returns <see langword="true"/> if the current challenge requires at least one scope that has not
823+ /// already been requested in a previous (re-)authorization. The caller combines this with step-up
824+ /// attempt tracking: per SEP-2350, a step-up that adds a new scope is always allowed, but once a
825+ /// step-up has been attempted, a later challenge that adds no new scope is treated as a permanent
826+ /// failure because re-running interactive authorization cannot make progress.
827+ /// </summary>
828+ private bool ChallengeIntroducesNewScopes ( ProtectedResourceMetadata protectedResourceMetadata )
829+ {
830+ var currentOperationScopes = GetCurrentOperationScopes ( protectedResourceMetadata ) ;
831+ if ( currentOperationScopes . Count == 0 )
832+ {
833+ // No concrete scope to request, so a re-authorization cannot add anything new.
834+ return false ;
835+ }
836+
837+ lock ( _scopeAccumulatorLock )
838+ {
839+ foreach ( var scope in currentOperationScopes )
840+ {
841+ if ( ! _accumulatedScopes . Contains ( scope ) )
842+ {
843+ return true ;
844+ }
845+ }
846+ }
847+
848+ return false ;
743849 }
744850
745851 /// <summary>
0 commit comments