Skip to content

Commit 984aa9a

Browse files
authored
fix: Prevent crash when Options.ResourceMetadata is null but handled by event (#603)
1 parent aa75f6f commit 984aa9a

File tree

2 files changed

+316
-6
lines changed

2 files changed

+316
-6
lines changed

src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ public async Task<bool> HandleRequestAsync()
4343
return false;
4444
}
4545

46-
var cancellationToken = Request.HttpContext.RequestAborted;
47-
await HandleResourceMetadataRequestAsync(cancellationToken);
46+
await HandleResourceMetadataRequestAsync();
4847
return true;
4948
}
5049

@@ -82,8 +81,7 @@ private string GetAbsoluteResourceMetadataUri()
8281
/// <summary>
8382
/// Handles the resource metadata request.
8483
/// </summary>
85-
/// <param name="cancellationToken">A token to cancel the operation.</param>
86-
private async Task HandleResourceMetadataRequestAsync(CancellationToken cancellationToken = default)
84+
private async Task HandleResourceMetadataRequestAsync()
8785
{
8886
var resourceMetadata = Options.ResourceMetadata;
8987

@@ -95,12 +93,14 @@ private async Task HandleResourceMetadataRequestAsync(CancellationToken cancella
9593
};
9694

9795
await Options.Events.OnResourceMetadataRequest(context);
96+
resourceMetadata = context.ResourceMetadata;
9897
}
9998

100-
10199
if (resourceMetadata == null)
102100
{
103-
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata.");
101+
throw new InvalidOperationException(
102+
"ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest."
103+
);
104104
}
105105

