Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
79 changes: 59 additions & 20 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,10 @@ private async Task<string> GetAccessTokenAsync(HttpResponseMessage response, boo
LogSelectedAuthorizationServer(selectedAuthServer, availableAuthorizationServers.Count);

// Get auth server metadata
var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false);
var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, protectedResourceMetadata.Resource, cancellationToken).ConfigureAwait(false);

// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
var resourceUri = GetResourceUri(protectedResourceMetadata);

// Only attempt a token refresh if we haven't attempted to already for this request.
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
Expand Down Expand Up @@ -332,7 +332,7 @@ static bool IsValidClientMetadataDocumentUri(Uri uri)
&& uri.AbsolutePath.Length > 1; // AbsolutePath always starts with "/"
}

private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, string? resourceUri, CancellationToken cancellationToken)
{
foreach (var wellKnownEndpoint in GetWellKnownAuthorizationServerMetadataUris(authServerUri))
{
Expand Down Expand Up @@ -376,9 +376,35 @@ private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri a
}
}

if (resourceUri is null)
{
// 2025-03-26 backcompat: when PRM is unavailable and auth server metadata discovery
// also fails, fall back to default endpoint paths per the 2025-03-26 spec.
return BuildDefaultAuthServerMetadata(authServerUri);
}

throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'");
}

/// <summary>
/// Constructs default authorization server metadata using conventional endpoint paths
/// as specified by the MCP 2025-03-26 specification for servers without metadata discovery.
/// </summary>
private static AuthorizationServerMetadata BuildDefaultAuthServerMetadata(Uri authServerUri)
{
var baseUrl = authServerUri.GetLeftPart(UriPartial.Authority);
return new AuthorizationServerMetadata
{
AuthorizationEndpoint = new Uri($"{baseUrl}/authorize"),
TokenEndpoint = new Uri($"{baseUrl}/token"),
RegistrationEndpoint = new Uri($"{baseUrl}/register"),
ResponseTypesSupported = ["code"],
GrantTypesSupported = ["authorization_code", "refresh_token"],
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
CodeChallengeMethodsSupported = ["S256"],
};
}

private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri issuer)
{
var builder = new UriBuilder(issuer);
Expand All @@ -398,15 +424,19 @@ private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri
}
}

