Add ScopeSelectorDelegate to enhance OAuth options for scope filtering (#1596)
This commit is contained in:
parent
157f855e51
commit
fd1ac08925
5 changed files with 277 additions and 7 deletions
|
|
@ -35,19 +35,40 @@ public sealed class ClientOAuthOptions
|
||||||
public Uri? ClientMetadataDocumentUri { get; set; }
|
public Uri? ClientMetadataDocumentUri { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the OAuth scopes to request.
|
/// Gets or sets the OAuth scopes to request as a fallback.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// When specified, these scopes will be used instead of the scopes advertised by the protected resource.
|
/// These scopes are used only when the server does not provide scope information via the
|
||||||
/// If not specified, the provider will use the scopes from the protected resource metadata.
|
/// WWW-Authenticate header or Protected Resource Metadata (<c>scopes_supported</c>). This
|
||||||
|
/// matches the MCP scope selection strategy: WWW-Authenticate scope → PRM scopes_supported →
|
||||||
|
/// client-configured scopes → omit scope parameter.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Common OAuth scopes include "openid", "profile", and "email".
|
/// To filter or customize scopes when the server <em>does</em> provide scope information,
|
||||||
|
/// use <see cref="ScopeSelector"/> instead.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public IEnumerable<string>? Scopes { get; set; }
|
public IEnumerable<string>? Scopes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a delegate that selects or filters the OAuth scopes to request.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// When set, this delegate is called after the MCP scope selection strategy has determined the
|
||||||
|
/// candidate scopes (WWW-Authenticate → PRM <c>scopes_supported</c> → <see cref="Scopes"/> fallback)
|
||||||
|
/// and after <c>offline_access</c> has been automatically appended when advertised by the
|
||||||
|
/// authorization server. The return value replaces the candidate scopes in the authorization request.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Use this to request only a subset of the scopes offered by the server, or to append a custom
|
||||||
|
/// scope that is not advertised in the server metadata. Return <see langword="null"/> or an empty
|
||||||
|
/// enumerable to omit the <c>scope</c> parameter entirely.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public ScopeSelectorDelegate? ScopeSelector { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
|
/// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
private readonly Uri _serverUrl;
|
private readonly Uri _serverUrl;
|
||||||
private readonly Uri _redirectUri;
|
private readonly Uri _redirectUri;
|
||||||
private readonly string? _configuredScopes;
|
private readonly string? _configuredScopes;
|
||||||
|
private readonly ScopeSelectorDelegate? _scopeSelector;
|
||||||
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
|
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
|
||||||
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
||||||
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
||||||
|
|
@ -76,6 +77,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
_clientSecret = options.ClientSecret;
|
_clientSecret = options.ClientSecret;
|
||||||
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
|
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
|
||||||
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
|
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
|
||||||
|
_scopeSelector = options.ScopeSelector;
|
||||||
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
|
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
|
||||||
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
|
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
|
||||||
|
|
||||||
|
|
@ -491,8 +493,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
queryParamsDictionary["resource"] = resourceUri;
|
queryParamsDictionary["resource"] = resourceUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
var scope = GetScopeParameter(protectedResourceMetadata);
|
var scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata);
|
||||||
scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
|
|
||||||
if (!string.IsNullOrEmpty(scope))
|
if (!string.IsNullOrEmpty(scope))
|
||||||
{
|
{
|
||||||
queryParamsDictionary["scope"] = scope!;
|
queryParamsDictionary["scope"] = scope!;
|
||||||
|
|
@ -654,7 +655,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
TokenEndpointAuthMethod = "client_secret_post",
|
TokenEndpointAuthMethod = "client_secret_post",
|
||||||
ClientName = _dcrClientName,
|
ClientName = _dcrClientName,
|
||||||
ClientUri = _dcrClientUri?.ToString(),
|
ClientUri = _dcrClientUri?.ToString(),
|
||||||
Scope = GetScopeParameter(protectedResourceMetadata),
|
Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata),
|
||||||
};
|
};
|
||||||
|
|
||||||
var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
|
var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
|
||||||
|
|
@ -713,6 +714,20 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
|
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
|
||||||
=> protectedResourceMetadata.Resource;
|
=> protectedResourceMetadata.Resource;
|
||||||
|
|
||||||
|
private string? ComputeEffectiveScope(
|
||||||
|
ProtectedResourceMetadata protectedResourceMetadata,
|
||||||
|
AuthorizationServerMetadata authServerMetadata)
|
||||||
|
{
|
||||||
|
var scope = GetScopeParameter(protectedResourceMetadata);
|
||||||
|
scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
|
||||||
|
if (_scopeSelector is not null)
|
||||||
|
{
|
||||||
|
var selected = _scopeSelector(scope?.Split(' '));
|
||||||
|
scope = selected is not null ? string.Join(" ", selected) : null;
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
|
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope))
|
if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a method that selects or filters the OAuth scopes to request during authorization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scope">
|
||||||
|
/// The scopes determined by the MCP scope selection strategy (WWW-Authenticate header scope →
|
||||||
|
/// <c>scopes_supported</c> from Protected Resource Metadata → <see cref="ClientOAuthOptions.Scopes"/>
|
||||||
|
/// fallback), with <c>offline_access</c> appended when advertised by the authorization server. May be
|
||||||
|
/// <see langword="null"/> if the server provided no scope information and no fallback scopes are configured.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// The scopes to include in the authorization and Dynamic Client Registration requests. Return
|
||||||
|
/// <see langword="null"/> or an empty enumerable to omit the <c>scope</c> parameter entirely.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Use this delegate to filter or customize the proposed scopes before the authorization request is made.
|
||||||
|
/// Common scenarios include:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Requesting only a subset of the scopes offered by the server.</description></item>
|
||||||
|
/// <item><description>Appending a custom scope not advertised in the server metadata.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// The MCP specification defines the following scope selection priority (highest to lowest):
|
||||||
|
/// WWW-Authenticate header scope → PRM <c>scopes_supported</c> → omit scope parameter. The
|
||||||
|
/// <paramref name="scope"/> parameter already reflects this priority. The delegate runs after
|
||||||
|
/// <c>offline_access</c> has been auto-appended, so it can also remove that scope if desired.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The resolved scope is applied consistently to both the authorization URL and the Dynamic Client
|
||||||
|
/// Registration (DCR) request, so the registered client scope matches what is actually requested.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public delegate IEnumerable<string>? ScopeSelectorDelegate(IReadOnlyCollection<string>? scope);
|
||||||
|
|
@ -1365,4 +1365,196 @@ public class AuthTests : OAuthTestBase
|
||||||
var scopeTokens = requestedScope!.Split(' ');
|
var scopeTokens = requestedScope!.Split(' ');
|
||||||
Assert.Single(scopeTokens, t => t == "offline_access");
|
Assert.Single(scopeTokens, t => t == "offline_access");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizationFlow_ScopeSelector_CanFilterServerProposedScopes()
|
||||||
|
{
|
||||||
|
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
string? requestedScope = null;
|
||||||
|
|
||||||
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new()
|
||||||
|
{
|
||||||
|
ClientId = "demo-client",
|
||||||
|
ClientSecret = "demo-secret",
|
||||||
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
|
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||||
|
{
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
requestedScope = query["scope"].ToString();
|
||||||
|
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||||
|
},
|
||||||
|
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("mcp:tools", requestedScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizationFlow_ScopeSelector_CanAddCustomScope()
|
||||||
|
{
|
||||||
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
string? requestedScope = null;
|
||||||
|
|
||||||
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new()
|
||||||
|
{
|
||||||
|
ClientId = "demo-client",
|
||||||
|
ClientSecret = "demo-secret",
|
||||||
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
|
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||||
|
{
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
requestedScope = query["scope"].ToString();
|
||||||
|
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||||
|
},
|
||||||
|
ScopeSelector = scopes => scopes?.Append("custom:scope") ?? ["custom:scope"],
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(requestedScope);
|
||||||
|
Assert.Contains("custom:scope", requestedScope!.Split(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizationFlow_ScopeSelector_ReceivesNull_WhenServerProvidesNoScopes()
|
||||||
|
{
|
||||||
|
// No ScopesSupported on PRM, no Scopes fallback on client, no offline_access on AS (default).
|
||||||
|
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
options.ResourceMetadata!.ScopesSupported = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
IEnumerable<string>? capturedInput = ["sentinel"];
|
||||||
|
|
||||||
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new()
|
||||||
|
{
|
||||||
|
ClientId = "demo-client",
|
||||||
|
ClientSecret = "demo-secret",
|
||||||
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
|
ScopeSelector = scopes =>
|
||||||
|
{
|
||||||
|
capturedInput = scopes;
|
||||||
|
return scopes;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Null(capturedInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizationFlow_ScopeSelector_ReturningNull_OmitsScopeParameter()
|
||||||
|
{
|
||||||
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
bool? scopePresent = null;
|
||||||
|
|
||||||
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new()
|
||||||
|
{
|
||||||
|
ClientId = "demo-client",
|
||||||
|
ClientSecret = "demo-secret",
|
||||||
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
|
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||||
|
{
|
||||||
|
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
|
||||||
|
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||||
|
},
|
||||||
|
ScopeSelector = _ => null,
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(scopePresent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizationFlow_ScopeSelector_ReturningEmpty_OmitsScopeParameter()
|
||||||
|
{
|
||||||
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
bool? scopePresent = null;
|
||||||
|
|
||||||
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new()
|
||||||
|
{
|
||||||
|
ClientId = "demo-client",
|
||||||
|
ClientSecret = "demo-secret",
|
||||||
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
|
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||||
|
{
|
||||||
|
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
|
||||||
|
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||||
|
},
|
||||||
|
ScopeSelector = _ => [],
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(scopePresent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope()
|
||||||
|
{
|
||||||
|
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new ClientOAuthOptions()
|
||||||
|
{
|
||||||
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
|
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
|
||||||
|
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("mcp:tools", TestOAuthServer.LastRegistrationScope);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@ public sealed class Program
|
||||||
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
||||||
|
|
||||||
|
/// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary>
|
||||||
|
public string? LastRegistrationScope { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for the application.
|
/// Entry point for the application.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -513,6 +516,8 @@ public sealed class Program
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LastRegistrationScope = registrationRequest.Scope;
|
||||||
|
|
||||||
// Validate redirect URIs are provided
|
// Validate redirect URIs are provided
|
||||||
if (registrationRequest.RedirectUris.Count == 0)
|
if (registrationRequest.RedirectUris.Count == 0)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue