@@ -768,7 +768,7 @@ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParent
768768 //
769769 // https://datatracker.ietf.org/doc/html/rfc9728/#section-3.3
770770 //
771- // CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates we won't fall back to root in this case.
771+ // CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates that a root-level resource is accepted in this case.
772772 // CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fall back to root when resource_metadata is missing.
773773 Builder . Services . Configure < AuthenticationOptions > ( options => options . DefaultChallengeScheme = JwtBearerDefaults . AuthenticationScheme ) ;
774774 Builder . Services . Configure < McpAuthenticationOptions > ( McpAuthenticationDefaults . AuthenticationScheme , options =>
@@ -807,8 +807,14 @@ await McpClient.CreateAsync(
807807 Assert . Contains ( "does not match" , ex . Message ) ;
808808 }
809809
810+ /// <summary>
811+ /// Verifies that OAuth authentication succeeds when the protected resource metadata URI
812+ /// matches the root server URL, even when the actual MCP endpoint is at a subpath.
813+ /// This tests the flexible URI matching behavior where the resource URI can be less specific
814+ /// than the actual endpoint being accessed.
815+ /// </summary>
810816 [ Fact ]
811- public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath ( )
817+ public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath ( )
812818 {
813819 const string requestedResourcePath = "/mcp/tools" ;
814820
@@ -839,12 +845,99 @@ public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPa
839845 } ,
840846 } , HttpClient , LoggerFactory ) ;
841847
842- var ex = await Assert . ThrowsAsync < McpException > ( async ( ) =>
848+ await using var client = await McpClient . CreateAsync (
849+ transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ;
850+ }
851+
852+ /// <summary>
853+ /// Verifies that OAuth authentication fails when the protected resource metadata URI
854+ /// does not match the requested MCP server endpoint. This ensures that clients cannot
855+ /// use OAuth tokens intended for one server to access a different server.
856+ /// </summary>
857+ [ Fact ]
858+ public async Task CannotAuthenticate_WhenResourceMetadataUriDoesNotMatch ( )
859+ {
860+ const string requestedResourcePath = "/mcp/tools" ;
861+ const string differentResourceUri = "http://different-server.example.com" ;
862+
863+ Builder . Services . Configure < McpAuthenticationOptions > ( McpAuthenticationDefaults . AuthenticationScheme , options =>
843864 {
844- await McpClient . CreateAsync (
845- transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ;
865+ options . ResourceMetadata = new ProtectedResourceMetadata
866+ {
867+ Resource = differentResourceUri ,
868+ AuthorizationServers = { OAuthServerUrl } ,
869+ } ;
846870 } ) ;
847871
872+ await using var app = Builder . Build ( ) ;
873+
874+ app . MapMcp ( requestedResourcePath ) . RequireAuthorization ( ) ;
875+
876+ await app . StartAsync ( TestContext . Current . CancellationToken ) ;
877+
878+ await using var transport = new HttpClientTransport ( new ( )
879+ {
880+ Endpoint = new Uri ( $ "{ McpServerUrl } { requestedResourcePath } ") ,
881+ OAuth = new ( )
882+ {
883+ ClientId = "demo-client" ,
884+ ClientSecret = "demo-secret" ,
885+ RedirectUri = new Uri ( "http://localhost:1179/callback" ) ,
886+ AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync ,
887+ } ,
888+ } , HttpClient , LoggerFactory ) ;
889+
890+ // This should fail because the resource URI doesn't match
891+ var ex = await Assert . ThrowsAsync < McpException > ( ( ) => McpClient . CreateAsync (
892+ transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ) ;
893+
894+ Assert . Contains ( "does not match" , ex . Message ) ;
895+ }
896+
897+ /// <summary>
898+ /// Verifies that OAuth authentication fails when the protected resource metadata URI is an
899+ /// unrelated path on the same host as the requested endpoint (e.g. resource=.../service-a vs
900+ /// endpoint .../service-b). This ensures the authority-level fallback only accepts an exact match
901+ /// or an authority-only resource, and not arbitrary sibling paths on the same host.
902+ /// </summary>
903+ [ Fact ]
904+ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsDifferentPathOnSameAuthority ( )
905+ {
906+ const string requestedResourcePath = "/service-b" ;
907+ const string differentResourcePath = "/service-a" ;
908+
909+ Builder . Services . Configure < McpAuthenticationOptions > ( McpAuthenticationDefaults . AuthenticationScheme , options =>
910+ {
911+ options . ResourceMetadata = new ProtectedResourceMetadata
912+ {
913+ Resource = $ "{ McpServerUrl } { differentResourcePath } ",
914+ AuthorizationServers = { OAuthServerUrl } ,
915+ } ;
916+ } ) ;
917+
918+ await using var app = Builder . Build ( ) ;
919+
920+ app . MapMcp ( requestedResourcePath ) . RequireAuthorization ( ) ;
921+
922+ await app . StartAsync ( TestContext . Current . CancellationToken ) ;
923+
924+ await using var transport = new HttpClientTransport ( new ( )
925+ {
926+ Endpoint = new Uri ( $ "{ McpServerUrl } { requestedResourcePath } ") ,
927+ OAuth = new ( )
928+ {
929+ ClientId = "demo-client" ,
930+ ClientSecret = "demo-secret" ,
931+ RedirectUri = new Uri ( "http://localhost:1179/callback" ) ,
932+ AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync ,
933+ } ,
934+ } , HttpClient , LoggerFactory ) ;
935+
936+ // This should fail because the resource URI is a different path on the same host,
937+ // which is neither an exact match nor the authority-only base URL.
938+ var ex = await Assert . ThrowsAsync < McpException > ( ( ) => McpClient . CreateAsync (
939+ transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ) ;
940+
848941 Assert . Contains ( "does not match" , ex . Message ) ;
849942 }
850943
@@ -853,7 +946,7 @@ public async Task ResourceMetadata_DoesNotAddTrailingSlash()
853946 {
854947 // This test verifies that automatically derived resource URIs don't have trailing slashes
855948 // and that the client doesn't add them during authentication
856-
949+
857950 // Don't explicitly set Resource - let it be derived from the request
858951 await using var app = await StartMcpServerAsync ( ) ;
859952
@@ -993,10 +1086,10 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash()
9931086 {
9941087 // This test verifies that explicitly configured trailing slashes are preserved
9951088 const string resourceWithTrailingSlash = "http://localhost:5000/" ;
996-
1089+
9971090 // Configure ValidResources to accept the trailing slash version for this test
9981091 TestOAuthServer . ValidResources = [ resourceWithTrailingSlash , "http://localhost:5000/mcp" ] ;
999-
1092+
10001093 Builder . Services . Configure < McpAuthenticationOptions > ( McpAuthenticationDefaults . AuthenticationScheme , options =>
10011094 {
10021095 options . ResourceMetadata = new ProtectedResourceMetadata
0 commit comments