Skip to content

Commit d4cc3ad

Browse files
committed
Multiple scheme support
1 parent f8650f9 commit d4cc3ad

File tree

4 files changed

+204
-48
lines changed

4 files changed

+204
-48
lines changed

samples/ProtectedMCPClient/BasicOAuthAuthorizationProvider.cs

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,38 +30,35 @@ public class BasicOAuthAuthorizationProvider(
3030
private readonly HttpClient _httpClient = new();
3131

3232
private TokenContainer? _token;
33-
3433
private AuthorizationServerMetadata? _authServerMetadata;
3534

36-
public string AuthorizationScheme => "Bearer";
35+
/// <inheritdoc />
36+
public IEnumerable<string> SupportedSchemes => new[] { "DPoP" };
3737

3838
/// <inheritdoc />
39-
public async Task<string?> GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default)
39+
public Task<string?> GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default)
4040
{
41-
// Return the token if it's valid
42-
if (_token != null && _token.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5))
41+
// This provider only supports Bearer tokens
42+
if (scheme != "Bearer")
4343
{
44-
return _token.AccessToken;
44+
return Task.FromResult<string?>(null);
4545
}
46-
47-
// Try to refresh the token if we have a refresh token
48-
if (_token?.RefreshToken != null && _authServerMetadata != null)
49-
{
50-
var newToken = await RefreshTokenAsync(_token.RefreshToken, _authServerMetadata, cancellationToken);
51-
if (newToken != null)
52-
{
53-
_token = newToken;
54-
return _token.AccessToken;
55-
}
56-
}
57-
58-
// No valid token - auth handler will trigger the 401 flow
59-
return null;
46+
47+
return GetBearerTokenAsync(cancellationToken);
6048
}
6149

6250
/// <inheritdoc />
63-
public async Task<bool> HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
51+
public async Task<(bool Success, string? RecommendedScheme)> HandleUnauthorizedResponseAsync(
52+
HttpResponseMessage response,
53+
string scheme,
54+
CancellationToken cancellationToken = default)
6455
{
56+
// This provider only supports Bearer scheme
57+
if (scheme != "Bearer")
58+
{
59+
return (false, null);
60+
}
61+
6562
try
6663
{
6764
// Get the metadata from the challenge
@@ -84,20 +81,43 @@ public async Task<bool> HandleUnauthorizedResponseAsync(HttpResponseMessage resp
8481
if (token != null)
8582
{
8683
_token = token;
87-
return true;
84+
return (true, "Bearer");
8885
}
8986
}
9087
}
9188

92-
return false;
89+
return (false, null);
9390
}
9491
catch (Exception ex)
9592
{
9693
Console.WriteLine($"Error handling auth challenge: {ex.Message}");
97-
return false;
94+
return (false, null);
9895
}
9996
}
10097

