Implementation for SEP-2350 Client-side scope accumulation in step-up authorization (#1591)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@microsoft.com>
This commit is contained in:
parent
4dd58c1e11
commit
9b783d7a3b
4 changed files with 511 additions and 12 deletions
76
.github/skills/run-conformance-from-branch/SKILL.md
vendored
Normal file
76
.github/skills/run-conformance-from-branch/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
---
|
||||||
|
name: run-conformance-from-branch
|
||||||
|
description: Run MCP conformance tests in the C# SDK against a conformance branch (including forks) instead of the published npm version, then restore pinned dependencies.
|
||||||
|
compatibility: Requires npm, node, and dotnet SDK. Uses the csharp-sdk repo package.json/package-lock.json and tests/ModelContextProtocol.AspNetCore.Tests.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Run Conformance From Branch
|
||||||
|
|
||||||
|
Run C# SDK conformance tests against an unpublished `modelcontextprotocol/conformance` branch (including branches in forks).
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
- Validate a conformance PR before it is published to npm
|
||||||
|
- Validate C# SDK behavior against a fork with custom scenario changes
|
||||||
|
- Reproduce failures caused by conformance changes
|
||||||
|
|
||||||
|
## Safety / Repo Hygiene
|
||||||
|
|
||||||
|
1. Start from a clean git state.
|
||||||
|
2. Commit or stash local changes first.
|
||||||
|
3. Restore pinned dependencies when done (`npm ci`).
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- **Source type**: `upstream-branch` or `fork-branch`
|
||||||
|
- **Source locator**:
|
||||||
|
- Upstream branch: `modelcontextprotocol/conformance#<branch>`
|
||||||
|
- Fork branch: `<owner>/conformance#<branch>`
|
||||||
|
- **Scenario** (optional): e.g. `auth/scope-step-up`
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
### A) Install directly from GitHub branch (upstream or fork)
|
||||||
|
|
||||||
|
From `csharp-sdk` root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --no-save @modelcontextprotocol/conformance@github:<owner>/conformance#<branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --no-save @modelcontextprotocol/conformance@github:modelcontextprotocol/conformance#main
|
||||||
|
npm install --no-save @modelcontextprotocol/conformance@github:myuser/conformance#sep-2350-check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run Tests
|
||||||
|
|
||||||
|
### Run client conformance tests with dotnet test filter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ClientConformanceTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run server conformance tests with dotnet test filter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ServerConformanceTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
Always report:
|
||||||
|
|
||||||
|
1. Installed conformance source (`npm ls @modelcontextprotocol/conformance --depth=0`)
|
||||||
|
2. Scenario results (pass/fail/warnings)
|
||||||
|
3. Any new check IDs observed (for traceability)
|
||||||
|
|
||||||
|
## Cleanup / Restore
|
||||||
|
|
||||||
|
Return repo to pinned dependency state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
```
|
||||||
|
|
@ -48,6 +48,14 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
private string? _tokenEndpointAuthMethod;
|
private string? _tokenEndpointAuthMethod;
|
||||||
private ITokenCache _tokenCache;
|
private ITokenCache _tokenCache;
|
||||||
private AuthorizationServerMetadata? _authServerMetadata;
|
private AuthorizationServerMetadata? _authServerMetadata;
|
||||||
|
// The accumulated scope set lives for this provider's lifetime and is intentionally not keyed by
|
||||||
|
// resource or authorization server. This is safe today because one ClientOAuthProvider is created
|
||||||
|
// per HttpClientTransport, i.e. per endpoint/resource. If a provider were ever reused across
|
||||||
|
// multiple resources or auth servers, accumulated scopes could be sent to a server that rejects
|
||||||
|
// them (invalid_scope). Accumulation is scoped per "resource and operation" combination (SEP-2350).
|
||||||
|
private readonly HashSet<string> _accumulatedScopes = new(StringComparer.Ordinal);
|
||||||
|
private readonly object _scopeAccumulatorLock = new();
|
||||||
|
private bool _hasAttemptedStepUp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="ClientOAuthProvider"/> class using the specified options.
|
/// Initializes a new instance of the <see cref="ClientOAuthProvider"/> class using the specified options.
|
||||||
|
|
@ -245,6 +253,28 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
ThrowFailedToHandleUnauthorizedResponse("No authorization servers found in authentication challenge");
|
ThrowFailedToHandleUnauthorizedResponse("No authorization servers found in authentication challenge");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SEP-2350: A step-up may legitimately introduce new scopes, so at least one interactive
|
||||||
|
// (re-)authorization attempt is always allowed. However, once a step-up has already been
|
||||||
|
// attempted, a subsequent insufficient_scope challenge that introduces no scope beyond those
|
||||||
|
// already requested cannot make progress by re-running authorization. Treat that repeated,
|
||||||
|
// unproductive challenge as a permanent authorization failure instead of prompting the user
|
||||||
|
// again for the same resource and operation combination.
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
bool introducesNewScopes = ChallengeIntroducesNewScopes(protectedResourceMetadata);
|
||||||
|
lock (_scopeAccumulatorLock)
|
||||||
|
{
|
||||||
|
if (_hasAttemptedStepUp && !introducesNewScopes)
|
||||||
|
{
|
||||||
|
ThrowFailedToHandleUnauthorizedResponse(
|
||||||
|
"A repeated insufficient_scope challenge added no scope beyond those already requested, " +
|
||||||
|
"so step-up authorization cannot satisfy the request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasAttemptedStepUp = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert string URIs to Uri objects for the selector
|
// Convert string URIs to Uri objects for the selector
|
||||||
List<Uri> authServerUris = [];
|
List<Uri> authServerUris = [];
|
||||||
foreach (var serverUriString in availableAuthorizationServers)
|
foreach (var serverUriString in availableAuthorizationServers)
|
||||||
|
|
@ -730,16 +760,92 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
|
|
||||||
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
|
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope))
|
// Determine the scopes for the current operation from the challenge or metadata.
|
||||||
|
var currentOperationScopes = GetCurrentOperationScopes(protectedResourceMetadata);
|
||||||
|
|
||||||
|
if (currentOperationScopes.Count == 0)
|
||||||
{
|
{
|
||||||
return protectedResourceMetadata.WwwAuthenticateScope;
|
lock (_scopeAccumulatorLock)
|
||||||
}
|
{
|
||||||
else if (protectedResourceMetadata.ScopesSupported.Count > 0)
|
// If we have previously requested scopes but nothing new, return the accumulated set.
|
||||||
{
|
return _accumulatedScopes.Count > 0
|
||||||
return string.Join(" ", protectedResourceMetadata.ScopesSupported);
|
? string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return _configuredScopes;
|
// Per SEP-2350: Compute the union of previously requested scopes and newly challenged scopes
|
||||||
|
// to avoid losing permissions needed for other operations during step-up authorization.
|
||||||
|
// Note: the accumulator stores only server-challenged / scopes_supported / configured scopes.
|
||||||
|
// offline_access (AugmentScopeWithOfflineAccess) and any ScopeSelector are applied per request
|
||||||
|
// in ComputeEffectiveScope and are intentionally not accumulated, so the selector always sees
|
||||||
|
// the full union and the operation stays idempotent.
|
||||||
|
lock (_scopeAccumulatorLock)
|
||||||
|
{
|
||||||
|
foreach (var scope in currentOperationScopes)
|
||||||
|
{
|
||||||
|
_accumulatedScopes.Add(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort scopes for stable, deterministic output (scopes are unordered per RFC 6749 §3.3).
|
||||||
|
return string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the scopes required for the current operation, preferring the <c>WWW-Authenticate</c>
|
||||||
|
/// challenge scope, then <c>scopes_supported</c> from the protected resource metadata, then the
|
||||||
|
/// configured scopes. Returns the individual scope tokens so callers can compare and accumulate them
|
||||||
|
/// without re-joining and re-splitting. This does not mutate the accumulated scope set.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyList<string> GetCurrentOperationScopes(ProtectedResourceMetadata protectedResourceMetadata)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope))
|
||||||
|
{
|
||||||
|
return SplitScopes(protectedResourceMetadata.WwwAuthenticateScope!);
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopesSupported = protectedResourceMetadata.ScopesSupported;
|
||||||
|
if (scopesSupported.Count > 0)
|
||||||
|
{
|
||||||
|
// scopes_supported is already a list of individual scopes; avoid join/split round-tripping.
|
||||||
|
return scopesSupported as IReadOnlyList<string> ?? [.. scopesSupported];
|
||||||
|
}
|
||||||
|
|
||||||
|
return _configuredScopes is null ? [] : SplitScopes(_configuredScopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] SplitScopes(string scopes) =>
|
||||||
|
scopes.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <see langword="true"/> if the current challenge requires at least one scope that has not
|
||||||
|
/// already been requested in a previous (re-)authorization. The caller combines this with step-up
|
||||||
|
/// attempt tracking: per SEP-2350, a step-up that adds a new scope is always allowed, but once a
|
||||||
|
/// step-up has been attempted, a later challenge that adds no new scope is treated as a permanent
|
||||||
|
/// failure because re-running interactive authorization cannot make progress.
|
||||||
|
/// </summary>
|
||||||
|
private bool ChallengeIntroducesNewScopes(ProtectedResourceMetadata protectedResourceMetadata)
|
||||||
|
{
|
||||||
|
var currentOperationScopes = GetCurrentOperationScopes(protectedResourceMetadata);
|
||||||
|
if (currentOperationScopes.Count == 0)
|
||||||
|
{
|
||||||
|
// No concrete scope to request, so a re-authorization cannot add anything new.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_scopeAccumulatorLock)
|
||||||
|
{
|
||||||
|
foreach (var scope in currentOperationScopes)
|
||||||
|
{
|
||||||
|
if (!_accumulatedScopes.Contains(scope))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,8 @@ public sealed class ProtectedResourceMetadata
|
||||||
/// The scopes included in the WWW-Authenticate challenge MAY match scopes_supported, be a subset or superset of it,
|
/// The scopes included in the WWW-Authenticate challenge MAY match scopes_supported, be a subset or superset of it,
|
||||||
/// or an alternative collection that is neither a strict subset nor superset. Clients MUST NOT assume any particular
|
/// or an alternative collection that is neither a strict subset nor superset. Clients MUST NOT assume any particular
|
||||||
/// set relationship between the challenged scope set and scopes_supported. Clients MUST treat the scopes provided
|
/// set relationship between the challenged scope set and scopes_supported. Clients MUST treat the scopes provided
|
||||||
/// in the challenge as authoritative for satisfying the current request.
|
/// in the challenge as authoritative for the current operation. When re-authorizing, clients SHOULD include these
|
||||||
|
/// scopes alongside any previously granted scopes to avoid losing permissions needed for other operations (SEP-2350).
|
||||||
///
|
///
|
||||||
/// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
|
/// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -409,7 +409,9 @@ public class AuthTests : OAuthTestBase
|
||||||
await using var client = await McpClient.CreateAsync(
|
await using var client = await McpClient.CreateAsync(
|
||||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal("mcp:tools files:read", requestedScope);
|
var requestedScopeSet = new HashSet<string>(requestedScope!.Split(' '));
|
||||||
|
Assert.Contains("mcp:tools", requestedScopeSet);
|
||||||
|
Assert.Contains("files:read", requestedScopeSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -473,9 +475,13 @@ public class AuthTests : OAuthTestBase
|
||||||
McpServerTool.Create([McpServerTool(Name = "admin-tool")]
|
McpServerTool.Create([McpServerTool(Name = "admin-tool")]
|
||||||
(ClaimsPrincipal user) =>
|
(ClaimsPrincipal user) =>
|
||||||
{
|
{
|
||||||
// Tool now just checks if user has the required scopes
|
// Verify the user's scope claim contains all required admin scopes.
|
||||||
// If they don't, it shouldn't get here due to middleware
|
// With scope accumulation (SEP-2350), the token scope will be the union
|
||||||
Assert.True(user.HasClaim("scope", adminScopes), "User should have admin scopes when tool executes");
|
// of previously granted and newly challenged scopes.
|
||||||
|
var scopeClaim = user.FindFirst("scope")?.Value ?? "";
|
||||||
|
var scopeSet = new HashSet<string>(scopeClaim.Split(' '));
|
||||||
|
Assert.Contains("admin:read", scopeSet);
|
||||||
|
Assert.Contains("admin:write", scopeSet);
|
||||||
return "Admin tool executed.";
|
return "Admin tool executed.";
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
@ -510,9 +516,11 @@ public class AuthTests : OAuthTestBase
|
||||||
|
|
||||||
if (toolCallParams?.Name == "admin-tool")
|
if (toolCallParams?.Name == "admin-tool")
|
||||||
{
|
{
|
||||||
// Check if user has required scopes
|
// Check if user has required scopes (scope claim contains all admin scopes)
|
||||||
var user = context.User;
|
var user = context.User;
|
||||||
if (!user.HasClaim("scope", adminScopes))
|
var scopeClaim = user.FindFirst("scope")?.Value ?? "";
|
||||||
|
var scopeSet = new HashSet<string>(scopeClaim.Split(' '));
|
||||||
|
if (!scopeSet.Contains("admin:read") || !scopeSet.Contains("admin:write"))
|
||||||
{
|
{
|
||||||
// User lacks required scopes, return 403 before MapMcp processes the request
|
// User lacks required scopes, return 403 before MapMcp processes the request
|
||||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
|
@ -554,7 +562,315 @@ public class AuthTests : OAuthTestBase
|
||||||
var adminResult = await client.CallToolAsync("admin-tool", cancellationToken: TestContext.Current.CancellationToken);
|
var adminResult = await client.CallToolAsync("admin-tool", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("Admin tool executed.", adminResult.Content[0].ToString());
|
Assert.Equal("Admin tool executed.", adminResult.Content[0].ToString());
|
||||||
|
|
||||||
Assert.Equal(adminScopes, requestedScope);
|
// SEP-2350: Verify that the step-up authorization request includes the union
|
||||||
|
// of previously requested scopes (mcp:tools) and newly challenged scopes (admin:read admin:write).
|
||||||
|
var requestedScopeSet = new HashSet<string>(requestedScope!.Split(' '));
|
||||||
|
Assert.Contains("mcp:tools", requestedScopeSet);
|
||||||
|
Assert.Contains("admin:read", requestedScopeSet);
|
||||||
|
Assert.Contains("admin:write", requestedScopeSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizationFlow_AccumulatesScopesAcrossMultipleStepUps()
|
||||||
|
{
|
||||||
|
// SEP-2350: Verify scope accumulation across multiple step-up authorization challenges.
|
||||||
|
// First call requires "files:read", second call requires "files:write".
|
||||||
|
// The second authorization request should include both "mcp:tools files:read files:write".
|
||||||
|
|
||||||
|
Builder.Services.AddMcpServer()
|
||||||
|
.WithTools([
|
||||||
|
McpServerTool.Create([McpServerTool(Name = "read-tool")]
|
||||||
|
(ClaimsPrincipal user) =>
|
||||||
|
{
|
||||||
|
return "Read tool executed.";
|
||||||
|
}),
|
||||||
|
McpServerTool.Create([McpServerTool(Name = "write-tool")]
|
||||||
|
(ClaimsPrincipal user) =>
|
||||||
|
{
|
||||||
|
return "Write tool executed.";
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
List<string?> requestedScopes = [];
|
||||||
|
|
||||||
|
await using var app = await StartMcpServerAsync(configureMiddleware: app =>
|
||||||
|
{
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/")
|
||||||
|
{
|
||||||
|
context.Request.EnableBuffering();
|
||||||
|
|
||||||
|
var message = await JsonSerializer.DeserializeAsync(
|
||||||
|
context.Request.Body,
|
||||||
|
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
|
||||||
|
context.RequestAborted) as JsonRpcMessage;
|
||||||
|
|
||||||
|
context.Request.Body.Position = 0;
|
||||||
|
|
||||||
|
if (message is JsonRpcRequest request && request.Method == "tools/call")
|
||||||
|
{
|
||||||
|
var toolCallParams = JsonSerializer.Deserialize(
|
||||||
|
request.Params,
|
||||||
|
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams;
|
||||||
|
|
||||||
|
var user = context.User;
|
||||||
|
var scopeClaim = user.FindFirst("scope")?.Value ?? "";
|
||||||
|
var scopeSet = new HashSet<string>(scopeClaim.Split(' '));
|
||||||
|
|
||||||
|
if (toolCallParams?.Name == "read-tool" && !scopeSet.Contains("files:read"))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"files:read\"";
|
||||||
|
await context.Response.StartAsync(context.RequestAborted);
|
||||||
|
await context.Response.Body.FlushAsync(context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCallParams?.Name == "write-tool" && !scopeSet.Contains("files:write"))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"files:write\"";
|
||||||
|
await context.Response.StartAsync(context.RequestAborted);
|
||||||
|
await context.Response.Body.FlushAsync(context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
requestedScopes.Add(query["scope"].ToString());
|
||||||
|
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Initial auth gets "mcp:tools" from protected resource metadata
|
||||||
|
Assert.Single(requestedScopes);
|
||||||
|
Assert.Equal("mcp:tools", requestedScopes[0]);
|
||||||
|
|
||||||
|
// First step-up: read-tool requires "files:read"
|
||||||
|
var readResult = await client.CallToolAsync("read-tool", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal("Read tool executed.", readResult.Content[0].ToString());
|
||||||
|
Assert.Equal(2, requestedScopes.Count);
|
||||||
|
var secondScopeSet = new HashSet<string>(requestedScopes[1]!.Split(' '));
|
||||||
|
Assert.Contains("mcp:tools", secondScopeSet);
|
||||||
|
Assert.Contains("files:read", secondScopeSet);
|
||||||
|
|
||||||
|
// Second step-up: write-tool requires "files:write"
|
||||||
|
var writeResult = await client.CallToolAsync("write-tool", cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal("Write tool executed.", writeResult.Content[0].ToString());
|
||||||
|
Assert.Equal(3, requestedScopes.Count);
|
||||||
|
var thirdScopeSet = new HashSet<string>(requestedScopes[2]!.Split(' '));
|
||||||
|
Assert.Contains("mcp:tools", thirdScopeSet);
|
||||||
|
Assert.Contains("files:read", thirdScopeSet);
|
||||||
|
Assert.Contains("files:write", thirdScopeSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizationFlow_StopsSteppingUpWhenChallengeAddsNoNewScope()
|
||||||
|
{
|
||||||
|
// SEP-2350: A misconfigured server repeats the same insufficient_scope challenge even after the
|
||||||
|
// client has already requested that scope. Re-running interactive authorization cannot make
|
||||||
|
// progress, so the client must treat it as a permanent failure rather than prompting the user
|
||||||
|
// again on every call to the same resource and operation.
|
||||||
|
|
||||||
|
Builder.Services.AddMcpServer()
|
||||||
|
.WithTools([
|
||||||
|
McpServerTool.Create([McpServerTool(Name = "deny-tool")]
|
||||||
|
(ClaimsPrincipal user) =>
|
||||||
|
{
|
||||||
|
return "Deny tool executed.";
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
List<string?> requestedScopes = [];
|
||||||
|
|
||||||
|
await using var app = await StartMcpServerAsync(configureMiddleware: app =>
|
||||||
|
{
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/")
|
||||||
|
{
|
||||||
|
context.Request.EnableBuffering();
|
||||||
|
|
||||||
|
var message = await JsonSerializer.DeserializeAsync(
|
||||||
|
context.Request.Body,
|
||||||
|
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
|
||||||
|
context.RequestAborted) as JsonRpcMessage;
|
||||||
|
|
||||||
|
context.Request.Body.Position = 0;
|
||||||
|
|
||||||
|
if (message is JsonRpcRequest request && request.Method == "tools/call")
|
||||||
|
{
|
||||||
|
var toolCallParams = JsonSerializer.Deserialize(
|
||||||
|
request.Params,
|
||||||
|
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams;
|
||||||
|
|
||||||
|
// Always reject "deny-tool" with the same challenge, regardless of the token's scopes.
|
||||||
|
if (toolCallParams?.Name == "deny-tool")
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"files:read\"";
|
||||||
|
await context.Response.StartAsync(context.RequestAborted);
|
||||||
|
await context.Response.Body.FlushAsync(context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
requestedScopes.Add(query["scope"].ToString());
|
||||||
|
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Initial auth gets "mcp:tools" from protected resource metadata.
|
||||||
|
Assert.Single(requestedScopes);
|
||||||
|
|
||||||
|
// First call introduces a new scope ("files:read"), so exactly one step-up authorization occurs.
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(
|
||||||
|
() => client.CallToolAsync("deny-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask());
|
||||||
|
Assert.Equal(2, requestedScopes.Count);
|
||||||
|
|
||||||
|
// Second call repeats the same challenge with no new scope. The client must NOT prompt again;
|
||||||
|
// it surfaces a permanent authorization failure instead of re-running interactive authorization.
|
||||||
|
var ex = await Assert.ThrowsAnyAsync<Exception>(
|
||||||
|
() => client.CallToolAsync("deny-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask());
|
||||||
|
Assert.Contains("added no scope beyond those already requested", ex.ToString());
|
||||||
|
|
||||||
|
// No additional authorization prompt was triggered by the second call.
|
||||||
|
Assert.Equal(2, requestedScopes.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizationFlow_AllowsOneStepUpEvenWhenChallengeAddsNoNewScope()
|
||||||
|
{
|
||||||
|
// SEP-2350 (strict reading): A step-up authorization is always allowed at least once, even when
|
||||||
|
// the challenged scope was already requested during the initial authorization. Only a *repeated*
|
||||||
|
// challenge that still adds no new scope is treated as permanent. Here the server always rejects
|
||||||
|
// "deny-tool" with the same "mcp:tools" scope that the client already requested on initial connect.
|
||||||
|
|
||||||
|
Builder.Services.AddMcpServer()
|
||||||
|
.WithTools([
|
||||||
|
McpServerTool.Create([McpServerTool(Name = "deny-tool")]
|
||||||
|
(ClaimsPrincipal user) =>
|
||||||
|
{
|
||||||
|
return "Deny tool executed.";
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
List<string?> requestedScopes = [];
|
||||||
|
|
||||||
|
await using var app = await StartMcpServerAsync(configureMiddleware: app =>
|
||||||
|
{
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/")
|
||||||
|
{
|
||||||
|
context.Request.EnableBuffering();
|
||||||
|
|
||||||
|
var message = await JsonSerializer.DeserializeAsync(
|
||||||
|
context.Request.Body,
|
||||||
|
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
|
||||||
|
context.RequestAborted) as JsonRpcMessage;
|
||||||
|
|
||||||
|
context.Request.Body.Position = 0;
|
||||||
|
|
||||||
|
if (message is JsonRpcRequest request && request.Method == "tools/call")
|
||||||
|
{
|
||||||
|
var toolCallParams = JsonSerializer.Deserialize(
|
||||||
|
request.Params,
|
||||||
|
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams;
|
||||||
|
|
||||||
|
// Always reject "deny-tool" challenging "mcp:tools", which the client already
|
||||||
|
// requested on initial connect, so the challenge never introduces a new scope.
|
||||||
|
if (toolCallParams?.Name == "deny-tool")
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"mcp:tools\"";
|
||||||
|
await context.Response.StartAsync(context.RequestAborted);
|
||||||
|
await context.Response.Body.FlushAsync(context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
requestedScopes.Add(query["scope"].ToString());
|
||||||
|
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Initial auth already requests "mcp:tools" from protected resource metadata.
|
||||||
|
Assert.Single(requestedScopes);
|
||||||
|
Assert.Equal("mcp:tools", requestedScopes[0]);
|
||||||
|
|
||||||
|
// First call: even though the challenged scope is not new, one step-up attempt is still allowed,
|
||||||
|
// so a second authorization request is made.
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(
|
||||||
|
() => client.CallToolAsync("deny-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask());
|
||||||
|
Assert.Equal(2, requestedScopes.Count);
|
||||||
|
|
||||||
|
// Second call: the step-up has already been attempted and the challenge still adds no new scope,
|
||||||
|
// so the client surfaces a permanent failure without prompting again.
|
||||||
|
var ex = await Assert.ThrowsAnyAsync<Exception>(
|
||||||
|
() => client.CallToolAsync("deny-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask());
|
||||||
|
Assert.Contains("added no scope beyond those already requested", ex.ToString());
|
||||||
|
Assert.Equal(2, requestedScopes.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue