Support incremental scope consent (SEP-835) (#1084)

This commit is contained in:
Stephen Halter 2025-12-11 19:51:47 -08:00 • committed by GitHub
commit 31f8d20a1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 782 additions and 394 deletions

View file

@ -1,9 +1,16 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
using System.Net;
using System.Reflection;
using System.Security.Claims;
using Xunit.Sdk;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
@ -88,7 +95,6 @@ public class AuthTests : OAuthTestBase
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
Scopes = ["mcp:tools"],
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
@ -139,7 +145,6 @@ public class AuthTests : OAuthTestBase
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
Scopes = ["mcp:tools"],
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client (No CIMD)",
@ -203,6 +208,33 @@ public class AuthTests : OAuthTestBase
[Fact]
public async Task CanAuthenticate_WithTokenRefresh()
{
var hasForcedRefresh = false;
Builder.Services.AddHttpContextAccessor();
Builder.Services.AddMcpServer(options =>
{
options.ToolCollection = new();
})
.AddListToolsFilter(next =>
{
return async (mcpContext, cancellationToken) =>
{
if (!hasForcedRefresh)
{
hasForcedRefresh = true;
var httpContext = mcpContext.Services!.GetRequiredService<IHttpContextAccessor>().HttpContext!;
await httpContext.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
await httpContext.Response.CompleteAsync();
throw new Exception("This exception will not impact the client because the response has already been completed.");
}
else
{
return await next(mcpContext, cancellationToken);
}
};
});
await using var app = await StartMcpServerAsync();
await using var transport = new HttpClientTransport(new()
@ -210,18 +242,18 @@ public class AuthTests : OAuthTestBase
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "test-refresh-client",
ClientSecret = "test-refresh-secret",
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
// The test-refresh-client should get an expired token first,
// then automatically refresh it to get a working token
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.True(TestOAuthServer.HasRefreshedToken);
}
@ -328,25 +360,396 @@ public class AuthTests : OAuthTestBase
}
[Fact]
public async Task JwtBearerChallenge_DoesNotIncludeResourceMetadata()
public async Task AuthorizationFlow_UsesScopeFromProtectedResourceMetadata()
{
await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme);
using var unauthorizedResponse = await HttpClient.GetAsync(McpServerUrl, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedResponse.StatusCode);
var headerFound = false;
foreach (var header in unauthorizedResponse.Headers.WwwAuthenticate)
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
headerFound = true;
Assert.Equal("Bearer", header.Scheme);
Assert.True(header.Parameter is null || !header.Parameter.Contains("resource_metadata", StringComparison.OrdinalIgnoreCase));
}
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
});
Assert.True(headerFound);
await using var app = await StartMcpServerAsync();
using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource", UriKind.Relative), TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
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);
},
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("mcp:tools files:read", requestedScope);
}
[Fact]
public async Task AuthorizationFlow_UsesScopeFromChallengeHeader()
{
var challengeScopes = "challenge:read challenge:write";
await using var app = Builder.Build();
app.Use(next =>
{
return async context =>
{
await next(context);
if (context.Response.StatusCode != 401)
{
return;
}
context.Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{challengeScopes}\"";
};
});
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
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);
},
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(challengeScopes, requestedScope);
}
[Fact]
public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader()
{
var adminScopes = "admin:read admin:write";
Builder.Services.AddHttpContextAccessor();
Builder.Services.AddMcpServer()
.WithTools([
McpServerTool.Create([McpServerTool(Name = "admin-tool")]
async (IServiceProvider serviceProvider, ClaimsPrincipal user) =>
{
if (!user.HasClaim("scope", adminScopes))
{
var httpContext = serviceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext!;
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
httpContext.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\"";
await httpContext.Response.CompleteAsync();
throw new Exception("This exception will not impact the client because the response has already been completed.");
}
return "Admin tool executed.";
}),
]);
string? requestedScope = null;
await using var app = await StartMcpServerAsync();
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);
},
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("mcp:tools", requestedScope);
var adminResult = await client.CallToolAsync("admin-tool", cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("Admin tool executed.", adminResult.Content[0].ToString());
Assert.Equal(adminScopes, requestedScope);
}
[Fact]
public async Task AuthorizationFails_WhenResourceMetadataPortDiffers()
{
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.Resource = new Uri("http://localhost:5999");
});
await using var app = await StartMcpServerAsync();
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,
},
}, HttpClient, LoggerFactory);
await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task CanAuthenticate_WithAuthorizationServerPathInsertionMetadata()
{
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.AuthorizationServers = [new Uri($"{OAuthServerUrl}/tenant1")];
});
await using var app = await StartMcpServerAsync();
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,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
var requests = TestOAuthServer.MetadataRequests.ToArray();
Assert.Contains("/.well-known/oauth-authorization-server/tenant1", requests);
}
[Fact]
public async Task CanAuthenticate_WithAuthorizationServerPathFallbacks()
{
const string issuerPath = "/subdir/tenant2";
TestOAuthServer.DisabledMetadataPaths.Add($"/.well-known/oauth-authorization-server{issuerPath}");
TestOAuthServer.DisabledMetadataPaths.Add($"/.well-known/openid-configuration{issuerPath}");
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata!.AuthorizationServers = [new Uri($"{OAuthServerUrl}{issuerPath}")];
});
await using var app = await StartMcpServerAsync();
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,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(
[
$"/.well-known/oauth-authorization-server{issuerPath}",
$"/.well-known/openid-configuration{issuerPath}",
$"{issuerPath}/.well-known/openid-configuration",
"/.well-known/openid-configuration",
],
TestOAuthServer.MetadataRequests);
}
[Fact]
public async Task CanAuthenticate_WithResourceMetadataPathFallbacks()
{
const string resourcePath = "/mcp";
List<string> wellKnownRequests = [];
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
await using var app = Builder.Build();
var metadata = new ProtectedResourceMetadata
{
Resource = new Uri($"{McpServerUrl}{resourcePath}"),
AuthorizationServers = { new Uri(OAuthServerUrl) },
};
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource", out var remaining))
{
wellKnownRequests.Add(context.Request.Path);
if (remaining.HasValue)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
}
await next();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp(resourcePath).RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
var endpoint = new Uri(new Uri(McpServerUrl), resourcePath);
await using var transport = new HttpClientTransport(new()
{
Endpoint = endpoint,
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(
[
$"/.well-known/oauth-protected-resource{resourcePath}",
"/.well-known/oauth-protected-resource"
],
wellKnownRequests);
}
[Fact]
public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParentPath()
{
const string configuredResourcePath = "/mcp";
const string requestedResourcePath = "/mcp/tools";
// Remove resource_metadata from the WWW-Authenticate header, because we should only fall back at all (even to root) when it's missing.
//
// If the protected resource metadata was retrieved from a URL returned by the protected resource via the WWW-Authenticate resource_metadata parameter,
// then the resource value returned MUST be identical to the URL that the client used to make the request to the resource server.
// If these values are not identical, the data contained in the response MUST NOT be used.
//
// https://datatracker.ietf.org/doc/html/rfc9728/#section-3.3
//
// CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates we won't fall back to root in this case.
// CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fall back to root when resource_metadata is missing.
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri($"{McpServerUrl}{configuredResourcePath}"),
AuthorizationServers = { new Uri(OAuthServerUrl) },
};
});
await using var app = Builder.Build();
app.MapMcp(requestedResourcePath).RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new HttpClientTransport(new()
{
Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
var ex = await Assert.ThrowsAsync<McpException>(async () =>
{
await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
});
Assert.Contains("does not match", ex.Message);
}
[Fact]
public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath()
{
const string requestedResourcePath = "/mcp/tools";
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri($"{McpServerUrl}"),
AuthorizationServers = { new Uri(OAuthServerUrl) },
};
});
await using var app = Builder.Build();
app.MapMcp(requestedResourcePath).RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new HttpClientTransport(new()
{
Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
var ex = await Assert.ThrowsAsync<McpException>(async () =>
{
await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
});
Assert.Contains("does not match", ex.Message);
}
[Fact]

View file

@ -8,7 +8,6 @@ using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Authentication;
using System.Net;
using Xunit.Sdk;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
@ -84,7 +83,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
protected async Task<WebApplication> StartMcpServerAsync(string path = "", string? authScheme = null)
{
Builder.Services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters.ValidAudience = $"{McpServerUrl}{path}";
});