98+
private async Task<string?> GetBearerTokenAsync(CancellationToken cancellationToken = default)
99+
{
100+
// Return the token if it's valid
101+
if (_token != null && _token.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5))
102+
{
103+
return _token.AccessToken;
104+
}
105+
106+
// Try to refresh the token if we have a refresh token
107+
if (_token?.RefreshToken != null && _authServerMetadata != null)
108+
{
109+
var newToken = await RefreshTokenAsync(_token.RefreshToken, _authServerMetadata, cancellationToken);
110+
if (newToken != null)
111+
{
112+
_token = newToken;
113+
return _token.AccessToken;
114+
}
115+
}
116+
117+
// No valid token - auth handler will trigger the 401 flow
118+
return null;
119+
}
120+
101121
private async Task<AuthorizationServerMetadata?> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
102122
{
103123
var baseUrl = authServerUri.ToString();

src/ModelContextProtocol/Authentication/AuthorizationDelegatingHandler.cs

Lines changed: 104 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using ModelContextProtocol.Authentication.Types;
12
using System.Net.Http.Headers;
23

34
namespace ModelContextProtocol.Authentication;
@@ -8,6 +9,7 @@ namespace ModelContextProtocol.Authentication;
89
public class AuthorizationDelegatingHandler : DelegatingHandler
910
{
1011
private readonly IMcpAuthorizationProvider _authorizationProvider;
12+
private string? _currentScheme;
1113

1214
/// <summary>
1315
/// Initializes a new instance of the <see cref="AuthorizationDelegatingHandler"/> class.
@@ -16,50 +18,137 @@ public class AuthorizationDelegatingHandler : DelegatingHandler
1618
public AuthorizationDelegatingHandler(IMcpAuthorizationProvider authorizationProvider)
1719
{
1820
_authorizationProvider = authorizationProvider ?? throw new ArgumentNullException(nameof(authorizationProvider));
21+
22+
// Select first supported scheme as the default
23+
_currentScheme = _authorizationProvider.SupportedSchemes.FirstOrDefault();
24+
if (_currentScheme == null)
25+
{
26+
throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(authorizationProvider));
27+
}
1928
}
2029

2130
/// <summary>
2231
/// Sends an HTTP request with authentication handling.
2332
/// </summary>
2433
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
2534
{
26-
if (request.Headers.Authorization == null)
35+
if (request.Headers.Authorization == null && _currentScheme != null)
2736
{
28-
await AddAuthorizationHeaderAsync(request, cancellationToken);
37+
await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken);
2938
}
3039

3140
var response = await base.SendAsync(request, cancellationToken);
3241

3342
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
3443
{
35-
var handled = await _authorizationProvider.HandleUnauthorizedResponseAsync(
36-
response,
37-
cancellationToken);
38-
39-
if (handled)
44+
// Gather the schemes the server wants us to use from WWW-Authenticate headers
45+
var serverSchemes = ExtractServerSupportedSchemes(response);
46+
47+
// Find the intersection between what the server supports and what our provider supports
48+
var supportedSchemes = _authorizationProvider.SupportedSchemes.ToList();
49+
string? bestSchemeMatch = null;
50+
51+
// First try to find a direct match with the current scheme if it's still valid
52+
string schemeUsed = request.Headers.Authorization?.Scheme ?? _currentScheme ?? string.Empty;
53+
if (serverSchemes.Contains(schemeUsed) && supportedSchemes.Contains(schemeUsed))
4054
{
41-
var retryRequest = await CloneHttpRequestMessageAsync(request);
42-
43-
await AddAuthorizationHeaderAsync(retryRequest, cancellationToken);
44-
45-
return await base.SendAsync(retryRequest, cancellationToken);
55+
bestSchemeMatch = schemeUsed;
56+
}
57+
else
58+
{
59+
// Try to find any matching scheme between server and provider
60+
bestSchemeMatch = serverSchemes.FirstOrDefault(scheme => supportedSchemes.Contains(scheme));
61+
62+
// If still no match, default to the provider's preferred scheme
63+
if (bestSchemeMatch == null && serverSchemes.Count > 0)
64+
{
65+
throw new AuthenticationSchemeMismatchException(
66+
$"No matching authentication scheme found. Server supports: [{string.Join(", ", serverSchemes)}], " +
67+
$"Provider supports: [{string.Join(", ", supportedSchemes)}].",
68+
serverSchemes,
69+
supportedSchemes);
70+
}
71+
else if (bestSchemeMatch == null)
72+
{
73+
// If the server didn't specify any schemes, use the provider's default
74+
bestSchemeMatch = supportedSchemes.FirstOrDefault();
75+
}
76+
}
77+
78+
// If we have a scheme to try, use it
79+
if (bestSchemeMatch != null)
80+
{
81+
// Try to handle the 401 response with the selected scheme
82+
var (handled, recommendedScheme) = await _authorizationProvider.HandleUnauthorizedResponseAsync(
83+
response,
84+
bestSchemeMatch,
85+
cancellationToken);
86+
87+
if (handled)
88+
{
89+
var retryRequest = await CloneHttpRequestMessageAsync(request);
90+
91+
// Use the recommended scheme if provided, otherwise use our best match
92+
string schemeToUse = recommendedScheme ?? bestSchemeMatch;
93+
if (!string.IsNullOrEmpty(recommendedScheme))
94+
{
95+
_currentScheme = recommendedScheme;
96+
}
97+
else
98+
{
99+
_currentScheme = bestSchemeMatch;
100+
}
101+
102+
await AddAuthorizationHeaderAsync(retryRequest, schemeToUse, cancellationToken);
103+
return await base.SendAsync(retryRequest, cancellationToken);
104+
}
105+
else
106+
{
107+
throw new McpException(
108+
$"Failed to handle unauthorized response with scheme '{bestSchemeMatch}'. " +
109+
"The authentication provider was unable to process the authentication challenge.");
110+
}
46111
}
47112
}
48113

49114
return response;
50115
}
51116

117+
/// <summary>
118+
/// Extracts the authentication schemes that the server supports from the WWW-Authenticate headers.
119+
/// </summary>
120+
private static List<string> ExtractServerSupportedSchemes(HttpResponseMessage response)
121+
{
122+
var serverSchemes = new List<string>();
123+
124+
if (response.Headers.Contains("WWW-Authenticate"))
125+
{
126+
foreach (var authHeader in response.Headers.GetValues("WWW-Authenticate"))
127+
{
128+
// Extract the scheme from the WWW-Authenticate header
129+
// Format is typically: "Scheme param1=value1, param2=value2"
130+
string scheme = authHeader.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries)[0];
131+
if (!string.IsNullOrEmpty(scheme))
132+
{
133+
serverSchemes.Add(scheme);
134+
}
135+
}
136+
}
137+
138+
return serverSchemes;
139+
}
140+
52141
/// <summary>
53142
/// Adds an authorization header to the request.
54143
/// </summary>
55-
private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, CancellationToken cancellationToken)
144+
private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, string scheme, CancellationToken cancellationToken)
56145
{
57146
if (request.RequestUri != null)
58147
{
59-
var token = await _authorizationProvider.GetCredentialAsync(request.RequestUri, cancellationToken);
148+
var token = await _authorizationProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken);
60149
if (!string.IsNullOrEmpty(token))
61150
{
62-
request.Headers.Authorization = new AuthenticationHeaderValue(_authorizationProvider.AuthorizationScheme, token);
151+
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
63152
}
64153
}
65154
}

