Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)

This commit is contained in:
Den Delimarsky 🌺 2025-07-03 14:39:28 -07:00 • committed by GitHub
commit cdf88e7f5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 4207 additions and 126 deletions

View file

@ -19,6 +19,7 @@
<!-- Product dependencies LTS -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
@ -26,6 +27,8 @@
<!-- Product dependencies .NET 9 -->
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="$(System9Version)" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(System9Version)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(System9Version)" />
<PackageVersion Include="System.IO.Pipelines" Version="$(System9Version)" />

View file

@ -12,6 +12,8 @@
<Project Path="samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj" />
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
<Project Path="samples/EverythingServer/EverythingServer.csproj" />
<Project Path="samples/ProtectedMCPClient/ProtectedMCPClient.csproj" />
<Project Path="samples/ProtectedMCPServer/ProtectedMCPServer.csproj" />
<Project Path="samples/QuickstartClient/QuickstartClient.csproj" />
<Project Path="samples/QuickstartWeatherServer/QuickstartWeatherServer.csproj" />
<Project Path="samples/TestServerWithHosting/TestServerWithHosting.csproj" />
@ -33,6 +35,7 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj" />
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj" />
<Project Path="tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj" />

View file

@ -0,0 +1,148 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Web;
var serverUrl = "http://localhost:7071/";
Console.WriteLine("Protected MCP Client");
Console.WriteLine($"Connecting to weather server at {serverUrl}...");
Console.WriteLine();
// We can customize a shared HttpClient with a custom handler if desired
var sharedHandler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1)
};
var httpClient = new HttpClient(sharedHandler);
var consoleLoggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
});
var transport = new SseClientTransport(new()
{
Endpoint = new Uri(serverUrl),
Name = "Secure Weather Client",
OAuth = new()
{
ClientName = "ProtectedMcpClient",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
}
}, httpClient, consoleLoggerFactory);
var client = await McpClientFactory.CreateAsync(transport, loggerFactory: consoleLoggerFactory);
var tools = await client.ListToolsAsync();
if (tools.Count == 0)
{
Console.WriteLine("No tools available on the server.");
return;
}
Console.WriteLine($"Found {tools.Count} tools on the server.");
Console.WriteLine();
if (tools.Any(t => t.Name == "get_alerts"))
{
Console.WriteLine("Calling get_alerts tool...");
var result = await client.CallToolAsync(
"get_alerts",
new Dictionary<string, object?> { { "state", "WA" } }
);
Console.WriteLine("Result: " + ((TextContentBlock)result.Content[0]).Text);
Console.WriteLine();
}
/// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
/// This implementation demonstrates how SDK consumers can provide their own authorization flow.
/// </summary>
/// <param name="authorizationUrl">The authorization URL to open in the browser.</param>
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The authorization code extracted from the callback, or null if the operation failed.</returns>
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
{
Console.WriteLine("Starting OAuth authorization flow...");
Console.WriteLine($"Opening browser to: {authorizationUrl}");
var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority);
if (!listenerPrefix.EndsWith("/")) listenerPrefix += "/";
using var listener = new HttpListener();
listener.Prefixes.Add(listenerPrefix);
try
{
listener.Start();
Console.WriteLine($"Listening for OAuth callback on: {listenerPrefix}");
OpenBrowser(authorizationUrl);
var context = await listener.GetContextAsync();
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
var code = query["code"];
var error = query["error"];
string responseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
byte[] buffer = Encoding.UTF8.GetBytes(responseHtml);
context.Response.ContentLength64 = buffer.Length;
context.Response.ContentType = "text/html";
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
context.Response.Close();
if (!string.IsNullOrEmpty(error))
{
Console.WriteLine($"Auth error: {error}");
return null;
}
if (string.IsNullOrEmpty(code))
{
Console.WriteLine("No authorization code received");
return null;
}
Console.WriteLine("Authorization code received successfully.");
return code;
}
catch (Exception ex)
{
Console.WriteLine($"Error getting auth code: {ex.Message}");
return null;
}
finally
{
if (listener.IsListening) listener.Stop();
}
}
/// <summary>
/// Opens the specified URL in the default browser.
/// </summary>
/// <param name="url">The URL to open.</param>
static void OpenBrowser(Uri url)
{
try
{
var psi = new ProcessStartInfo
{
FileName = url.ToString(),
UseShellExecute = true
};
Process.Start(psi);
}
catch (Exception ex)
{
Console.WriteLine($"Error opening browser. {ex.Message}");
Console.WriteLine($"Please manually open this URL: {url}");
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,93 @@
# Protected MCP Client Sample
This sample demonstrates how to create an MCP client that connects to a protected MCP server using OAuth 2.0 authentication. The client implements a custom OAuth authorization flow with browser-based authentication.
## Overview
The Protected MCP Client sample shows how to:
- Connect to an OAuth-protected MCP server
- Handle OAuth 2.0 authorization code flow
- Use custom authorization redirect handling
- Call protected MCP tools with authentication
## Prerequisites
- .NET 9.0 or later
- A running TestOAuthServer (for OAuth authentication)
- A running ProtectedMCPServer (for MCP services)
## Setup and Running
### Step 1: Start the Test OAuth Server
First, you need to start the TestOAuthServer which provides OAuth authentication:
```bash
cd tests\ModelContextProtocol.TestOAuthServer
dotnet run --framework net9.0
```
The OAuth server will start at `https://localhost:7029`
### Step 2: Start the Protected MCP Server
Next, start the ProtectedMCPServer which provides the weather tools:
```bash
cd samples\ProtectedMCPServer
dotnet run
```
The protected server will start at `http://localhost:7071`
### Step 3: Run the Protected MCP Client
Finally, run this client:
```bash
cd samples\ProtectedMCPClient
dotnet run
```
## What Happens
1. The client attempts to connect to the protected MCP server at `http://localhost:7071`
2. The server responds with OAuth metadata indicating authentication is required
3. The client initiates OAuth 2.0 authorization code flow:
- Opens a browser to the authorization URL at the OAuth server
- Starts a local HTTP listener on `http://localhost:1179/callback` to receive the authorization code
- Exchanges the authorization code for an access token
4. The client uses the access token to authenticate with the MCP server
5. The client lists available tools and calls the `GetAlerts` tool for Washington state
## OAuth Configuration
The client is configured with:
- **Client ID**: `demo-client`
- **Client Secret**: `demo-secret`
- **Redirect URI**: `http://localhost:1179/callback`
- **OAuth Server**: `https://localhost:7029`
- **Protected Resource**: `http://localhost:7071`
## Available Tools
Once authenticated, the client can access weather tools including:
- **GetAlerts**: Get weather alerts for a US state
- **GetForecast**: Get weather forecast for a location (latitude/longitude)
## Troubleshooting
- Ensure the ASP.NET Core dev certificate is trusted.
```
dotnet dev-certs https --clean
dotnet dev-certs https --trust
```
- Ensure all three services are running in the correct order
- Check that ports 7029, 7071, and 1179 are available
- If the browser doesn't open automatically, copy the authorization URL from the console and open it manually
- Make sure to allow the OAuth server's self-signed certificate in your browser
## Key Files
- `Program.cs`: Main client application with OAuth flow implementation
- `ProtectedMCPClient.csproj`: Project file with dependencies

View file

@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore.Authentication;
using ProtectedMCPServer.Tools;
using System.Net.Http.Headers;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
var serverUrl = "http://localhost:7071/";
var inMemoryOAuthServerUrl = "https://localhost:7029";
builder.Services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
// Configure to validate tokens from our in-memory OAuth server
options.Authority = inMemoryOAuthServerUrl;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidAudience = serverUrl, // Validate that the audience matches the resource metadata as suggested in RFC 8707
ValidIssuer = inMemoryOAuthServerUrl,
NameClaimType = "name",
RoleClaimType = "roles"
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
var name = context.Principal?.Identity?.Name ?? "unknown";
var email = context.Principal?.FindFirstValue("preferred_username") ?? "unknown";
Console.WriteLine($"Token validated for: {name} ({email})");
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
Console.WriteLine($"Authentication failed: {context.Exception.Message}");
return Task.CompletedTask;
},
OnChallenge = context =>
{
Console.WriteLine($"Challenging client to authenticate with Entra ID");
return Task.CompletedTask;
}
};
})
.AddMcp(options =>
{
options.ResourceMetadata = new()
{
Resource = new Uri(serverUrl),
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
ScopesSupported = ["mcp:tools"],
};
});
builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor();
builder.Services.AddMcpServer()
.WithTools<WeatherTools>()
.WithHttpTransport();
// Configure HttpClientFactory for weather.gov API
builder.Services.AddHttpClient("WeatherApi", client =>
{
client.BaseAddress = new Uri("https://api.weather.gov");
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0"));
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Use the default MCP policy name that we've configured
app.MapMcp().RequireAuthorization();
Console.WriteLine($"Starting MCP server with authorization at {serverUrl}");
Console.WriteLine($"Using in-memory OAuth server at {inMemoryOAuthServerUrl}");
Console.WriteLine($"Protected Resource Metadata URL: {serverUrl}.well-known/oauth-protected-resource");
Console.WriteLine("Press Ctrl+C to stop the server");
app.Run(serverUrl);

View file

@ -0,0 +1,12 @@
{
"profiles": {
"ProtectedMCPServer": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:7071"
}
}
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>783daef3-9c45-408d-a1d3-7caf44724f39</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,125 @@
# Protected MCP Server Sample
This sample demonstrates how to create an MCP server that requires OAuth 2.0 authentication to access its tools and resources. The server provides weather-related tools protected by JWT bearer token authentication.
## Overview
The Protected MCP Server sample shows how to:
- Create an MCP server with OAuth 2.0 protection
- Configure JWT bearer token authentication
- Implement protected MCP tools and resources
- Integrate with ASP.NET Core authentication and authorization
- Provide OAuth resource metadata for client discovery
## Prerequisites
- .NET 9.0 or later
- A running TestOAuthServer (for OAuth authentication)
## Setup and Running
### Step 1: Start the Test OAuth Server
First, you need to start the TestOAuthServer which issues access tokens:
```bash
cd tests\ModelContextProtocol.TestOAuthServer
dotnet run --framework net9.0
```
The OAuth server will start at `https://localhost:7029`
### Step 2: Start the Protected MCP Server
Run this protected server:
```bash
cd samples\ProtectedMCPServer
dotnet run
```
The protected server will start at `http://localhost:7071`
### Step 3: Test with Protected MCP Client
You can test the server using the ProtectedMCPClient sample:
```bash
cd samples\ProtectedMCPClient
dotnet run
```
## What the Server Provides
### Protected Resources
- **MCP Endpoint**: `http://localhost:7071/` (requires authentication)
- **OAuth Resource Metadata**: `http://localhost:7071/.well-known/oauth-protected-resource`
### Available Tools
The server provides weather-related tools that require authentication:
1. **GetAlerts**: Get weather alerts for a US state
- Parameter: `state` (string) - 2-letter US state abbreviation
- Example: `GetAlerts` with `state: "WA"`
2. **GetForecast**: Get weather forecast for a location
- Parameters:
- `latitude` (double) - Latitude coordinate
- `longitude` (double) - Longitude coordinate
- Example: `GetForecast` with `latitude: 47.6062, longitude: -122.3321`
### Authentication Configuration
The server is configured to:
- Accept JWT bearer tokens from the OAuth server at `https://localhost:7029`
- Validate token audience as `demo-client`
- Require tokens to have appropriate scopes (`mcp:tools`)
- Provide OAuth resource metadata for client discovery
## Architecture
The server uses:
- **ASP.NET Core** for hosting and HTTP handling
- **JWT Bearer Authentication** for token validation
- **MCP Authentication Extensions** for OAuth resource metadata
- **HttpClient** for calling the weather.gov API
- **Authorization** to protect MCP endpoints
## Configuration Details
- **Server URL**: `http://localhost:7071`
- **OAuth Server**: `https://localhost:7029`
- **Demo Client ID**: `demo-client`
## Testing Without Client
You can test the server directly using HTTP tools:
1. Get an access token from the OAuth server
2. Include the token in the `Authorization: Bearer <token>` header
3. Make requests to the MCP endpoints
## External Dependencies
The weather tools use the National Weather Service API at `api.weather.gov` to fetch real weather data.
## Troubleshooting
- Ensure the ASP.NET Core dev certificate is trusted.
```
dotnet dev-certs https --clean
dotnet dev-certs https --trust
```
- Ensure the TestOAuthServer is running first
- Check that port 7071 is available
- Verify the OAuth server is accessible at `https://localhost:7029`
- Check console output for authentication events and errors
## Key Files
- `Program.cs`: Server setup with authentication and MCP configuration
- `Tools/WeatherTools.cs`: Weather tool implementations
- `Tools/HttpClientExt.cs`: HTTP client extensions
- `Properties/launchSettings.json`: Development launch configuration

View file

@ -0,0 +1,13 @@
using System.Text.Json;
namespace ModelContextProtocol;
internal static class HttpClientExt
{
public static async Task<JsonDocument> ReadJsonDocumentAsync(this HttpClient client, string requestUri)
{
using var response = await client.GetAsync(requestUri);
response.EnsureSuccessStatusCode();
return await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
}
}

View file

@ -0,0 +1,67 @@
using ModelContextProtocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Globalization;
using System.Text.Json;
namespace ProtectedMCPServer.Tools;
[McpServerToolType]
public sealed class WeatherTools
{
private readonly IHttpClientFactory _httpClientFactory;
public WeatherTools(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
[McpServerTool, Description("Get weather alerts for a US state.")]
public async Task<string> GetAlerts(
[Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state)
{
var client = _httpClientFactory.CreateClient("WeatherApi");
using var jsonDocument = await client.ReadJsonDocumentAsync($"/alerts/active/area/{state}");
var jsonElement = jsonDocument.RootElement;
var alerts = jsonElement.GetProperty("features").EnumerateArray();
if (!alerts.Any())
{
return "No active alerts for this state.";
}
return string.Join("\n--\n", alerts.Select(alert =>
{
JsonElement properties = alert.GetProperty("properties");
return $"""
Event: {properties.GetProperty("event").GetString()}
Area: {properties.GetProperty("areaDesc").GetString()}
Severity: {properties.GetProperty("severity").GetString()}
Description: {properties.GetProperty("description").GetString()}
Instruction: {properties.GetProperty("instruction").GetString()}
""";
}));
}
[McpServerTool, Description("Get weather forecast for a location.")]
public async Task<string> GetForecast(
[Description("Latitude of the location.")] double latitude,
[Description("Longitude of the location.")] double longitude)
{
var client = _httpClientFactory.CreateClient("WeatherApi");
var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}");
using var jsonDocument = await client.ReadJsonDocumentAsync(pointUrl);
var forecastUrl = jsonDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
?? throw new Exception($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}");
using var forecastDocument = await client.ReadJsonDocumentAsync(forecastUrl);
var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray();
return string.Join("\n---\n", periods.Select(period => $"""
{period.GetProperty("name").GetString()}
Temperature: {period.GetProperty("temperature").GetInt32()}°F
Wind: {period.GetProperty("windSpeed").GetString()} {period.GetProperty("windDirection").GetString()}
Forecast: {period.GetProperty("detailedForecast").GetString()}
"""));
}
}

View file

@ -0,0 +1,17 @@
namespace ModelContextProtocol.AspNetCore.Authentication;
/// <summary>
/// Default values used by MCP authentication.
/// </summary>
public static class McpAuthenticationDefaults
{
/// <summary>
/// The default value used for authentication scheme name.
/// </summary>
public const string AuthenticationScheme = "McpAuth";
/// <summary>
/// The default value used for authentication scheme display name.
/// </summary>
public const string DisplayName = "MCP Authentication";
}

View file

@ -0,0 +1,17 @@
namespace ModelContextProtocol.AspNetCore.Authentication;
/// <summary>
/// Represents the authentication events for Model Context Protocol.
/// </summary>
public class McpAuthenticationEvents
{
/// <summary>
/// Gets or sets the function that is invoked when resource metadata is requested.
/// </summary>
/// <remarks>
/// This function is called when a resource metadata request is made to the protected resource metadata endpoint.
/// The implementer should set the <see cref="ResourceMetadataRequestContext.ResourceMetadata"/> property
/// to provide the appropriate metadata for the current request.
/// </remarks>
public Func<ResourceMetadataRequestContext, Task> OnResourceMetadataRequest { get; set; } = context => Task.CompletedTask;
}

View file

@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authentication;
using ModelContextProtocol.AspNetCore.Authentication;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for adding MCP authorization support to ASP.NET Core applications.
/// </summary>
public static class McpAuthenticationExtensions
{
/// <summary>
/// Adds MCP authorization support to the application.
/// </summary>
/// <param name="builder">The authentication builder.</param>
/// <param name="configureOptions">An action to configure MCP authentication options.</param>
/// <returns>The authentication builder for chaining.</returns>
public static AuthenticationBuilder AddMcp(
this AuthenticationBuilder builder,
Action<McpAuthenticationOptions>? configureOptions = null)
{
return AddMcp(
builder,
McpAuthenticationDefaults.AuthenticationScheme,
McpAuthenticationDefaults.DisplayName,
configureOptions);
}
/// <summary>
/// Adds MCP authorization support to the application with a custom scheme name.
/// </summary>
/// <param name="builder">The authentication builder.</param>
/// <param name="authenticationScheme">The authentication scheme name to use.</param>
/// <param name="displayName">The display name for the authentication scheme.</param>
/// <param name="configureOptions">An action to configure MCP authentication options.</param>
/// <returns>The authentication builder for chaining.</returns>
public static AuthenticationBuilder AddMcp(
this AuthenticationBuilder builder,
string authenticationScheme,
string displayName,
Action<McpAuthenticationOptions>? configureOptions = null)
{
return builder.AddScheme<McpAuthenticationOptions, McpAuthenticationHandler>(
authenticationScheme,
displayName,
configureOptions);
}
}

View file

@ -0,0 +1,157 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Authentication;
using System.Text.Encodings.Web;
namespace ModelContextProtocol.AspNetCore.Authentication;
/// <summary>
/// Authentication handler for MCP protocol that adds resource metadata to challenge responses
/// and handles resource metadata endpoint requests.
/// </summary>
public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationOptions>, IAuthenticationRequestHandler
{
/// <summary>
/// Initializes a new instance of the <see cref="McpAuthenticationHandler"/> class.
/// </summary>
public McpAuthenticationHandler(
IOptionsMonitor<McpAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
/// <inheritdoc />
public async Task<bool> HandleRequestAsync()
{
// Check if the request is for the resource metadata endpoint
string requestPath = Request.Path.Value ?? string.Empty;
string expectedMetadataPath = Options.ResourceMetadataUri?.ToString() ?? string.Empty;
if (Options.ResourceMetadataUri != null && !Options.ResourceMetadataUri.IsAbsoluteUri)
{
// For relative URIs, it's just the path component.
expectedMetadataPath = Options.ResourceMetadataUri.OriginalString;
}
// If the path doesn't match, let the request continue through the pipeline
if (!string.Equals(requestPath, expectedMetadataPath, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var cancellationToken = Request.HttpContext.RequestAborted;
await HandleResourceMetadataRequestAsync(cancellationToken);
return true;
}
/// <summary>
/// Gets the base URL from the current request, including scheme, host, and path base.
/// </summary>
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
/// <summary>
/// Gets the absolute URI for the resource metadata endpoint.
/// </summary>
private string GetAbsoluteResourceMetadataUri()
{
var resourceMetadataUri = Options.ResourceMetadataUri;
string currentPath = resourceMetadataUri?.ToString() ?? string.Empty;
if (resourceMetadataUri != null && resourceMetadataUri.IsAbsoluteUri)
{
return currentPath;
}
// For relative URIs, combine with the base URL
string baseUrl = GetBaseUrl();
string relativePath = resourceMetadataUri?.OriginalString.TrimStart('/') ?? string.Empty;
if (!Uri.TryCreate($"{baseUrl.TrimEnd('/')}/{relativePath}", UriKind.Absolute, out var absoluteUri))
{
throw new InvalidOperationException($"Could not create absolute URI for resource metadata. Base URL: {baseUrl}, Relative Path: {relativePath}");
}
return absoluteUri.ToString();
}
/// <summary>
/// Handles the resource metadata request.
/// </summary>
/// <param name="cancellationToken">A token to cancel the operation.</param>
private async Task HandleResourceMetadataRequestAsync(CancellationToken cancellationToken = default)
{
var resourceMetadata = Options.ResourceMetadata;
if (Options.Events.OnResourceMetadataRequest is not null)
{
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
{
ResourceMetadata = CloneResourceMetadata(resourceMetadata),
};
await Options.Events.OnResourceMetadataRequest(context);
}
if (resourceMetadata == null)
{
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata.");
}
await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
}
/// <inheritdoc />
// If no forwarding is configured, this handler doesn't perform authentication
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() => AuthenticateResult.NoResult();
/// <inheritdoc />
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
// Get the absolute URI for the resource metadata
string rawPrmDocumentUri = GetAbsoluteResourceMetadataUri();
properties ??= new AuthenticationProperties();
// Store the resource_metadata in properties in case other handlers need it
properties.Items["resource_metadata"] = rawPrmDocumentUri;
// Add the WWW-Authenticate header with Bearer scheme and resource metadata
string headerValue = $"Bearer realm=\"{Scheme.Name}\", resource_metadata=\"{rawPrmDocumentUri}\"";
Response.Headers.Append("WWW-Authenticate", headerValue);
return base.HandleChallengeAsync(properties);
}
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata)
{
if (resourceMetadata is null)
{
return null;
}
return new ProtectedResourceMetadata
{
Resource = resourceMetadata.Resource,
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
ScopesSupported = [.. resourceMetadata.ScopesSupported],
JwksUri = resourceMetadata.JwksUri,
ResourceSigningAlgValuesSupported = resourceMetadata.ResourceSigningAlgValuesSupported is not null ? [.. resourceMetadata.ResourceSigningAlgValuesSupported] : null,
ResourceName = resourceMetadata.ResourceName,
ResourceDocumentation = resourceMetadata.ResourceDocumentation,
ResourcePolicyUri = resourceMetadata.ResourcePolicyUri,
ResourceTosUri = resourceMetadata.ResourceTosUri,
TlsClientCertificateBoundAccessTokens = resourceMetadata.TlsClientCertificateBoundAccessTokens,
AuthorizationDetailsTypesSupported = resourceMetadata.AuthorizationDetailsTypesSupported is not null ? [.. resourceMetadata.AuthorizationDetailsTypesSupported] : null,
DpopSigningAlgValuesSupported = resourceMetadata.DpopSigningAlgValuesSupported is not null ? [.. resourceMetadata.DpopSigningAlgValuesSupported] : null,
DpopBoundAccessTokensRequired = resourceMetadata.DpopBoundAccessTokensRequired
};
}
}

View file

@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Authentication;
using ModelContextProtocol.Authentication;
namespace ModelContextProtocol.AspNetCore.Authentication;
/// <summary>
/// Options for the MCP authentication handler.
/// </summary>
public class McpAuthenticationOptions : AuthenticationSchemeOptions
{
private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative);
/// <summary>
/// Initializes a new instance of the <see cref="McpAuthenticationOptions"/> class.
/// </summary>
public McpAuthenticationOptions()
{
// "Bearer" is JwtBearerDefaults.AuthenticationScheme, but we don't have a reference to the JwtBearer package here.
ForwardAuthenticate = "Bearer";
ResourceMetadataUri = DefaultResourceMetadataUri;
Events = new McpAuthenticationEvents();
}
/// <summary>
/// Gets or sets the events used to handle authentication events.
/// </summary>
public new McpAuthenticationEvents Events
{
get { return (McpAuthenticationEvents)base.Events!; }
set { base.Events = value; }
}
/// <summary>
/// The URI to the resource metadata document.
/// </summary>
/// <remarks>
/// This URI will be included in the WWW-Authenticate header when a 401 response is returned.
/// </remarks>
public Uri ResourceMetadataUri { get; set; }
/// <summary>
/// Gets or sets the protected resource metadata.
/// </summary>
/// <remarks>
/// This contains the OAuth metadata for the protected resource, including authorization servers,
/// supported scopes, and other information needed for clients to authenticate.
/// </remarks>
public ProtectedResourceMetadata? ResourceMetadata { get; set; }
}

View file

@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using ModelContextProtocol.Authentication;
namespace ModelContextProtocol.AspNetCore.Authentication;
/// <summary>
/// Context for resource metadata request events.
/// </summary>
public class ResourceMetadataRequestContext : HandleRequestContext<McpAuthenticationOptions>
{
/// <summary>
/// Initializes a new instance of the <see cref="ResourceMetadataRequestContext"/> class.
/// </summary>
/// <param name="context">The HTTP context.</param>
/// <param name="scheme">The authentication scheme.</param>
/// <param name="options">The authentication options.</param>
public ResourceMetadataRequestContext(
HttpContext context,
AuthenticationScheme scheme,
McpAuthenticationOptions options)
: base(context, scheme, options)
{
}
/// <summary>
/// Gets or sets the protected resource metadata for the current request.
/// </summary>
public ProtectedResourceMetadata? ResourceMetadata { get; set; }
}

View file

@ -0,0 +1,118 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Net.Http.Headers;
namespace ModelContextProtocol.Authentication;
/// <summary>
/// A delegating handler that adds authentication tokens to requests and handles 401 responses.
/// </summary>
internal sealed class AuthenticatingMcpHttpClient(HttpClient httpClient, ClientOAuthProvider credentialProvider) : McpHttpClient(httpClient)
{
// Select first supported scheme as the default
private string _currentScheme = credentialProvider.SupportedSchemes.FirstOrDefault() ??
throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(credentialProvider));
/// <summary>
/// Sends an HTTP request with authentication handling.
/// </summary>
internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
{
if (request.Headers.Authorization == null)
{
await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken).ConfigureAwait(false);
}
var response = await base.SendAsync(request, message, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
return await HandleUnauthorizedResponseAsync(request, message, response, cancellationToken).ConfigureAwait(false);
}
return response;
}
/// <summary>
/// Handles a 401 Unauthorized response by attempting to authenticate and retry the request.
/// </summary>
private async Task<HttpResponseMessage> HandleUnauthorizedResponseAsync(
HttpRequestMessage originalRequest,
JsonRpcMessage? originalJsonRpcMessage,
HttpResponseMessage response,
CancellationToken cancellationToken)
{
// Gather the schemes the server wants us to use from WWW-Authenticate headers
var serverSchemes = ExtractServerSupportedSchemes(response);
if (!serverSchemes.Contains(_currentScheme))
{
// Find the first server scheme that's in our supported set
var bestSchemeMatch = serverSchemes.Intersect(credentialProvider.SupportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault();
if (bestSchemeMatch is not null)
{
_currentScheme = bestSchemeMatch;
}
else if (serverSchemes.Count > 0)
{
// If no match was found, either throw an exception or use default
throw new McpException(
$"The server does not support any of the provided authentication schemes." +
$"Server supports: [{string.Join(", ", serverSchemes)}], " +
$"Provider supports: [{string.Join(", ", credentialProvider.SupportedSchemes)}].");
}
}
// Try to handle the 401 response with the selected scheme
await credentialProvider.HandleUnauthorizedResponseAsync(_currentScheme, response, cancellationToken).ConfigureAwait(false);
using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri);
// Copy headers except Authorization which we'll set separately
foreach (var header in originalRequest.Headers)
{
if (!header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
retryRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
await AddAuthorizationHeaderAsync(retryRequest, _currentScheme, cancellationToken).ConfigureAwait(false);
return await base.SendAsync(retryRequest, originalJsonRpcMessage, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Extracts the authentication schemes that the server supports from the WWW-Authenticate headers.
/// </summary>
private static HashSet<string> ExtractServerSupportedSchemes(HttpResponseMessage response)
{
var serverSchemes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var header in response.Headers.WwwAuthenticate)
{
serverSchemes.Add(header.Scheme);
}
return serverSchemes;
}
/// <summary>
/// Adds an authorization header to the request.
/// </summary>
private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, string scheme, CancellationToken cancellationToken)
{
if (request.RequestUri is null)
{
return;
}
var token = await credentialProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(token))
{
return;
}
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
}
}

View file

@ -0,0 +1,28 @@

namespace ModelContextProtocol.Authentication;
/// <summary>
/// Represents a method that handles the OAuth authorization URL and returns the authorization code.
/// </summary>
/// <param name="authorizationUri">The authorization URL that the user needs to visit.</param>
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the authorization code if successful, or null if the operation failed or was cancelled.</returns>
/// <remarks>
/// <para>
/// This delegate provides SDK consumers with full control over how the OAuth authorization flow is handled.
/// Implementers can choose to:
/// </para>
/// <list type="bullet">
/// <item><description>Start a local HTTP server and open a browser (default behavior)</description></item>
/// <item><description>Display the authorization URL to the user for manual handling</description></item>
/// <item><description>Integrate with a custom UI or authentication flow</description></item>
/// <item><description>Use a different redirect mechanism altogether</description></item>
/// </list>
/// <para>
/// The implementation should handle user interaction to visit the authorization URL and extract
/// the authorization code from the callback. The authorization code is typically provided as
/// a query parameter in the redirect URI callback.
/// </para>
/// </remarks>
public delegate Task<string?> AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken);

View file

@ -0,0 +1,69 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Authentication;
/// <summary>
/// Represents the metadata about an OAuth authorization server.
/// </summary>
internal sealed class AuthorizationServerMetadata
{
/// <summary>
/// The authorization endpoint URI.
/// </summary>
[JsonPropertyName("authorization_endpoint")]
public Uri AuthorizationEndpoint { get; set; } = null!;
/// <summary>
/// The token endpoint URI.
/// </summary>
[JsonPropertyName("token_endpoint")]
public Uri TokenEndpoint { get; set; } = null!;
/// <summary>
/// The registration endpoint URI.
/// </summary>
[JsonPropertyName("registration_endpoint")]
public Uri? RegistrationEndpoint { get; set; }
/// <summary>
/// The revocation endpoint URI.
/// </summary>
[JsonPropertyName("revocation_endpoint")]
public Uri? RevocationEndpoint { get; set; }
/// <summary>
/// The response types supported by the authorization server.
/// </summary>
[JsonPropertyName("response_types_supported")]
public List<string>? ResponseTypesSupported { get; set; }
/// <summary>
/// The grant types supported by the authorization server.
/// </summary>
[JsonPropertyName("grant_types_supported")]
public List<string>? GrantTypesSupported { get; set; }
/// <summary>
/// The token endpoint authentication methods supported by the authorization server.
/// </summary>
[JsonPropertyName("token_endpoint_auth_methods_supported")]
public List<string>? TokenEndpointAuthMethodsSupported { get; set; }
/// <summary>
/// The code challenge methods supported by the authorization server.
/// </summary>
[JsonPropertyName("code_challenge_methods_supported")]
public List<string>? CodeChallengeMethodsSupported { get; set; }
/// <summary>
/// The issuer URI of the authorization server.
/// </summary>
[JsonPropertyName("issuer")]
public Uri? Issuer { get; set; }
/// <summary>
/// The scopes supported by the authorization server.
/// </summary>
[JsonPropertyName("scopes_supported")]
public List<string>? ScopesSupported { get; set; }
}

Some files were not shown because too many files have changed in this diff Show more