106106
await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using System.Text.Json;
4+
using Microsoft.AspNetCore.Authentication.JwtBearer;
5+
using Microsoft.AspNetCore.Builder;
6+
using Microsoft.AspNetCore.WebUtilities;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.IdentityModel.Tokens;
9+
using ModelContextProtocol.AspNetCore.Authentication;
10+
using ModelContextProtocol.AspNetCore.Tests.Utils;
11+
using ModelContextProtocol.Authentication;
12+
using ModelContextProtocol.Client;
13+
14+
namespace ModelContextProtocol.AspNetCore.Tests;
15+
16+
/// <summary>
17+
/// Tests for MCP authentication when resource metadata is provided via events rather than static configuration.
18+
/// </summary>
19+
public class AuthEventTests : KestrelInMemoryTest, IAsyncDisposable
20+
{
21+
private const string McpServerUrl = "http://localhost:5000";
22+
private const string OAuthServerUrl = "https://localhost:7029";
23+
24+
private readonly CancellationTokenSource _testCts = new();
25+
private readonly TestOAuthServer.Program _testOAuthServer;
26+
private readonly Task _testOAuthRunTask;
27+
28+
public AuthEventTests(ITestOutputHelper outputHelper)
29+
: base(outputHelper)
30+
{
31+
// Let the HandleAuthorizationUrlAsync take a look at the Location header
32+
SocketsHttpHandler.AllowAutoRedirect = false;
33+
// The dev cert may not be installed on the CI, but AddJwtBearer requires an HTTPS backchannel by default.
34+
// The easiest workaround is to disable cert validation for testing purposes.
35+
SocketsHttpHandler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
36+
37+
_testOAuthServer = new TestOAuthServer.Program(
38+
XunitLoggerProvider,
39+
KestrelInMemoryTransport
40+
);
41+
_testOAuthRunTask = _testOAuthServer.RunServerAsync(cancellationToken: _testCts.Token);
42+
43+
Builder
44+
.Services.AddAuthentication(options =>
45+
{
46+
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
47+
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
48+
})
49+
.AddJwtBearer(options =>
50+
{
51+
options.Backchannel = HttpClient;
52+
options.Authority = OAuthServerUrl;
53+
options.TokenValidationParameters = new TokenValidationParameters
54+
{
55+
ValidateIssuer = true,
56+
ValidateAudience = true,
57+
ValidateLifetime = true,
58+
ValidateIssuerSigningKey = true,
59+
ValidAudience = McpServerUrl,
60+
ValidIssuer = OAuthServerUrl,
61+
NameClaimType = "name",
62+
RoleClaimType = "roles",
63+
};
64+
})
65+
.AddMcp(options =>
66+
{
67+
// Note: ResourceMetadata is NOT set here - it will be provided via events
68+
options.Events.OnResourceMetadataRequest = async context =>
69+
{
70+
// Dynamically provide the resource metadata
71+
context.ResourceMetadata = new ProtectedResourceMetadata
72+
{
73+
Resource = new Uri(McpServerUrl),
74+
AuthorizationServers = { new Uri(OAuthServerUrl) },
75+
ScopesSupported = ["mcp:tools"],
76+
};
77+
await Task.CompletedTask;
78+
};
79+
});
80+
81+
Builder.Services.AddAuthorization();
82+
}
83+
84+
public async ValueTask DisposeAsync()
85+
{
86+
_testCts.Cancel();
87+
try
88+
{
89+
await _testOAuthRunTask;
90+
}
91+
catch (OperationCanceledException) { }
92+
finally
93+
{
94+
_testCts.Dispose();
95+
}
96+
}
97+
98+
[Fact]
99+
public async Task CanAuthenticate_WithResourceMetadataFromEvent()
100+
{
101+
Builder.Services.AddMcpServer().WithHttpTransport();
102+
103+
await using var app = Builder.Build();
104+
105+
app.MapMcp().RequireAuthorization();
106+
107+
await app.StartAsync(TestContext.Current.CancellationToken);
108+
109+
await using var transport = new SseClientTransport(
110+
new()
111+
{
112+
Endpoint = new(McpServerUrl),
113+
OAuth = new()
114+
{
115+
ClientId = "demo-client",
116+
ClientSecret = "demo-secret",
117+
RedirectUri = new Uri("http://localhost:1179/callback"),
118+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
119+
},
120+
},
121+
HttpClient,
122+
LoggerFactory
123+
);
124+
125+
await using var client = await McpClientFactory.CreateAsync(
126+
transport,
127+
loggerFactory: LoggerFactory,
128+
cancellationToken: TestContext.Current.CancellationToken
129+
);
130+
}
131+
132+
[Fact]
133+
public async Task CanAuthenticate_WithDynamicClientRegistration_FromEvent()
134+
{
135+
Builder.Services.AddMcpServer().WithHttpTransport();
136+
137+
await using var app = Builder.Build();
138+
139+
app.MapMcp().RequireAuthorization();
140+
141+
await app.StartAsync(TestContext.Current.CancellationToken);
142+
143+
await using var transport = new SseClientTransport(
144+
new()
145+
{
146+
Endpoint = new(McpServerUrl),
147+
OAuth = new ClientOAuthOptions()
148+
{
149+
RedirectUri = new Uri("http://localhost:1179/callback"),
150+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
151+
ClientName = "Test MCP Client",
152+
ClientUri = new Uri("https://example.com"),
153+
Scopes = ["mcp:tools"],
154+
},
155+
},
156+
HttpClient,
157+
LoggerFactory
158+
);
159+
160+
await using var client = await McpClientFactory.CreateAsync(
161+
transport,
162+
loggerFactory: LoggerFactory,
163+
cancellationToken: TestContext.Current.CancellationToken
164+
);
165+
}
166+
167+
[Fact]
168+
public async Task ResourceMetadataEndpoint_ReturnsCorrectMetadata_FromEvent()
169+
{
170+
Builder.Services.AddMcpServer().WithHttpTransport();
171+
172+
await using var app = Builder.Build();
173+
174+
app.MapMcp().RequireAuthorization();
175+
176+
await app.StartAsync(TestContext.Current.CancellationToken);
177+
178+
// Make a direct request to the resource metadata endpoint
179+
using var response = await HttpClient.GetAsync(
180+
"/.well-known/oauth-protected-resource",
181+
TestContext.Current.CancellationToken
182+
);
183+
184+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
185+
186+
var metadata = await response.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
187+
McpJsonUtilities.DefaultOptions,
188+
TestContext.Current.CancellationToken
189+
);
190+
191+
Assert.NotNull(metadata);
192+
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
193+
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
194+
Assert.Contains("mcp:tools", metadata.ScopesSupported);
195+
}
196+
197+
[Fact]
198+
public async Task ResourceMetadataEndpoint_CanModifyExistingMetadata_InEvent()
199+
{
200+
Builder.Services.AddMcpServer().WithHttpTransport();
201+
202+
// Override the configuration to test modification of existing metadata
203+
Builder.Services.Configure<McpAuthenticationOptions>(
204+
McpAuthenticationDefaults.AuthenticationScheme,
205+
options =>
206+
{
207+
// Set initial metadata
208+
options.ResourceMetadata = new ProtectedResourceMetadata
209+
{
210+
Resource = new Uri(McpServerUrl),
211+
AuthorizationServers = { new Uri(OAuthServerUrl) },
212+
ScopesSupported = ["mcp:basic"],
213+
};
214+
215+
// Override the event to modify the metadata
216+
options.Events.OnResourceMetadataRequest = async context =>
217+
{
218+
// Start with the existing metadata and modify it
219+
if (context.ResourceMetadata != null)
220+
{
221+
context.ResourceMetadata.ScopesSupported.Add("mcp:tools");
222+
context.ResourceMetadata.ResourceName = "Dynamic Test Resource";
223+
}
224+
await Task.CompletedTask;
225+
};
226+
}
227+
);
228+
229+
await using var app = Builder.Build();
230+
231+
app.MapMcp().RequireAuthorization();
232+
233+
await app.StartAsync(TestContext.Current.CancellationToken);
234+
235+
// Make a direct request to the resource metadata endpoint
236+
using var response = await HttpClient.GetAsync(
237+
"/.well-known/oauth-protected-resource",
238+
TestContext.Current.CancellationToken
239+
);
240+
241+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
242+
243+
var metadata = await response.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
244+
McpJsonUtilities.DefaultOptions,
245+
TestContext.Current.CancellationToken
246+
);
247+
248+
Assert.NotNull(metadata);
249+
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
250+
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
251+
Assert.Contains("mcp:basic", metadata.ScopesSupported);
252+
Assert.Contains("mcp:tools", metadata.ScopesSupported);
253+
Assert.Equal("Dynamic Test Resource", metadata.ResourceName);
254+
}
255+
256+
[Fact]
257+
public async Task ResourceMetadataEndpoint_ThrowsException_WhenNoMetadataProvided()
258+
{
259+
Builder.Services.AddMcpServer().WithHttpTransport();
260+
261+
// Override the configuration to test the error case where no metadata is provided
262+
Builder.Services.Configure<McpAuthenticationOptions>(
263+
McpAuthenticationDefaults.AuthenticationScheme,
264+
options =>
265+
{
266+
// Don't set ResourceMetadata and provide an event that doesn't set it either
267+
options.ResourceMetadata = null;
268+
options.Events.OnResourceMetadataRequest = async context =>
269+
{
270+
// Intentionally don't set context.ResourceMetadata to test error handling
271+
await Task.CompletedTask;
272+
};
273+
}
274+
);
275+
276+
await using var app = Builder.Build();
277+
278+
app.MapMcp().RequireAuthorization();
279+
280+
await app.StartAsync(TestContext.Current.CancellationToken);
281+
282+
// Make a direct request to the resource metadata endpoint - this should fail
283+
using var response = await HttpClient.GetAsync(
284+
"/.well-known/oauth-protected-resource",
285+
TestContext.Current.CancellationToken
286+
);
287+
288+
// The request should fail with an internal server error due to the InvalidOperationException
289+
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
290+
}
291+
292+
private async Task<string?> HandleAuthorizationUrlAsync(
293+
Uri authorizationUri,
294+
Uri redirectUri,
295+
CancellationToken cancellationToken
296+
)
297+
{
298+
using var redirectResponse = await HttpClient.GetAsync(authorizationUri, cancellationToken);
299+
Assert.Equal(HttpStatusCode.Redirect, redirectResponse.StatusCode);
300+
var location = redirectResponse.Headers.Location;
301+
302+
if (location is not null && !string.IsNullOrEmpty(location.Query))
303+
{
304+
var queryParams = QueryHelpers.ParseQuery(location.Query);
305+
return queryParams["code"];
306+
}
307+
308+
return null;
309+
}
310+
}

0 commit comments

Comments
 (0)