| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -35,19 +35,40 @@ public sealed class ClientOAuthOptions | |||
| 35 | 35 | public Uri? ClientMetadataDocumentUri { get; set; } | |
| 36 | 36 | ||
| 37 | 37 | /// <summary> | |
| 38 | - /// Gets or sets the OAuth scopes to request. | ||
| 38 | + /// Gets or sets the OAuth scopes to request as a fallback. | ||
| 39 | 39 | /// </summary> | |
| 40 | 40 | /// <remarks> | |
| 41 | 41 | /// <para> | |
| 42 | - /// When specified, these scopes will be used instead of the scopes advertised by the protected resource. | ||
| 43 | - /// If not specified, the provider will use the scopes from the protected resource metadata. | ||
| 42 | + /// These scopes are used only when the server does not provide scope information via the | ||
| 43 | + /// WWW-Authenticate header or Protected Resource Metadata (<c>scopes_supported</c>). This | ||
| 44 | + /// matches the MCP scope selection strategy: WWW-Authenticate scope → PRM scopes_supported → | ||
| 45 | + /// client-configured scopes → omit scope parameter. | ||
| 44 | 46 | /// </para> | |
| 45 | 47 | /// <para> | |
| 46 | - /// Common OAuth scopes include "openid", "profile", and "email". | ||
| 48 | + /// To filter or customize scopes when the server <em>does</em> provide scope information, | ||
| 49 | + /// use <see cref="ScopeSelector"/> instead. | ||
| 47 | 50 | /// </para> | |
| 48 | 51 | /// </remarks> | |
| 49 | 52 | public IEnumerable<string>? Scopes { get; set; } | |
| 50 | 53 | ||
| 54 | + /// <summary> | ||
| 55 | + /// Gets or sets a delegate that selects or filters the OAuth scopes to request. | ||
| 56 | + /// </summary> | ||
| 57 | + /// <remarks> | ||
| 58 | + /// <para> | ||
| 59 | + /// When set, this delegate is called after the MCP scope selection strategy has determined the | ||
| 60 | + /// candidate scopes (WWW-Authenticate → PRM <c>scopes_supported</c> → <see cref="Scopes"/> fallback) | ||
| 61 | + /// and after <c>offline_access</c> has been automatically appended when advertised by the | ||
| 62 | + /// authorization server. The return value replaces the candidate scopes in the authorization request. | ||
| 63 | + /// </para> | ||
| 64 | + /// <para> | ||
| 65 | + /// Use this to request only a subset of the scopes offered by the server, or to append a custom | ||
| 66 | + /// scope that is not advertised in the server metadata. Return <see langword="null"/> or an empty | ||
| 67 | + /// enumerable to omit the <c>scope</c> parameter entirely. | ||
| 68 | + /// </para> | ||
| 69 | + /// </remarks> | ||
| 70 | + public ScopeSelectorDelegate? ScopeSelector { get; set; } | ||
| 71 | + | ||
| 51 | 72 | /// <summary> | |
| 52 | 73 | /// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow. | |
| 53 | 74 | /// </summary> | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -28,6 +28,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient | |||
| 28 | 28 | private readonly Uri _serverUrl; | |
| 29 | 29 | private readonly Uri _redirectUri; | |
| 30 | 30 | private readonly string? _configuredScopes; | |
| 31 | + private readonly ScopeSelectorDelegate? _scopeSelector; | ||
| 31 | 32 | private readonly IDictionary<string, string> _additionalAuthorizationParameters; | |
| 32 | 33 | private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector; | |
| 33 | 34 | private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate; | |
@@ -76,6 +77,7 @@ public ClientOAuthProvider( | |||
| 76 | 77 | _clientSecret = options.ClientSecret; | |
| 77 | 78 | _redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options)); | |
| 78 | 79 | _configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes); | |
| 80 | + _scopeSelector = options.ScopeSelector; | ||
| 79 | 81 | _additionalAuthorizationParameters = options.AdditionalAuthorizationParameters; | |
| 80 | 82 | _clientMetadataDocumentUri = options.ClientMetadataDocumentUri; | |
| 81 | 83 | ||
@@ -491,8 +493,7 @@ private Uri BuildAuthorizationUrl( | |||
| 491 | 493 | queryParamsDictionary["resource"] = resourceUri; | |
| 492 | 494 | } | |
| 493 | 495 | ||
| 494 | - var scope = GetScopeParameter(protectedResourceMetadata); | ||
| 495 | - scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata); | ||
| 496 | + var scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata); | ||
| 496 | 497 | if (!string.IsNullOrEmpty(scope)) | |
| 497 | 498 | { | |
| 498 | 499 | queryParamsDictionary["scope"] = scope!; | |
@@ -654,7 +655,7 @@ private async Task PerformDynamicClientRegistrationAsync( | |||
| 654 | 655 | TokenEndpointAuthMethod = "client_secret_post", | |
| 655 | 656 | ClientName = _dcrClientName, | |
| 656 | 657 | ClientUri = _dcrClientUri?.ToString(), | |
| 657 | - Scope = GetScopeParameter(protectedResourceMetadata), | ||
| 658 | + Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata), | ||
| 658 | 659 | }; | |
| 659 | 660 | ||
| 660 | 661 | var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest); | |
@@ -713,6 +714,20 @@ private async Task PerformDynamicClientRegistrationAsync( | |||
| 713 | 714 | private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata) | |
| 714 | 715 | => protectedResourceMetadata.Resource; | |
| 715 | 716 | ||
| 717 | + private string? ComputeEffectiveScope( | ||
| 718 | + ProtectedResourceMetadata protectedResourceMetadata, | ||
| 719 | + AuthorizationServerMetadata authServerMetadata) | ||
| 720 | + { | ||
| 721 | + var scope = GetScopeParameter(protectedResourceMetadata); | ||
| 722 | + scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata); | ||
| 723 | + if (_scopeSelector is not null) | ||
| 724 | + { | ||
| 725 | + var selected = _scopeSelector(scope?.Split(' ')); | ||
| 726 | + scope = selected is not null ? string.Join(" ", selected) : null; | ||
| 727 | + } | ||
| 728 | + return scope; | ||
| 729 | + } | ||
| 730 | + | ||
| 716 | 731 | private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata) | |
| 717 | 732 | { | |
| 718 | 733 | if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope)) | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,37 @@ | |||
| 1 | + | ||
| 2 | + namespace ModelContextProtocol.Authentication; | ||
| 3 | + | ||
| 4 | + /// <summary> | ||
| 5 | + /// Represents a method that selects or filters the OAuth scopes to request during authorization. | ||
| 6 | + /// </summary> | ||
| 7 | + /// <param name="scope"> | ||
| 8 | + /// The scopes determined by the MCP scope selection strategy (WWW-Authenticate header scope → | ||
| 9 | + /// <c>scopes_supported</c> from Protected Resource Metadata → <see cref="ClientOAuthOptions.Scopes"/> | ||
| 10 | + /// fallback), with <c>offline_access</c> appended when advertised by the authorization server. May be | ||
| 11 | + /// <see langword="null"/> if the server provided no scope information and no fallback scopes are configured. | ||
| 12 | + /// </param> | ||
| 13 | + /// <returns> | ||
| 14 | + /// The scopes to include in the authorization and Dynamic Client Registration requests. Return | ||
| 15 | + /// <see langword="null"/> or an empty enumerable to omit the <c>scope</c> parameter entirely. | ||
| 16 | + /// </returns> | ||
| 17 | + /// <remarks> | ||
| 18 | + /// <para> | ||
| 19 | + /// Use this delegate to filter or customize the proposed scopes before the authorization request is made. | ||
| 20 | + /// Common scenarios include: | ||
| 21 | + /// </para> | ||
| 22 | + /// <list type="bullet"> | ||
| 23 | + /// <item><description>Requesting only a subset of the scopes offered by the server.</description></item> | ||
| 24 | + /// <item><description>Appending a custom scope not advertised in the server metadata.</description></item> | ||
| 25 | + /// </list> | ||
| 26 | + /// <para> | ||
| 27 | + /// The MCP specification defines the following scope selection priority (highest to lowest): | ||
| 28 | + /// WWW-Authenticate header scope → PRM <c>scopes_supported</c> → omit scope parameter. The | ||
| 29 | + /// <paramref name="scope"/> parameter already reflects this priority. The delegate runs after | ||
| 30 | + /// <c>offline_access</c> has been auto-appended, so it can also remove that scope if desired. | ||
| 31 | + /// </para> | ||
| 32 | + /// <para> | ||
| 33 | + /// The resolved scope is applied consistently to both the authorization URL and the Dynamic Client | ||
| 34 | + /// Registration (DCR) request, so the registered client scope matches what is actually requested. | ||
| 35 | + /// </para> | ||
| 36 | + /// </remarks> | ||
| 37 | + public delegate IEnumerable<string>? ScopeSelectorDelegate(IReadOnlyCollection<string>? scope); | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1365,4 +1365,196 @@ public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPre | |||
| 1365 | 1365 | var scopeTokens = requestedScope!.Split(' '); | |
| 1366 | 1366 | Assert.Single(scopeTokens, t => t == "offline_access"); | |
| 1367 | 1367 | } | |
| 1368 | + | ||
| 1369 | + [Fact] | ||
| 1370 | + public async Task AuthorizationFlow_ScopeSelector_CanFilterServerProposedScopes() | ||
| 1371 | + { | ||
| 1372 | + Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options => | ||
| 1373 | + { | ||
| 1374 | + options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"]; | ||
| 1375 | + }); | ||
| 1376 | + | ||
| 1377 | + await using var app = await StartMcpServerAsync(); | ||
| 1378 | + | ||
| 1379 | + string? requestedScope = null; | ||
| 1380 | + | ||
| 1381 | + await using var transport = new HttpClientTransport(new() | ||
| 1382 | + { | ||
| 1383 | + Endpoint = new(McpServerUrl), | ||
| 1384 | + OAuth = new() | ||
| 1385 | + { | ||
| 1386 | + ClientId = "demo-client", | ||
| 1387 | + ClientSecret = "demo-secret", | ||
| 1388 | + RedirectUri = new Uri("http://localhost:1179/callback"), | ||
| 1389 | + AuthorizationRedirectDelegate = (uri, redirect, ct) => | ||
| 1390 | + { | ||
| 1391 | + var query = QueryHelpers.ParseQuery(uri.Query); | ||
| 1392 | + requestedScope = query["scope"].ToString(); | ||
| 1393 | + return HandleAuthorizationUrlAsync(uri, redirect, ct); | ||
| 1394 | + }, | ||
| 1395 | + ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"), | ||
| 1396 | + }, | ||
| 1397 | + }, HttpClient, LoggerFactory); | ||
| 1398 | + | ||
| 1399 | + await using var client = await McpClient.CreateAsync( | ||
| 1400 | + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); | ||
| 1401 | + | ||
| 1402 | + Assert.Equal("mcp:tools", requestedScope); | ||
| 1403 | + } | ||
| 1404 | + | ||
| 1405 | + [Fact] | ||
| 1406 | + public async Task AuthorizationFlow_ScopeSelector_CanAddCustomScope() | ||
| 1407 | + { | ||
| 1408 | + await using var app = await StartMcpServerAsync(); | ||
| 1409 | + | ||
| 1410 | + string? requestedScope = null; | ||
| 1411 | + | ||
| 1412 | + await using var transport = new HttpClientTransport(new() | ||
| 1413 | + { | ||
| 1414 | + Endpoint = new(McpServerUrl), | ||
| 1415 | + OAuth = new() | ||
| 1416 | + { | ||
| 1417 | + ClientId = "demo-client", | ||
| 1418 | + ClientSecret = "demo-secret", | ||
| 1419 | + RedirectUri = new Uri("http://localhost:1179/callback"), | ||
| 1420 | + AuthorizationRedirectDelegate = (uri, redirect, ct) => | ||
| 1421 | + { | ||
| 1422 | + var query = QueryHelpers.ParseQuery(uri.Query); | ||
| 1423 | + requestedScope = query["scope"].ToString(); | ||
| 1424 | + return HandleAuthorizationUrlAsync(uri, redirect, ct); | ||
| 1425 | + }, | ||
| 1426 | + ScopeSelector = scopes => scopes?.Append("custom:scope") ?? ["custom:scope"], | ||
| 1427 | + }, | ||
| 1428 | + }, HttpClient, LoggerFactory); | ||
| 1429 | + | ||
| 1430 | + await using var client = await McpClient.CreateAsync( | ||
| 1431 | + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); | ||
| 1432 | + | ||
| 1433 | + Assert.NotNull(requestedScope); | ||
| 1434 | + Assert.Contains("custom:scope", requestedScope!.Split(' ')); | ||
| 1435 | + } | ||
| 1436 | + | ||
| 1437 | + [Fact] | ||
| 1438 | + public async Task AuthorizationFlow_ScopeSelector_ReceivesNull_WhenServerProvidesNoScopes() | ||
| 1439 | + { | ||
| 1440 | + // No ScopesSupported on PRM, no Scopes fallback on client, no offline_access on AS (default). | ||
| 1441 | + Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options => | ||
| 1442 | + { | ||
| 1443 | + options.ResourceMetadata!.ScopesSupported = []; | ||
| 1444 | + }); | ||
| 1445 | + | ||
| 1446 | + await using var app = await StartMcpServerAsync(); | ||
| 1447 | + | ||
| 1448 | + IEnumerable<string>? capturedInput = ["sentinel"]; | ||
| 1449 | + | ||
| 1450 | + await using var transport = new HttpClientTransport(new() | ||
| 1451 | + { | ||
| 1452 | + Endpoint = new(McpServerUrl), | ||
| 1453 | + OAuth = new() | ||
| 1454 | + { | ||
| 1455 | + ClientId = "demo-client", | ||
| 1456 | + ClientSecret = "demo-secret", | ||
| 1457 | + RedirectUri = new Uri("http://localhost:1179/callback"), | ||
| 1458 | + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, | ||
| 1459 | + ScopeSelector = scopes => | ||
| 1460 | + { | ||
| 1461 | + capturedInput = scopes; | ||
| 1462 | + return scopes; | ||
| 1463 | + }, | ||
| 1464 | + }, | ||
| 1465 | + }, HttpClient, LoggerFactory); | ||
| 1466 | + | ||
| 1467 | + await using var client = await McpClient.CreateAsync( | ||
| 1468 | + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); | ||
| 1469 | + | ||
| 1470 | + Assert.Null(capturedInput); | ||
| 1471 | + } | ||
| 1472 | + | ||
| 1473 | + [Fact] | ||
| 1474 | + public async Task AuthorizationFlow_ScopeSelector_ReturningNull_OmitsScopeParameter() | ||
| 1475 | + { | ||
| 1476 | + await using var app = await StartMcpServerAsync(); | ||
| 1477 | + | ||
| 1478 | + bool? scopePresent = null; | ||
| 1479 | + | ||
| 1480 | + await using var transport = new HttpClientTransport(new() | ||
| 1481 | + { | ||
| 1482 | + Endpoint = new(McpServerUrl), | ||
| 1483 | + OAuth = new() | ||
| 1484 | + { | ||
| 1485 | + ClientId = "demo-client", | ||
| 1486 | + ClientSecret = "demo-secret", | ||
| 1487 | + RedirectUri = new Uri("http://localhost:1179/callback"), | ||
| 1488 | + AuthorizationRedirectDelegate = (uri, redirect, ct) => | ||
| 1489 | + { | ||
| 1490 | + scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope"); | ||
| 1491 | + return HandleAuthorizationUrlAsync(uri, redirect, ct); | ||
| 1492 | + }, | ||
| 1493 | + ScopeSelector = _ => null, | ||
| 1494 | + }, | ||
| 1495 | + }, HttpClient, LoggerFactory); | ||
| 1496 | + | ||
| 1497 | + await using var client = await McpClient.CreateAsync( | ||
| 1498 | + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); | ||
| 1499 | + | ||
| 1500 | + Assert.False(scopePresent); | ||
| 1501 | + } | ||
| 1502 | + | ||
| 1503 | + [Fact] | ||
| 1504 | + public async Task AuthorizationFlow_ScopeSelector_ReturningEmpty_OmitsScopeParameter() | ||
| 1505 | + { | ||
| 1506 | + await using var app = await StartMcpServerAsync(); | ||
| 1507 | + | ||
| 1508 | + bool? scopePresent = null; | ||
| 1509 | + | ||
| 1510 | + await using var transport = new HttpClientTransport(new() | ||
| 1511 | + { | ||
| 1512 | + Endpoint = new(McpServerUrl), | ||
| 1513 | + OAuth = new() | ||
| 1514 | + { | ||
| 1515 | + ClientId = "demo-client", | ||
| 1516 | + ClientSecret = "demo-secret", | ||
| 1517 | + RedirectUri = new Uri("http://localhost:1179/callback"), | ||
| 1518 | + AuthorizationRedirectDelegate = (uri, redirect, ct) => | ||
| 1519 | + { | ||
| 1520 | + scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope"); | ||
| 1521 | + return HandleAuthorizationUrlAsync(uri, redirect, ct); | ||
| 1522 | + }, | ||
| 1523 | + ScopeSelector = _ => [], | ||
| 1524 | + }, | ||
| 1525 | + }, HttpClient, LoggerFactory); | ||
| 1526 | + | ||
| 1527 | + await using var client = await McpClient.CreateAsync( | ||
| 1528 | + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); | ||
| 1529 | + | ||
| 1530 | + Assert.False(scopePresent); | ||
| 1531 | + } | ||
| 1532 | + | ||
| 1533 | + [Fact] | ||
| 1534 | + public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope() | ||
| 1535 | + { | ||
| 1536 | + Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options => | ||
| 1537 | + { | ||
| 1538 | + options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"]; | ||
| 1539 | + }); | ||
| 1540 | + | ||
| 1541 | + await using var app = await StartMcpServerAsync(); | ||
| 1542 | + | ||
| 1543 | + await using var transport = new HttpClientTransport(new() | ||
| 1544 | + { | ||
| 1545 | + Endpoint = new(McpServerUrl), | ||
| 1546 | + OAuth = new ClientOAuthOptions() | ||
| 1547 | + { | ||
| 1548 | + RedirectUri = new Uri("http://localhost:1179/callback"), | ||
| 1549 | + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, | ||
| 1550 | + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, | ||
| 1551 | + ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"), | ||
| 1552 | + }, | ||
| 1553 | + }, HttpClient, LoggerFactory); | ||
| 1554 | + | ||
| 1555 | + await using var client = await McpClient.CreateAsync( | ||
| 1556 | + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); | ||
| 1557 | + | ||
| 1558 | + Assert.Equal("mcp:tools", TestOAuthServer.LastRegistrationScope); | ||
| 1559 | + } | ||
| 1368 | 1560 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -91,6 +91,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor | |||
| 91 | 91 | public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); | |
| 92 | 92 | public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray(); | |
| 93 | 93 | ||
| 94 | + /// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary> | ||
| 95 | + public string? LastRegistrationScope { get; private set; } | ||
| 96 | + | ||
| 94 | 97 | /// <summary> | |
| 95 | 98 | /// Entry point for the application. | |
| 96 | 99 | /// </summary> | |
@@ -513,6 +516,8 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) | |||
| 513 | 516 | }); | |
| 514 | 517 | } | |
| 515 | 518 | ||
| 519 | + LastRegistrationScope = registrationRequest.Scope; | ||
| 520 | + | ||
| 516 | 521 | // Validate redirect URIs are provided | |
| 517 | 522 | if (registrationRequest.RedirectUris.Count == 0) | |
| 518 | 523 | { | |
| Back | FazBrowse Home | New Git URL |
0 commit comments