Add 2025-03-26 OAuth backward compatibility for client conformance (#1374)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c57f917d63
commit
4e68a4e9ec
4 changed files with 291 additions and 30 deletions
|
|
@ -270,10 +270,10 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
LogSelectedAuthorizationServer(selectedAuthServer, availableAuthorizationServers.Count);
|
LogSelectedAuthorizationServer(selectedAuthServer, availableAuthorizationServers.Count);
|
||||||
|
|
||||||
// Get auth server metadata
|
// 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.
|
// 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.
|
// 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
|
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
|
||||||
|
|
@ -332,7 +332,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
&& uri.AbsolutePath.Length > 1; // AbsolutePath always starts with "/"
|
&& 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))
|
foreach (var wellKnownEndpoint in GetWellKnownAuthorizationServerMetadataUris(authServerUri))
|
||||||
{
|
{
|
||||||
|
|
@ -376,9 +376,35 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}'");
|
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)
|
private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri issuer)
|
||||||
{
|
{
|
||||||
var builder = new UriBuilder(issuer);
|
var builder = new UriBuilder(issuer);
|
||||||
|
|
@ -398,15 +424,19 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
Dictionary<string, string> formFields = new()
|
||||||
{
|
{
|
||||||
["grant_type"] = "refresh_token",
|
["grant_type"] = "refresh_token",
|
||||||
["refresh_token"] = refreshToken,
|
["refresh_token"] = refreshToken,
|
||||||
["resource"] = resourceUri,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (resourceUri is not null)
|
||||||
|
{
|
||||||
|
formFields["resource"] = resourceUri;
|
||||||
|
}
|
||||||
|
|
||||||
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
|
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
|
||||||
|
|
||||||
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
@ -445,7 +475,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
AuthorizationServerMetadata authServerMetadata,
|
AuthorizationServerMetadata authServerMetadata,
|
||||||
string codeChallenge)
|
string codeChallenge)
|
||||||
{
|
{
|
||||||
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
var resourceUri = GetResourceUri(protectedResourceMetadata);
|
||||||
|
|
||||||
var queryParamsDictionary = new Dictionary<string, string>
|
var queryParamsDictionary = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
|
|
@ -454,9 +484,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
["response_type"] = "code",
|
["response_type"] = "code",
|
||||||
["code_challenge"] = codeChallenge,
|
["code_challenge"] = codeChallenge,
|
||||||
["code_challenge_method"] = "S256",
|
["code_challenge_method"] = "S256",
|
||||||
["resource"] = resourceUri,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (resourceUri is not null)
|
||||||
|
{
|
||||||
|
queryParamsDictionary["resource"] = resourceUri;
|
||||||
|
}
|
||||||
|
|
||||||
var scope = GetScopeParameter(protectedResourceMetadata);
|
var scope = GetScopeParameter(protectedResourceMetadata);
|
||||||
if (!string.IsNullOrEmpty(scope))
|
if (!string.IsNullOrEmpty(scope))
|
||||||
{
|
{
|
||||||
|
|
@ -490,7 +524,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
string codeVerifier,
|
string codeVerifier,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
var resourceUri = GetResourceUri(protectedResourceMetadata);
|
||||||
|
|
||||||
Dictionary<string, string> formFields = new()
|
Dictionary<string, string> formFields = new()
|
||||||
{
|
{
|
||||||
|
|
@ -498,9 +532,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
["code"] = authorizationCode,
|
["code"] = authorizationCode,
|
||||||
["redirect_uri"] = _redirectUri.ToString(),
|
["redirect_uri"] = _redirectUri.ToString(),
|
||||||
["code_verifier"] = codeVerifier,
|
["code_verifier"] = codeVerifier,
|
||||||
["resource"] = resourceUri,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (resourceUri is not null)
|
||||||
|
{
|
||||||
|
formFields["resource"] = resourceUri;
|
||||||
|
}
|
||||||
|
|
||||||
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
|
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
|
||||||
|
|
||||||
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
@ -671,15 +709,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
|
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
|
||||||
{
|
=> protectedResourceMetadata.Resource;
|
||||||
if (protectedResourceMetadata.Resource is null)
|
|
||||||
{
|
|
||||||
ThrowFailedToHandleUnauthorizedResponse("Protected resource metadata did not include a 'resource' value.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return protectedResourceMetadata.Resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
|
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
|
||||||
{
|
{
|
||||||
|
|
@ -801,6 +832,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
ProtectedResourceMetadata? metadata = null;
|
ProtectedResourceMetadata? metadata = null;
|
||||||
|
bool isLegacyFallback = false;
|
||||||
|
|
||||||
if (resourceMetadataUrl is not null)
|
if (resourceMetadataUrl is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -822,7 +854,14 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
|
|
||||||
if (metadata is null)
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -833,7 +872,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server
|
// 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);
|
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})");
|
throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({resourceUri})");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ public class ClientConformanceTests
|
||||||
[InlineData("auth/resource-mismatch")]
|
[InlineData("auth/resource-mismatch")]
|
||||||
[InlineData("auth/pre-registration")]
|
[InlineData("auth/pre-registration")]
|
||||||
|
|
||||||
// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata) we don't implement.
|
// 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-metadata-backcompat")]
|
||||||
// [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
|
[InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
|
||||||
|
|
||||||
// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
|
// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
|
||||||
// [InlineData("auth/client-credentials-jwt")]
|
// [InlineData("auth/client-credentials-jwt")]
|
||||||
|
|
|
||||||
|
|
@ -586,7 +586,7 @@ public class AuthTests : OAuthTestBase
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CannotAuthenticate_WhenProtectedResourceMetadataMissingResource()
|
public async Task CannotAuthenticate_WhenProtectedResourceMetadataMissingResource()
|
||||||
{
|
{
|
||||||
TestOAuthServer.RequireResource = false;
|
TestOAuthServer.ExpectResource = false;
|
||||||
|
|
||||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||||
{
|
{
|
||||||
|
|
@ -1044,4 +1044,221 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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.ExpectResource = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve auth server metadata pointing to the MCP server's own 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": "{{McpServerUrl}}/authorize",
|
||||||
|
"token_endpoint": "{{McpServerUrl}}/token",
|
||||||
|
"registration_endpoint": "{{McpServerUrl}}/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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.ExpectResource = 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,15 @@ public sealed class Program
|
||||||
public bool ClientIdMetadataDocumentSupported { get; set; } = true;
|
public bool ClientIdMetadataDocumentSupported { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether the authorization server requires a resource parameter.
|
/// Gets or sets a value indicating whether the authorization server expects a resource parameter.
|
||||||
|
/// When <c>true</c>, the resource parameter must be present and match a valid resource.
|
||||||
|
/// When <c>false</c>, the resource parameter must be absent to simulate legacy servers that
|
||||||
|
/// do not support RFC 8707 resource indicators.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The default value is <c>true</c>.
|
/// The default value is <c>true</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool RequireResource { get; set; } = true;
|
public bool ExpectResource { get; set; } = true;
|
||||||
|
|
||||||
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
||||||
|
|
@ -297,8 +300,9 @@ public sealed class Program
|
||||||
return Results.Redirect($"{redirect_uri}?error=invalid_request&error_description=Only+S256+code_challenge_method+is+supported&state={state}");
|
return Results.Redirect($"{redirect_uri}?error=invalid_request&error_description=Only+S256+code_challenge_method+is+supported&state={state}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate resource in accordance with RFC 8707
|
// Validate resource in accordance with RFC 8707.
|
||||||
if (RequireResource && (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)))
|
// When ExpectResource is false, the resource parameter must be absent (legacy mode).
|
||||||
|
if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource))
|
||||||
{
|
{
|
||||||
return Results.Redirect($"{redirect_uri}?error=invalid_target&error_description=The+specified+resource+is+not+valid&state={state}");
|
return Results.Redirect($"{redirect_uri}?error=invalid_target&error_description=The+specified+resource+is+not+valid&state={state}");
|
||||||
}
|
}
|
||||||
|
|
@ -344,9 +348,10 @@ public sealed class Program
|
||||||
type: "https://tools.ietf.org/html/rfc6749#section-5.2");
|
type: "https://tools.ietf.org/html/rfc6749#section-5.2");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate resource in accordance with RFC 8707
|
// Validate resource in accordance with RFC 8707.
|
||||||
|
// When ExpectResource is false, the resource parameter must be absent (legacy mode).
|
||||||
var resource = form["resource"].ToString();
|
var resource = form["resource"].ToString();
|
||||||
if (RequireResource && (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)))
|
if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new OAuthErrorResponse
|
return Results.BadRequest(new OAuthErrorResponse
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue