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
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,21 @@ private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri

if (!httpResponse.IsSuccessStatusCode)
{
// The MCP authorization spec requires the RFC 8707 resource parameter on token requests, but some
// authorization servers reject it on refresh_token grants (Microsoft Entra ID v2.0 fails with
// AADSTS9010010). Rather than forcing interactive re-authorization every time the access token
// expires, retry the refresh once without it. A dead refresh token (invalid_grant) can't be fixed
// by dropping the resource, so that falls through to re-authorization as before.
if (resourceUri is not null && httpResponse.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var error = await ReadOAuthErrorCodeAsync(httpResponse, cancellationToken).ConfigureAwait(false);
if (error != "invalid_grant")
{
LogOAuthTokenRefreshRetryingWithoutResource(resourceUri, error);
return await RefreshTokensAsync(refreshToken, resourceUri: null, authServerMetadata, cancellationToken).ConfigureAwait(false);
}
}

return null;
}

Expand All @@ -698,6 +713,19 @@ private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri
return tokens.AccessToken;
}

private static async Task<string?> ReadOAuthErrorCodeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
try
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize(body, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse)?.Error;
}
catch (JsonException)
{
return null;
}
}

private async Task<string> InitiateAuthorizationCodeFlowAsync(
ProtectedResourceMetadata protectedResourceMetadata,
AuthorizationServerMetadata authServerMetadata,
Expand Down Expand Up @@ -1572,6 +1600,9 @@ private static void ThrowFailedToHandleUnauthorizedResponse(string message) =>
[LoggerMessage(Level = LogLevel.Information, Message = "OAuth token refresh completed successfully")]
partial void LogOAuthTokenRefreshCompleted();

[LoggerMessage(Level = LogLevel.Warning, Message = "OAuth token refresh with resource '{Resource}' was rejected with error '{Error}'. Retrying without the resource parameter.")]
partial void LogOAuthTokenRefreshRetryingWithoutResource(string resource, string? error);

[LoggerMessage(Level = LogLevel.Error, Message = "Error fetching auth server metadata from {Endpoint}")]
partial void LogErrorFetchingAuthServerMetadata(Exception ex, Uri endpoint);

Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public sealed class Program

private readonly ConcurrentQueue<string> _metadataRequests = new();
private int _authorizationCodeTokenRequestCount;
private readonly ConcurrentQueue<string?> _refreshTokenRequestResources = new();

private readonly RSA _rsa;
private readonly string _keyId;
Expand Down Expand Up @@ -91,6 +92,13 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
/// </remarks>
public bool ExpectResource { get; set; } = true;

/// <summary>
/// Gets or sets the OAuth error code returned for <c>refresh_token</c> grants that include a resource parameter.
/// When set, such grants are rejected and grants without a resource parameter are accepted, simulating
/// authorization servers like Microsoft Entra ID v2.0 that reject RFC 8707 resource indicators on refresh (AADSTS9010010).
/// </summary>
public string? RejectRefreshWithResourceError { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the authorization server advertises support for
/// <c>offline_access</c> in its <c>scopes_supported</c> metadata. This simulates an OIDC-flavored
Expand Down Expand Up @@ -141,6 +149,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
/// <summary>Gets the number of authorization-code token exchange requests received.</summary>
public int AuthorizationCodeTokenRequestCount => Volatile.Read(ref _authorizationCodeTokenRequestCount);

/// <summary>Gets the <c>resource</c> field of each <c>refresh_token</c> grant received, or <see langword="null"/> where it was absent.</summary>
public IReadOnlyCollection<string?> RefreshTokenRequestResources => _refreshTokenRequestResources.ToArray();

/// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary>
public string? LastRegistrationScope { get; private set; }

Expand Down Expand Up @@ -452,7 +463,23 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
// RFC 7523 JWT-bearer assertions carry the target resource inside the JWT itself,
// so we skip the form-level resource check for that grant type.
var resource = form["resource"].ToString();
if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
if (grant_type == "refresh_token")
{
_refreshTokenRequestResources.Enqueue(string.IsNullOrEmpty(resource) ? null : resource);
}

if (grant_type == "refresh_token" && RejectRefreshWithResourceError is { } refreshResourceError)
{
if (!string.IsNullOrEmpty(resource))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = refreshResourceError,
ErrorDescription = "AADSTS9010010: The resource parameter provided in the request doesn't match with the requested scopes."
});
}
}
else if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
(ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)))
{
return Results.BadRequest(new OAuthErrorResponse
Expand Down