Skip to content

Commit 1590b1f

Browse files
jeffhandleyCopilot
andcommitted
Add unit tests for 2025-03-26 OAuth backward compatibility
Add two tests to AuthTests.cs covering legacy server scenarios: - CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetadata: Server lacks RFC 9728 PRM but serves auth server metadata at well-known URLs on the MCP server origin. - CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback: Server lacks both PRM and auth server metadata, forcing fallback to default /authorize, /token, /register endpoint paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d6fcb49 commit 1590b1f

1 file changed

Lines changed: 175 additions & 0 deletions

File tree

  • tests/ModelContextProtocol.AspNetCore.Tests/OAuth

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,4 +1044,179 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash()
10441044
await using var client = await McpClient.CreateAsync(
10451045
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
10461046
}
1047+
1048+
[Fact]
1049+
public async Task CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetadata()
1050+
{
1051+
// 2025-03-26 backcompat: server does NOT serve PRM, but DOES serve auth server metadata.
1052+
// The client should fall back to using the MCP server's origin as the auth server
1053+
// and discover auth metadata from well-known URLs on that origin.
1054+
TestOAuthServer.RequireResource = false;
1055+
1056+
// Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata.
1057+
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
1058+
1059+
// Legacy servers don't use resource-based audiences in tokens (no resource parameter is sent).
1060+
Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
1061+
{
1062+
options.TokenValidationParameters.ValidateAudience = false;
1063+
});
1064+
1065+
await using var app = Builder.Build();
1066+
1067+
app.Use(async (context, next) =>
1068+
{
1069+
// Return 404 for PRM to simulate a legacy server that doesn't support RFC 9728.
1070+
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource"))
1071+
{
1072+
context.Response.StatusCode = StatusCodes.Status404NotFound;
1073+
return;
1074+
}
1075+
1076+
// Serve auth server metadata pointing to the real OAuth server endpoints.
1077+
// In a real 2025-03-26 deployment, the MCP server itself would be the auth server.
1078+
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") ||
1079+
context.Request.Path.StartsWithSegments("/.well-known/openid-configuration"))
1080+
{
1081+
context.Response.ContentType = "application/json";
1082+
await context.Response.WriteAsync($$"""
1083+
{
1084+
"issuer": "{{OAuthServerUrl}}",
1085+
"authorization_endpoint": "{{OAuthServerUrl}}/authorize",
1086+
"token_endpoint": "{{OAuthServerUrl}}/token",
1087+
"registration_endpoint": "{{OAuthServerUrl}}/register",
1088+
"response_types_supported": ["code"],
1089+
"grant_types_supported": ["authorization_code", "refresh_token"],
1090+
"token_endpoint_auth_methods_supported": ["client_secret_post"],
1091+
"code_challenge_methods_supported": ["S256"]
1092+
}
1093+
""");
1094+
return;
1095+
}
1096+
1097+
await next();
1098+
});
1099+
1100+
app.UseAuthentication();
1101+
app.UseAuthorization();
1102+
app.MapMcp().RequireAuthorization();
1103+
await app.StartAsync(TestContext.Current.CancellationToken);
1104+
1105+
await using var transport = new HttpClientTransport(new()
1106+
{
1107+
Endpoint = new(McpServerUrl),
1108+
OAuth = new()
1109+
{
1110+
ClientId = "demo-client",
1111+
ClientSecret = "demo-secret",
1112+
RedirectUri = new Uri("http://localhost:1179/callback"),
1113+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
1114+
},
1115+
}, HttpClient, LoggerFactory);
1116+
1117+
await using var client = await McpClient.CreateAsync(
1118+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1119+
}
1120+
1121+
[Fact]
1122+
public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback()
1123+
{
1124+
// 2025-03-26 backcompat: server does NOT serve PRM AND does NOT serve auth server metadata.
1125+
// The client should fall back to default endpoint paths (/authorize, /token, /register)
1126+
// on the MCP server's origin.
1127+
TestOAuthServer.RequireResource = false;
1128+
1129+
// Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata.
1130+
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
1131+
1132+
// Legacy servers don't use resource-based audiences in tokens (no resource parameter is sent).
1133+
Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
1134+
{
1135+
options.TokenValidationParameters.ValidateAudience = false;
1136+
});
1137+
1138+
await using var app = Builder.Build();
1139+
1140+
// Capture HttpClient for use in the proxy middleware.
1141+
var httpClient = HttpClient;
1142+
1143+
app.Use(async (context, next) =>
1144+
{
1145+
// Return 404 for PRM to simulate a legacy server that doesn't support RFC 9728.
1146+
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource"))
1147+
{
1148+
context.Response.StatusCode = StatusCodes.Status404NotFound;
1149+
return;
1150+
}
1151+
1152+
// Return 404 for auth server metadata to force fallback to default endpoints.
1153+
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") ||
1154+
context.Request.Path.StartsWithSegments("/.well-known/openid-configuration"))
1155+
{
1156+
context.Response.StatusCode = StatusCodes.Status404NotFound;
1157+
return;
1158+
}
1159+
1160+
// Proxy default OAuth endpoints to the real OAuth server.
1161+
// In a real 2025-03-26 deployment, the MCP server itself would host these endpoints.
1162+
var path = context.Request.Path.Value;
1163+
if (path is "/authorize" or "/token" or "/register")
1164+
{
1165+
var targetUrl = $"{OAuthServerUrl}{path}{context.Request.QueryString}";
1166+
using var proxyRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUrl);
1167+
1168+
if (context.Request.ContentLength > 0 || context.Request.ContentType is not null)
1169+
{
1170+
proxyRequest.Content = new StreamContent(context.Request.Body);
1171+
if (context.Request.ContentType is not null)
1172+
{
1173+
proxyRequest.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType);
1174+
}
1175+
}
1176+
1177+
if (context.Request.Headers.Authorization.Count > 0)
1178+
{
1179+
proxyRequest.Headers.TryAddWithoutValidation("Authorization", context.Request.Headers.Authorization.ToString());
1180+
}
1181+
1182+
using var response = await httpClient.SendAsync(proxyRequest);
1183+
context.Response.StatusCode = (int)response.StatusCode;
1184+
1185+
if (response.Headers.Location is not null)
1186+
{
1187+
context.Response.Headers.Location = response.Headers.Location.ToString();
1188+
}
1189+
1190+
if (response.Content.Headers.ContentType is not null)
1191+
{
1192+
context.Response.ContentType = response.Content.Headers.ContentType.ToString();
1193+
}
1194+
1195+
await response.Content.CopyToAsync(context.Response.Body);
1196+
return;
1197+
}
1198+
1199+
await next();
1200+
});
1201+
1202+
app.UseAuthentication();
1203+
app.UseAuthorization();
1204+
app.MapMcp().RequireAuthorization();
1205+
await app.StartAsync(TestContext.Current.CancellationToken);
1206+
1207+
await using var transport = new HttpClientTransport(new()
1208+
{
1209+
Endpoint = new(McpServerUrl),
1210+
OAuth = new()
1211+
{
1212+
ClientId = "demo-client",
1213+
ClientSecret = "demo-secret",
1214+
RedirectUri = new Uri("http://localhost:1179/callback"),
1215+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
1216+
},
1217+
}, HttpClient, LoggerFactory);
1218+
1219+
await using var client = await McpClient.CreateAsync(
1220+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1221+
}
10471222
}

0 commit comments

Comments
 (0)