Retry OAuth token refresh without resource when the authorization server rejects it (Entra AADSTS9010010) by 1aifanatic · Pull Request #1883 · modelcontextprotocol/csharp-sdk · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 55 additions & 20 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,60 @@ public async Task CannotAuthenticate_WithInvalidClientMetadataDocument(string ur

[Fact]
public async Task CanAuthenticate_WithTokenRefresh()
{
await using var app = await StartMcpServerThatForcesTokenRefreshAsync();
await using var transport = CreateOAuthTransport();
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);

// Conformant authorization servers only see the spec-required refresh request that includes the resource.
Assert.Equal([McpServerUrl], TestOAuthServer.RefreshTokenRequestResources);
Assert.Equal(1, TestOAuthServer.AuthorizationCodeTokenRequestCount);
}

[Fact]
public async Task CanAuthenticate_WithTokenRefresh_WhenAuthServerRejectsResourceOnRefresh()
{
// Simulates Microsoft Entra ID v2.0, which rejects the RFC 8707 resource parameter on refresh_token grants (AADSTS9010010).
TestOAuthServer.RejectRefreshWithResourceError = "invalid_target";

await using var app = await StartMcpServerThatForcesTokenRefreshAsync();
await using var transport = CreateOAuthTransport();
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);

await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);

// The rejected refresh is retried once without the resource instead of forcing interactive re-authorization.
Assert.True(TestOAuthServer.HasRefreshedToken);
Assert.Equal([McpServerUrl, null], TestOAuthServer.RefreshTokenRequestResources);
Assert.Equal(1, TestOAuthServer.AuthorizationCodeTokenRequestCount);
}

[Fact]
public async Task TokenRefresh_RejectedWithInvalidGrant_IsNotRetriedWithoutResource()
{
// invalid_grant means the refresh token itself is dead, so dropping the resource can't help.
TestOAuthServer.RejectRefreshWithResourceError = "invalid_grant";

await using var app = await StartMcpServerThatForcesTokenRefreshAsync();
await using var transport = CreateOAuthTransport();
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);

await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);

// The client falls back to a new authorization-code flow instead.
Assert.False(TestOAuthServer.HasRefreshedToken);
Assert.Equal([McpServerUrl], TestOAuthServer.RefreshTokenRequestResources);
Assert.Equal(2, TestOAuthServer.AuthorizationCodeTokenRequestCount);
}

private async Task<WebApplication> StartMcpServerThatForcesTokenRefreshAsync()
{
var hasForcedRefresh = false;

Expand All @@ -531,7 +585,7 @@ public async Task CanAuthenticate_WithTokenRefresh()
options.ToolCollection = new();
});

await using var app = await StartMcpServerAsync(configureMiddleware: app =>
return await StartMcpServerAsync(configureMiddleware: app =>
{
// Add middleware to intercept list tools requests and force a token refresh on the first call
app.Use(async (context, next) =>
Expand Down Expand Up @@ -566,25 +620,6 @@ public async Task CanAuthenticate_WithTokenRefresh()
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"),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);

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);
}

[Fact]
Expand Down
29 changes: 28 additions & 1 deletion tests/ModelContextProtocol.TestOAuthServer/Program.cs