@@ -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