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; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OAuth scopes to request.
|
||||
/// Gets or sets the OAuth scopes to request as a fallback.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When specified, these scopes will be used instead of the scopes advertised by the protected resource.
|
||||
/// If not specified, the provider will use the scopes from the protected resource metadata.
|
||||
/// These scopes are used only when the server does not provide scope information via the
|
||||
/// 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>
|
||||
/// 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>
|
||||
/// </remarks>
|
||||
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>
|
||||
/// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
private readonly Uri _serverUrl;
|
||||
private readonly Uri _redirectUri;
|
||||
private readonly string? _configuredScopes;
|
||||
private readonly ScopeSelectorDelegate? _scopeSelector;
|
||||
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
|
||||
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
||||
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
||||
|
|
@ -76,6 +77,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
_clientSecret = options.ClientSecret;
|
||||
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
|
||||
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
|
||||
_scopeSelector = options.ScopeSelector;
|
||||
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
|
||||
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
|
||||
|
||||
|
|
@ -491,8 +493,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
queryParamsDictionary["resource"] = resourceUri;
|
||||
}
|
||||
|
||||
var scope = GetScopeParameter(protectedResourceMetadata);
|
||||
scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
|
||||
var scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata);
|
||||
if (!string.IsNullOrEmpty(scope))
|
||||
{
|
||||
queryParamsDictionary["scope"] = scope!;
|
||||
|
|
@ -654,7 +655,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
TokenEndpointAuthMethod = "client_secret_post",
|
||||
ClientName = _dcrClientName,
|
||||
ClientUri = _dcrClientUri?.ToString(),
|
||||
Scope = GetScopeParameter(protectedResourceMetadata),
|
||||
Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata),
|
||||
};
|
||||
|
||||
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)
|
||||
=> 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)
|
||||
{
|
||||
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(' ');
|
||||
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 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>
|
||||
/// Entry point for the application.
|
||||
/// </summary>
|
||||
|
|
@ -513,6 +516,8 @@ public sealed class Program
|
|||
});
|
||||
}
|
||||
|
||||
LastRegistrationScope = registrationRequest.Scope;
|
||||
|
||||
// Validate redirect URIs are provided
|
||||
if (registrationRequest.RedirectUris.Count == 0)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue