@@ -417,13 +417,13 @@ private async Task<string> GetAccessTokenCoreAsync(HttpResponseMessage response,
417417 return steppedUpToken . AccessToken ;
418418 }
419419
420- // The step-up that already requested these scopes may still be pending: the request
421- // that started it was canceled while the user was completing it. Its outcome is what
422- // will satisfy this challenge, so join it rather than reject the challenge as repeated.
423- if ( _inFlightAuthorizationCodeFlow is { IsCompleted : false } pendingStepUp &&
424- InFlightAuthorizationCodeFlowCoversChallenge ( protectedResourceMetadata ) )
420+ // The step-up that already requested these scopes may still be pending ( the request
421+ // that started it was canceled while the user was completing it) or may have completed
422+ // since the cache was read above. Either way its outcome is what satisfies this
423+ // challenge, so reuse it rather than reject the challenge as repeated.
424+ if ( await TryReuseInFlightAuthorizationCodeFlowAsync ( protectedResourceMetadata , usedAccessToken , cancellationToken ) . ConfigureAwait ( false ) is { } steppedUpAccessToken )
425425 {
426- return await pendingStepUp . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
426+ return steppedUpAccessToken ;
427427 }
428428
429429 ThrowFailedToHandleUnauthorizedResponse (
@@ -519,29 +519,60 @@ private async Task<string> GetAccessTokenCoreAsync(HttpResponseMessage response,
519519 // Store auth server metadata for future refresh operations
520520 _authServerMetadata = authServerMetadata ;
521521
522- // Perform the OAuth flow. A caller that reaches this point while a previous caller's flow is
523- // still pending (that caller's request was canceled mid- flow, releasing the lock) joins the
524- // in-flight flow instead of starting a competing one, provided the flow requested every scope
525- // this challenge needs; see the _inFlightAuthorizationCodeFlow comment.
526- var flow = _inFlightAuthorizationCodeFlow ;
527- if ( flow is { IsCompleted : false } && ! InFlightAuthorizationCodeFlowCoversChallenge ( protectedResourceMetadata ) )
522+ // Perform the OAuth flow, unless the in-flight flow already satisfies this challenge: a caller
523+ // that reaches this point while a previous caller's flow is still pending (that caller's
524+ // request was canceled mid-flow, releasing the lock) joins it instead of starting a competing
525+ // one, and a flow that completed during the metadata work above has cached the token this
526+ // challenge needs. See the _inFlightAuthorizationCodeFlow comment.
527+ if ( await TryReuseInFlightAuthorizationCodeFlowAsync ( protectedResourceMetadata , usedAccessToken , cancellationToken ) . ConfigureAwait ( false ) is { } reusedAccessToken )
528528 {
529- // The pending flow's token cannot satisfy this challenge, but two interactive flows must
530- // never be presented at once, so let it settle first. Its outcome, success or failure, is
531- // reported to the callers that joined it and is irrelevant here (Task.WhenAny never
532- // faults); this challenge then runs its own step-up for the accumulated scopes.
533- await Task . WhenAny ( flow ) . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
534- flow = null ;
529+ return reusedAccessToken ;
535530 }
536531
537- if ( flow is null || flow . IsCompleted )
532+ if ( _inFlightAuthorizationCodeFlow is { IsCompleted : false } pendingFlow )
538533 {
539- _inFlightAuthorizationCodeFlow = flow = StartAuthorizationCodeFlow ( protectedResourceMetadata , authServerMetadata , _disposeCts . Token ) ;
534+ // The pending flow did not request a scope this challenge needs, so its token cannot
535+ // satisfy it, but two interactive flows must never be presented at once: let it settle
536+ // first. Its outcome, success or failure, is reported to the callers that joined it and
537+ // is irrelevant here (Task.WhenAny never faults).
538+ await Task . WhenAny ( pendingFlow ) . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
540539 }
541540
541+ var flow = _inFlightAuthorizationCodeFlow = StartAuthorizationCodeFlow ( protectedResourceMetadata , authServerMetadata , _disposeCts . Token ) ;
542542 return await flow . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
543543 }
544544
545+ /// <summary>
546+ /// Satisfies the current challenge from the in-flight authorization-code flow when that flow requested
547+ /// every scope the challenge needs: joins the flow while it is pending, or reuses the token it cached
548+ /// if it completed after the caller last consulted the cache. Returns <see langword="null"/> when there
549+ /// is no such flow or nothing usable came of it, in which case the caller runs a flow of its own.
550+ /// </summary>
551+ private async Task < string ? > TryReuseInFlightAuthorizationCodeFlowAsync ( ProtectedResourceMetadata protectedResourceMetadata , string ? usedAccessToken , CancellationToken cancellationToken )
552+ {
553+ if ( _inFlightAuthorizationCodeFlow is not { } flow || ! InFlightAuthorizationCodeFlowCoversChallenge ( protectedResourceMetadata ) )
554+ {
555+ return null ;
556+ }
557+
558+ if ( ! flow . IsCompleted )
559+ {
560+ return await flow . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
561+ }
562+
563+ // A flow that ran to completion stored its token, and a token other than the one this challenge
564+ // rejected is worth retrying with. A flow that faulted or was canceled stored nothing, and a
565+ // long-completed flow's token is the rejected one itself.
566+ if ( flow . Status == TaskStatus . RanToCompletion &&
567+ await _tokenCache . GetTokensAsync ( cancellationToken ) . ConfigureAwait ( false ) is { IsExpired : false } cached &&
568+ ! string . Equals ( cached . AccessToken , usedAccessToken , StringComparison . Ordinal ) )
569+ {
570+ return cached . AccessToken ;
571+ }
572+
573+ return null ;
574+ }
575+
545576 /// <summary>
546577 /// Returns whether the in-flight authorization-code flow requested every scope the current challenge
547578 /// requires, so that joining it can satisfy the challenge. A challenge that names no concrete scope is
@@ -792,10 +823,12 @@ private Task<string> StartAuthorizationCodeFlow(
792823 var codeChallenge = GenerateCodeChallenge ( codeVerifier ) ;
793824 var state = GenerateRandomBase64UrlValue ( ) ;
794825
795- // Building the URL folds this challenge's scopes into _accumulatedScopes, so the accumulated set
796- // is now exactly the set of scopes this flow asks the authorization server for.
797- var authUrl = BuildAuthorizationUrl ( protectedResourceMetadata , authServerMetadata , codeChallenge , state ) ;
798- _inFlightAuthorizationCodeFlowScopes = new HashSet < string > ( _accumulatedScopes , StringComparer . Ordinal ) ;
826+ // Record the scopes this flow actually asks the authorization server for: the effective scope
827+ // placed in the URL, which offline_access augmentation and any ScopeSelector can make differ
828+ // from _accumulatedScopes.
829+ var scope = ComputeEffectiveScope ( protectedResourceMetadata , authServerMetadata ) ;
830+ var authUrl = BuildAuthorizationUrl ( protectedResourceMetadata , authServerMetadata , codeChallenge , state , scope ) ;
831+ _inFlightAuthorizationCodeFlowScopes = new HashSet < string > ( scope is null ? [ ] : SplitScopes ( scope ) , StringComparer . Ordinal ) ;
799832
800833 return CompleteAuthorizationCodeFlowAsync ( protectedResourceMetadata , authServerMetadata , authUrl , state , codeVerifier , cancellationToken ) ;
801834 }
@@ -848,7 +881,8 @@ private Uri BuildAuthorizationUrl(
848881 ProtectedResourceMetadata protectedResourceMetadata ,
849882 AuthorizationServerMetadata authServerMetadata ,
850883 string codeChallenge ,
851- string state )
884+ string state ,
885+ string ? scope )
852886 {
853887 var resourceUri = GetResourceUri ( protectedResourceMetadata ) ;
854888
@@ -867,7 +901,6 @@ private Uri BuildAuthorizationUrl(
867901 queryParamsDictionary [ "resource" ] = resourceUri ;
868902 }
869903
870- var scope = ComputeEffectiveScope ( protectedResourceMetadata , authServerMetadata ) ;
871904 if ( ! string . IsNullOrEmpty ( scope ) )
872905 {
873906 queryParamsDictionary [ "scope" ] = scope ! ;
0 commit comments