1313namespace ModelContextProtocol . Authentication ;
1414
1515/// <summary>
16- /// A generic implementation of an OAuth authorization provider for MCP. This does not do any advanced token
17- /// protection or caching - it acquires a token and server metadata and holds it in memory.
18- /// This is suitable for demonstration and development purposes.
16+ /// A generic implementation of an OAuth authorization provider.
1917/// </summary>
2018internal sealed partial class ClientOAuthProvider
2119{
@@ -24,6 +22,8 @@ internal sealed partial class ClientOAuthProvider
2422 /// </summary>
2523 private const string BearerScheme = "Bearer" ;
2624
25+ private static readonly string [ ] s_wellKnownPaths = [ ".well-known/openid-configuration" , ".well-known/oauth-authorization-server" ] ;
26+
2727 private readonly Uri _serverUrl ;
2828 private readonly Uri _redirectUri ;
2929 private readonly string [ ] ? _scopes ;
@@ -43,7 +43,7 @@ internal sealed partial class ClientOAuthProvider
4343 private string ? _clientId ;
4444 private string ? _clientSecret ;
4545
46- private TokenContainer ? _token ;
46+ private ITokenCache _tokenCache ;
4747 private AuthorizationServerMetadata ? _authServerMetadata ;
4848
4949 /// <summary>
@@ -57,11 +57,11 @@ internal sealed partial class ClientOAuthProvider
5757 public ClientOAuthProvider (
5858 Uri serverUrl ,
5959 ClientOAuthOptions options ,
60- HttpClient ? httpClient = null ,
60+ HttpClient httpClient ,
6161 ILoggerFactory ? loggerFactory = null )
6262 {
6363 _serverUrl = serverUrl ?? throw new ArgumentNullException ( nameof ( serverUrl ) ) ;
64- _httpClient = httpClient ?? new HttpClient ( ) ;
64+ _httpClient = httpClient ;
6565 _logger = ( ILogger ? ) loggerFactory ? . CreateLogger < ClientOAuthProvider > ( ) ?? NullLogger . Instance ;
6666
6767 if ( options is null )
@@ -85,6 +85,7 @@ public ClientOAuthProvider(
8585 _dcrClientUri = options . DynamicClientRegistration ? . ClientUri ;
8686 _dcrInitialAccessToken = options . DynamicClientRegistration ? . InitialAccessToken ;
8787 _dcrResponseDelegate = options . DynamicClientRegistration ? . ResponseDelegate ;
88+ _tokenCache = options . TokenCache ?? new InMemoryTokenCache ( ) ;
8889 }
8990
9091 /// <summary>
@@ -138,20 +139,21 @@ public ClientOAuthProvider(
138139 {
139140 ThrowIfNotBearerScheme ( scheme ) ;
140141
142+ var tokens = await _tokenCache . GetTokensAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
143+
141144 // Return the token if it's valid
142- if ( _token != null && _token . ExpiresAt > DateTimeOffset . UtcNow . AddMinutes ( 5 ) )
145+ if ( tokens is not null && ! tokens . IsExpired )
143146 {
144- return _token . AccessToken ;
147+ return tokens . AccessToken ;
145148 }
146149
147- // Try to refresh the token if we have a refresh token
148- if ( _token ? . RefreshToken != null && _authServerMetadata != null )
150+ // Try to refresh the access token if it is invalid and we have a refresh token.
151+ if ( tokens ? . RefreshToken != null && _authServerMetadata != null )
149152 {
150- var newToken = await RefreshTokenAsync ( _token . RefreshToken , resourceUri , _authServerMetadata , cancellationToken ) . ConfigureAwait ( false ) ;
151- if ( newToken != null )
153+ var newTokens = await RefreshTokenAsync ( tokens . RefreshToken , resourceUri , _authServerMetadata , cancellationToken ) . ConfigureAwait ( false ) ;
154+ if ( newTokens is not null )
152155 {
153- _token = newToken ;
154- return _token . AccessToken ;
156+ return newTokens . AccessToken ;
155157 }
156158 }
157159
@@ -174,12 +176,7 @@ public async Task HandleUnauthorizedResponseAsync(
174176 HttpResponseMessage response ,
175177 CancellationToken cancellationToken = default )
176178 {
177- // This provider only supports Bearer scheme
178- if ( ! string . Equals ( scheme , BearerScheme , StringComparison . OrdinalIgnoreCase ) )
179- {
180- throw new InvalidOperationException ( "This credential provider only supports the Bearer scheme" ) ;
181- }
182-
179+ ThrowIfNotBearerScheme ( scheme ) ;
183180 await PerformOAuthAuthorizationAsync ( response , cancellationToken ) . ConfigureAwait ( false ) ;
184181 }
185182
@@ -223,26 +220,29 @@ private async Task PerformOAuthAuthorizationAsync(
223220 // Store auth server metadata for future refresh operations
224221 _authServerMetadata = authServerMetadata ;
225222
223+ // The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
224+ if ( await _tokenCache . GetTokensAsync ( cancellationToken ) . ConfigureAwait ( false ) is { RefreshToken : { } refreshToken } )
225+ {
226+ var refreshedTokens = await RefreshTokenAsync ( refreshToken , protectedResourceMetadata . Resource , authServerMetadata , cancellationToken ) . ConfigureAwait ( false ) ;
227+ if ( refreshedTokens is not null )
228+ {
229+ // A non-null result indicates the refresh succeeded and the new tokens have been stored.
230+ return ;
231+ }
232+ }
233+
226234 // Perform dynamic client registration if needed
227235 if ( string . IsNullOrEmpty ( _clientId ) )
228236 {
229237 await PerformDynamicClientRegistrationAsync ( authServerMetadata , cancellationToken ) . ConfigureAwait ( false ) ;
230238 }
231239
232240 // Perform the OAuth flow
233- var token = await InitiateAuthorizationCodeFlowAsync ( protectedResourceMetadata , authServerMetadata , cancellationToken ) . ConfigureAwait ( false ) ;
234-
235- if ( token is null )
236- {
237- ThrowFailedToHandleUnauthorizedResponse ( $ "The { nameof ( AuthorizationRedirectDelegate ) } returned a null or empty token.") ;
238- }
241+ await InitiateAuthorizationCodeFlowAsync ( protectedResourceMetadata , authServerMetadata , cancellationToken ) . ConfigureAwait ( false ) ;
239242
240- _token = token ;
241243 LogOAuthAuthorizationCompleted ( ) ;
242244 }
243245
244- private static readonly string [ ] s_wellKnownPaths = [ ".well-known/openid-configuration" , ".well-known/oauth-authorization-server" ] ;
245-
246246 private async Task < AuthorizationServerMetadata > GetAuthServerMetadataAsync ( Uri authServerUri , CancellationToken cancellationToken )
247247 {
248248 if ( authServerUri . OriginalString . Length == 0 ||
@@ -298,7 +298,7 @@ private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri a
298298 throw new McpException ( $ "Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{ authServerUri } '") ;
299299 }
300300
301- private async Task < TokenContainer > RefreshTokenAsync ( string refreshToken , Uri resourceUri , AuthorizationServerMetadata authServerMetadata , CancellationToken cancellationToken )
301+ private async Task < TokenContainer ? > RefreshTokenAsync ( string refreshToken , Uri resourceUri , AuthorizationServerMetadata authServerMetadata , CancellationToken cancellationToken )
302302 {
303303 var requestContent = new FormUrlEncodedContent ( new Dictionary < string , string >
304304 {
@@ -314,10 +314,17 @@ private async Task<TokenContainer> RefreshTokenAsync(string refreshToken, Uri re
314314 Content = requestContent
315315 } ;
316316
317- return await FetchTokenAsync ( request , cancellationToken ) . ConfigureAwait ( false ) ;
317+ using var httpResponse = await _httpClient . SendAsync ( request , cancellationToken ) . ConfigureAwait ( false ) ;
318+
319+ if ( ! httpResponse . IsSuccessStatusCode )
320+ {
321+ return null ;
322+ }
323+
324+ return await HandleSuccessfulTokenResponseAsync ( httpResponse , cancellationToken ) . ConfigureAwait ( false ) ;
318325 }
319326
320- private async Task < TokenContainer ? > InitiateAuthorizationCodeFlowAsync (
327+ private async Task InitiateAuthorizationCodeFlowAsync (
321328 ProtectedResourceMetadata protectedResourceMetadata ,
322329 AuthorizationServerMetadata authServerMetadata ,
323330 CancellationToken cancellationToken )
@@ -330,10 +337,10 @@ private async Task<TokenContainer> RefreshTokenAsync(string refreshToken, Uri re
330337
331338 if ( string . IsNullOrEmpty ( authCode ) )
332339 {
333- return null ;
340+ ThrowFailedToHandleUnauthorizedResponse ( $ "The { nameof ( AuthorizationRedirectDelegate ) } returned a null or empty authorization code." ) ;
334341 }
335342
336- return await ExchangeCodeForTokenAsync ( protectedResourceMetadata , authServerMetadata , authCode ! , codeVerifier , cancellationToken ) . ConfigureAwait ( false ) ;
343+ await ExchangeCodeForTokenAsync ( protectedResourceMetadata , authServerMetadata , authCode ! , codeVerifier , cancellationToken ) . ConfigureAwait ( false ) ;
337344 }
338345
339346 private Uri BuildAuthorizationUrl (
@@ -377,7 +384,7 @@ private Uri BuildAuthorizationUrl(
377384 return uriBuilder . Uri ;
378385 }
379386
380- private async Task < TokenContainer > ExchangeCodeForTokenAsync (
387+ private async Task ExchangeCodeForTokenAsync (
381388 ProtectedResourceMetadata protectedResourceMetadata ,
382389 AuthorizationServerMetadata authServerMetadata ,
383390 string authorizationCode ,
@@ -400,24 +407,39 @@ private async Task<TokenContainer> ExchangeCodeForTokenAsync(
400407 Content = requestContent
401408 } ;
402409
403- return await FetchTokenAsync ( request , cancellationToken ) . ConfigureAwait ( false ) ;
404- }
405-
406- private async Task < TokenContainer > FetchTokenAsync ( HttpRequestMessage request , CancellationToken cancellationToken )
407- {
408410 using var httpResponse = await _httpClient . SendAsync ( request , cancellationToken ) . ConfigureAwait ( false ) ;
409411 httpResponse . EnsureSuccessStatusCode ( ) ;
412+ await HandleSuccessfulTokenResponseAsync ( httpResponse , cancellationToken ) . ConfigureAwait ( false ) ;
413+ }
410414
411- using var stream = await httpResponse . Content . ReadAsStreamAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
412- var tokenResponse = await JsonSerializer . DeserializeAsync ( stream , McpJsonUtilities . JsonContext . Default . TokenContainer , cancellationToken ) . ConfigureAwait ( false ) ;
415+ private async Task < TokenContainer > HandleSuccessfulTokenResponseAsync ( HttpResponseMessage response , CancellationToken cancellationToken )
416+ {
417+ using var stream = await response . Content . ReadAsStreamAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
418+ var tokenResponse = await JsonSerializer . DeserializeAsync ( stream , McpJsonUtilities . JsonContext . Default . TokenResponse , cancellationToken ) . ConfigureAwait ( false ) ;
413419
414420 if ( tokenResponse is null )
415421 {
416- ThrowFailedToHandleUnauthorizedResponse ( $ "The token endpoint '{ request . RequestUri } ' returned an empty response.") ;
422+ ThrowFailedToHandleUnauthorizedResponse ( $ "The token endpoint '{ response . RequestMessage ? . RequestUri } ' returned an empty response.") ;
423+ }
424+
425+ if ( tokenResponse . TokenType is null || ! string . Equals ( tokenResponse . TokenType , BearerScheme , StringComparison . OrdinalIgnoreCase ) )
426+ {
427+ ThrowFailedToHandleUnauthorizedResponse ( $ "The token endpoint '{ response . RequestMessage ? . RequestUri } ' returned an unsupported token type: '{ tokenResponse . TokenType ?? "<null>" } '. Only 'Bearer' tokens are supported.") ;
417428 }
418429
419- tokenResponse . ObtainedAt = DateTimeOffset . UtcNow ;
420- return tokenResponse ;
430+ TokenContainer tokens = new ( )
431+ {
432+ AccessToken = tokenResponse . AccessToken ,
433+ RefreshToken = tokenResponse . RefreshToken ,
434+ ExpiresIn = tokenResponse . ExpiresIn ,
435+ TokenType = tokenResponse . TokenType ,
436+ Scope = tokenResponse . Scope ,
437+ ObtainedAt = DateTimeOffset . UtcNow ,
438+ } ;
439+
440+ await _tokenCache . StoreTokensAsync ( tokens , cancellationToken ) . ConfigureAwait ( false ) ;
441+
442+ return tokens ;
421443 }
422444
423445 /// <summary>
@@ -581,7 +603,7 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
581603 string ? resourceMetadataUrl = null ;
582604 foreach ( var header in response . Headers . WwwAuthenticate )
583605 {
584- if ( string . Equals ( header . Scheme , "Bearer" , StringComparison . OrdinalIgnoreCase ) && ! string . IsNullOrEmpty ( header . Parameter ) )
606+ if ( string . Equals ( header . Scheme , BearerScheme , StringComparison . OrdinalIgnoreCase ) && ! string . IsNullOrEmpty ( header . Parameter ) )
585607 {
586608 resourceMetadataUrl = ParseWwwAuthenticateParameters ( header . Parameter , "resource_metadata" ) ;
587609 if ( resourceMetadataUrl != null )
0 commit comments