private async Task<string?> RefreshTokensAsync(string refreshToken, string resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
private async Task<string?> RefreshTokensAsync(string refreshToken, string? resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
{
Dictionary<string, string> formFields = new()
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["resource"] = resourceUri,
};

if (resourceUri is not null)
{
formFields["resource"] = resourceUri;
}

using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);

using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -445,7 +475,7 @@ private Uri BuildAuthorizationUrl(
AuthorizationServerMetadata authServerMetadata,
string codeChallenge)
{
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
var resourceUri = GetResourceUri(protectedResourceMetadata);

var queryParamsDictionary = new Dictionary<string, string>
{
Expand All @@ -454,9 +484,13 @@ private Uri BuildAuthorizationUrl(
["response_type"] = "code",
["code_challenge"] = codeChallenge,
["code_challenge_method"] = "S256",
["resource"] = resourceUri,
};

if (resourceUri is not null)
{
queryParamsDictionary["resource"] = resourceUri;
}

var scope = GetScopeParameter(protectedResourceMetadata);
if (!string.IsNullOrEmpty(scope))
{
Expand Down Expand Up @@ -490,17 +524,21 @@ private async Task<string> ExchangeCodeForTokenAsync(
string codeVerifier,
CancellationToken cancellationToken)
{
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
var resourceUri = GetResourceUri(protectedResourceMetadata);

Dictionary<string, string> formFields = new()
{
["grant_type"] = "authorization_code",
["code"] = authorizationCode,
["redirect_uri"] = _redirectUri.ToString(),
["code_verifier"] = codeVerifier,
["resource"] = resourceUri,
};

if (resourceUri is not null)
{
formFields["resource"] = resourceUri;
}

using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);

using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -671,15 +709,8 @@ private async Task PerformDynamicClientRegistrationAsync(
}
}

private static string GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
{
if (protectedResourceMetadata.Resource is null)
{
ThrowFailedToHandleUnauthorizedResponse("Protected resource metadata did not include a 'resource' value.");
}

return protectedResourceMetadata.Resource;
}
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
=> protectedResourceMetadata.Resource;

private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
{
Expand Down Expand Up @@ -801,6 +832,7 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
}

ProtectedResourceMetadata? metadata = null;
bool isLegacyFallback = false;

if (resourceMetadataUrl is not null)
{
Expand All @@ -822,7 +854,14 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H

if (metadata is null)
{
throw new McpException($"Failed to find protected resource metadata at a well-known location for {_serverUrl}");
// 2025-03-26 backcompat: server doesn't support PRM (RFC 9728).
// Fall back to treating the MCP server's origin as the authorization server.
var serverOrigin = _serverUrl.GetLeftPart(UriPartial.Authority);
metadata = new ProtectedResourceMetadata
{
AuthorizationServers = [serverOrigin],
};
isLegacyFallback = true;
}
}

Expand All @@ -833,7 +872,7 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server
LogValidatingResourceMetadata(resourceUri);

if (!VerifyResourceMatch(metadata, resourceUri))
if (!isLegacyFallback && !VerifyResourceMatch(metadata, resourceUri))
{
throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({resourceUri})");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ public ClientConformanceTests(ITestOutputHelper output)
[InlineData("auth/resource-mismatch")]
[InlineData("auth/pre-registration")]

// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata) we don't implement.
// [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
// [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata).
[InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
[InlineData("auth/2025-03-26-oauth-endpoint-fallback")]

// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
// [InlineData("auth/client-credentials-jwt")]
Expand Down
175 changes: 175 additions & 0 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1044,4 +1044,179 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash()
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}

[Fact]
public async Task CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetadata()
{
// 2025-03-26 backcompat: server does NOT serve PRM, but DOES serve auth server metadata.
// The client should fall back to using the MCP server's origin as the auth server
// and discover auth metadata from well-known URLs on that origin.
TestOAuthServer.RequireResource = false;

// Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata.
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);

// Legacy servers don't use resource-based audiences in tokens (no resource parameter is sent).
Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters.ValidateAudience = false;
});

await using var app = Builder.Build();

app.Use(async (context, next) =>
{
// Return 404 for PRM to simulate a legacy server that doesn't support RFC 9728.
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource"))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}

// Serve auth server metadata pointing to the real OAuth server endpoints.
// In a real 2025-03-26 deployment, the MCP server itself would be the auth server.
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") ||
context.Request.Path.StartsWithSegments("/.well-known/openid-configuration"))
{
context.Response.ContentType = "application/json";
await context.Response.WriteAsync($$"""
{
"issuer": "{{OAuthServerUrl}}",
"authorization_endpoint": "{{OAuthServerUrl}}/authorize",
"token_endpoint": "{{OAuthServerUrl}}/token",
"registration_endpoint": "{{OAuthServerUrl}}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"code_challenge_methods_supported": ["S256"]
}
""");
return;
}

await next();
});

app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);

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

[Fact]
public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback()
{
// 2025-03-26 backcompat: server does NOT serve PRM AND does NOT serve auth server metadata.
// The client should fall back to default endpoint paths (/authorize, /token, /register)
// on the MCP server's origin.
TestOAuthServer.RequireResource = false;

// Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata.
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);

// Legacy servers don't use resource-based audiences in tokens (no resource parameter is sent).
Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters.ValidateAudience = false;
});

await using var app = Builder.Build();

// Capture HttpClient for use in the proxy middleware.
var httpClient = HttpClient;

app.Use(async (context, next) =>
{
// Return 404 for PRM to simulate a legacy server that doesn't support RFC 9728.
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource"))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}

// Return 404 for auth server metadata to force fallback to default endpoints.
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") ||
context.Request.Path.StartsWithSegments("/.well-known/openid-configuration"))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}

// Proxy default OAuth endpoints to the real OAuth server.
// In a real 2025-03-26 deployment, the MCP server itself would host these endpoints.
var path = context.Request.Path.Value;
if (path is "/authorize" or "/token" or "/register")
{
var targetUrl = $"{OAuthServerUrl}{path}{context.Request.QueryString}";
using var proxyRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUrl);

if (context.Request.ContentLength > 0 || context.Request.ContentType is not null)
{
proxyRequest.Content = new StreamContent(context.Request.Body);
if (context.Request.ContentType is not null)
{
proxyRequest.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType);
}
}

if (context.Request.Headers.Authorization.Count > 0)
{
proxyRequest.Headers.TryAddWithoutValidation("Authorization", context.Request.Headers.Authorization.ToString());
}

using var response = await httpClient.SendAsync(proxyRequest);
context.Response.StatusCode = (int)response.StatusCode;

if (response.Headers.Location is not null)
{
context.Response.Headers.Location = response.Headers.Location.ToString();
}

if (response.Content.Headers.ContentType is not null)
{
context.Response.ContentType = response.Content.Headers.ContentType.ToString();
}

await response.Content.CopyToAsync(context.Response.Body);
return;
}

await next();
});

app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);

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