@@ -1297,4 +1297,111 @@ await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
12971297 Assert . False ( query . ContainsKey ( "resource" ) , "The 'resource' query parameter should not be present when IncludeResourceIndicator is false." ) ;
12981298 Assert . True ( query . ContainsKey ( "scope" ) , "The 'scope' query parameter should still be present." ) ;
12991299 }
1300+
1301+ [ Fact ]
1302+ public async Task CanAuthenticate_WithoutResourceIndicator_EndToEnd ( )
1303+ {
1304+ // Simulate an Entra ID-like server that rejects the 'resource' parameter.
1305+ TestOAuthServer . ExpectResource = false ;
1306+
1307+ // Without resource indicator the token audience falls back to the client ID,
1308+ // matching real Entra ID behavior. Configure the server to accept it.
1309+ Builder . Services . Configure < JwtBearerOptions > ( JwtBearerDefaults . AuthenticationScheme , options =>
1310+ {
1311+ options . TokenValidationParameters . ValidAudiences = [ McpServerUrl , "demo-client" ] ;
1312+ } ) ;
1313+
1314+ await using var app = await StartMcpServerAsync ( ) ;
1315+
1316+ await using var transport = new HttpClientTransport ( new ( )
1317+ {
1318+ Endpoint = new ( McpServerUrl ) ,
1319+ OAuth = new ( )
1320+ {
1321+ ClientId = "demo-client" ,
1322+ ClientSecret = "demo-secret" ,
1323+ RedirectUri = new Uri ( "http://localhost:1179/callback" ) ,
1324+ IncludeResourceIndicator = false ,
1325+ AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync ,
1326+ } ,
1327+ } , HttpClient , LoggerFactory ) ;
1328+
1329+ // This would fail with "invalid_target" if the resource parameter leaked through
1330+ // in either the authorization, token exchange, or silent refresh paths.
1331+ await using var client = await McpClient . CreateAsync (
1332+ transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ;
1333+ }
1334+
1335+ [ Fact ]
1336+ public async Task CanAuthenticate_WithoutResourceIndicator_TokenRefresh ( )
1337+ {
1338+ // Simulate an Entra ID-like server that rejects the 'resource' parameter.
1339+ TestOAuthServer . ExpectResource = false ;
1340+
1341+ var hasForcedRefresh = false ;
1342+
1343+ Builder . Services . AddMcpServer ( options =>
1344+ {
1345+ options . ToolCollection = new ( ) ;
1346+ } ) ;
1347+
1348+ // Without resource indicator the token audience falls back to the client ID.
1349+ Builder . Services . Configure < JwtBearerOptions > ( JwtBearerDefaults . AuthenticationScheme , options =>
1350+ {
1351+ options . TokenValidationParameters . ValidAudiences = [ McpServerUrl , "demo-client" ] ;
1352+ } ) ;
1353+
1354+ await using var app = await StartMcpServerAsync ( configureMiddleware : app =>
1355+ {
1356+ app . Use ( async ( context , next ) =>
1357+ {
1358+ if ( context . Request . Method == HttpMethods . Post && context . Request . Path == "/" && ! hasForcedRefresh )
1359+ {
1360+ context . Request . EnableBuffering ( ) ;
1361+
1362+ var message = await JsonSerializer . DeserializeAsync (
1363+ context . Request . Body ,
1364+ McpJsonUtilities . DefaultOptions . GetTypeInfo ( typeof ( JsonRpcMessage ) ) ,
1365+ context . RequestAborted ) as JsonRpcMessage ;
1366+
1367+ context . Request . Body . Position = 0 ;
1368+
1369+ if ( message is JsonRpcRequest request && request . Method == "tools/list" )
1370+ {
1371+ hasForcedRefresh = true ;
1372+
1373+ // Return 401 to force token refresh
1374+ await context . ChallengeAsync ( JwtBearerDefaults . AuthenticationScheme ) ;
1375+ await context . Response . StartAsync ( context . RequestAborted ) ;
1376+ await context . Response . Body . FlushAsync ( context . RequestAborted ) ;
1377+ return ;
1378+ }
1379+ }
1380+
1381+ await next ( context ) ;
1382+ } ) ;
1383+ } ) ;
1384+
1385+ await using var transport = new HttpClientTransport ( new ( )
1386+ {
1387+ Endpoint = new ( McpServerUrl ) ,
1388+ OAuth = new ( )
1389+ {
1390+ ClientId = "demo-client" ,
1391+ ClientSecret = "demo-secret" ,
1392+ RedirectUri = new Uri ( "http://localhost:1179/callback" ) ,
1393+ IncludeResourceIndicator = false ,
1394+ AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync ,
1395+ } ,
1396+ } , HttpClient , LoggerFactory ) ;
1397+
1398+ await using var client = await McpClient . CreateAsync (
1399+ transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ;
1400+
1401+ // This triggers the 401 → token refresh path. If the resource parameter
1402+ // leaks into the refresh request, the mock Entra ID server returns invalid_target.
1403+ await client . ListToolsAsync ( cancellationToken : TestContext . Current . CancellationToken ) ;
1404+
1405+ Assert . True ( TestOAuthServer . HasRefreshedToken , "Token refresh should have occurred." ) ;
1406+ }
13001407}
0 commit comments