src/ModelContextProtocol/Authentication/ITokenProvider.cs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,42 @@ namespace ModelContextProtocol.Authentication;
77
public interface IMcpAuthorizationProvider
88
{
99
/// <summary>
10-
/// Gets the authentication scheme to use with credentials from this provider.
10+
/// Gets the collection of authentication schemes supported by this provider.
1111
/// </summary>
1212
/// <remarks>
1313
/// <para>
14-
/// Common values include "Bearer" for JWT tokens and "Basic" for username/password authentication.
14+
/// This property returns all authentication schemes that this provider can handle,
15+
/// allowing clients to select the appropriate scheme based on server capabilities.
16+
/// </para>
17+
/// <para>
18+
/// Common values include "Bearer" for JWT tokens, "Basic" for username/password authentication,
19+
/// and "Negotiate" for integrated Windows authentication.
1520
/// </para>
1621
/// </remarks>
17-
string AuthorizationScheme { get; }
22+
IEnumerable<string> SupportedSchemes { get; }
1823

1924
/// <summary>
20-
/// Gets an authentication token or credential for authenticating requests to a resource.
25+
/// Gets an authentication token or credential for authenticating requests to a resource
26+
/// using the specified authentication scheme.
2127
/// </summary>
28+
/// <param name="scheme">The authentication scheme to use.</param>
2229
/// <param name="resourceUri">The URI of the resource requiring authentication.</param>
2330
/// <param name="cancellationToken">A token to cancel the operation.</param>
24-
/// <returns>An authentication token string or null if no token could be obtained.</returns>
25-
Task<string?> GetCredentialAsync(Uri resourceUri, CancellationToken cancellationToken = default);
26-
31+
/// <returns>An authentication token string or null if no token could be obtained for the specified scheme.</returns>
32+
Task<string?> GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default);
33+
2734
/// <summary>
2835
/// Handles a 401 Unauthorized response from a resource.
2936
/// </summary>
3037
/// <param name="response">The HTTP response that contained the 401 status code.</param>
38+
/// <param name="scheme">The authentication scheme that was used when the unauthorized response was received.</param>
3139
/// <param name="cancellationToken">A token to cancel the operation.</param>
32-
/// <returns>True if the provider was able to handle the unauthorized response, otherwise false.</returns>
33-
Task<bool> HandleUnauthorizedResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default);
40+
/// <returns>
41+
/// A tuple containing a boolean indicating if the provider was able to handle the unauthorized response,
42+
/// and the authentication scheme that should be used for the next attempt.
43+
/// </returns>
44+
Task<(bool Success, string? RecommendedScheme)> HandleUnauthorizedResponseAsync(
45+
HttpResponseMessage response,
46+
string scheme,
47+
CancellationToken cancellationToken = default);
3448
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace ModelContextProtocol.Authentication.Types;
2+
3+
/// <summary>
4+
/// Exception thrown when no compatible authentication scheme can be found between the client and server.
5+
/// </summary>
6+
public class AuthenticationSchemeMismatchException : Exception
7+
{
8+
/// <summary>
9+
/// Gets the authentication schemes supported by the server.
10+
/// </summary>
11+
public IReadOnlyList<string> ServerSchemes { get; }
12+
13+
/// <summary>
14+
/// Gets the authentication schemes supported by the client provider.
15+
/// </summary>
16+
public IReadOnlyList<string> ProviderSchemes { get; }
17+
18+
/// <summary>
19+
/// Initializes a new instance of the <see cref="AuthenticationSchemeMismatchException"/> class.
20+
/// </summary>
21+
/// <param name="message">The exception message.</param>
22+
/// <param name="serverSchemes">The authentication schemes supported by the server.</param>
23+
/// <param name="providerSchemes">The authentication schemes supported by the client provider.</param>
24+
public AuthenticationSchemeMismatchException(
25+
string message,
26+
IEnumerable<string> serverSchemes,
27+
IEnumerable<string> providerSchemes)
28+
: base(message)
29+
{
30+
ServerSchemes = serverSchemes.ToList().AsReadOnly();
31+
ProviderSchemes = providerSchemes.ToList().AsReadOnly();
32+
}
33+
}

0 commit comments

Comments
 (0)