How to authenticate a user when consuming MassTransit messages in Asp.Net Core Web API?
Asked Answered
H

1

10

I have several Asp.Net Core Web APIs that use Bearer authentication and IdentityServer4.AccessTokenValidation middleware to introspect tokens, authenticate the user and create claims. This works fine for HTTP requests.

I am in the process of configuring these APIs to also be MassTransit endpoints (for both Publishing and Consuming messages) using RabbitMQ as transport. I followed the instructions here for adding MassTransit to the API and for setting up message consumers. A typical workflow will be something like:

HTTP Request to API > Publish message on MassTransit > RabbitMQ > Message consumed in another API

What I'm struggling to understand is how I can create a ClaimsPrincipal when consuming messages off the bus so that I know which user to perform actions on behalf of? Where it's not an HTTP request there is no AuthenticationHandler being invoked.

What I've tried so far:

I thought I'd approach this by passing a token (and/or individual claim values) in message headers. The publish part seemed easily enough as MassTransit allows adding any number of custom headers when publishing messages using MassTransit.PublishContextExecuteExtensions.Publish. This allowed me to get messages onto the transport with information identifying a user and this info can be viewed in a consumer by manually viewing the headers e.g.

public class SomeEventConsumer : IConsumer<SomeEventData>
{
    public async Task Consume(ConsumeContext<SomeEventData> context)
    {
        var token = context.Headers["token"];
    }
} 

At this point I could take the token and call the Introspection endpoint in Identity Server manually but then I'd need to:

  1. Do this in every consumer every time and then ...
  2. ... pass that information down to logic classes etc manually instead of making use of IHttpContextAccessor.HttpContext.User.Claims or by wrapping the claims and using Dependency Injection.

To address point 1 I created a new custom middleware ...

public class AuthenticationFilter<T> : IFilter<ConsumeContext<T>> where T : class
{
    public void Probe(ProbeContext context)
    {
        var scope = context.CreateFilterScope("authenticationFilter");
    }

    public async Task Send(ConsumeContext<T> context, IPipe<ConsumeContext<T>> next)
    {
        var token = context.Headers.Where(x => x.Key == "token").Select(x => x.Value.ToString()).Single();

        // TODO: Call token introspection

        await next.Send(context);
    }
}

public class AuthenticationFilterSpecification<T> : IPipeSpecification<ConsumeContext<T>> where T : class
{
    public void Apply(IPipeBuilder<ConsumeContext<T>> builder)
    {
        var filter = new AuthenticationFilter<T>();
        builder.AddFilter(filter);
    }

    public IEnumerable<ValidationResult> Validate()
    {
        return Enumerable.Empty<ValidationResult>();
    }
}

public class AuthenticationFilterConfigurationObserver : ConfigurationObserver, IMessageConfigurationObserver
{
    public AuthenticationFilterConfigurationObserver(IConsumePipeConfigurator receiveEndpointConfigurator) : base(receiveEndpointConfigurator)
    {
        Connect(this);
    }

    public void MessageConfigured<TMessage>(IConsumePipeConfigurator configurator)
        where TMessage : class
    {
        var specification = new AuthenticationFilterSpecification<TMessage>();
        configurator.AddPipeSpecification(specification);
    }
}

public static class AuthenticationExtensions
{
    public static void UseAuthenticationFilter(this IConsumePipeConfigurator configurator)
    {
        if (configurator == null)
        {
            throw new ArgumentNullException(nameof(configurator));
        }

        _ = new AuthenticationFilterConfigurationObserver(configurator);
    }
}

... and then added that into the pipeline ...

IBusControl CreateBus(IServiceProvider serviceProvider)
{
    return Bus.Factory.CreateUsingRabbitMq(cfg =>
    {
        cfg.Host("rabbitmq://localhost");
        cfg.UseAuthenticationFilter();
        // etc ...
    });
}

And this is where I'm stuck. I don't know how to authenticate the user for the scope of the request. Where it's not an HTTP request I'm not sure what best practice is here. Any suggestions or pointers would be gratefully received. Thanks...

Heloise answered 4/2, 2020 at 19:12 Comment(5)
User authentication is web concern. It should be done properly, using your identity provider. Messages you send to the bus could contain the identity itself, scopes and subs, whatever you get in the claim. Users must be already authenticated.Farinose
@AlexeyZimarev so I just need to serialize principal's claims on producer side and add them to message headers.On consumer side I just need to read them, deserialize to ClaimsPrincipal and set the current thread. Is that ok?Auliffe
@Gavin Sutherland why did you decide to pass a token insyaed of user claims?Auliffe
@АлександрСысоев .. because I can verify a token is valid by querying the trusted Authority Service (Introspection endpoint) that created it. By doing so, I can then be confident that the claims in the token are genuine and not malicious. For example, if I just passed user claims, how can I be sure that a message from [email protected] is really from that person?Heloise
@GavinSutherland Your application has already authenticated the user before sending the message, hasn’t it? Can consumers assume that messages are sent by a trusted service? That also avoids issues with token expiration (what if the token is valid during the HTTP request but expires before the consumer can handle the message?)Deejay
E
6

I've just been watching a Kevin Dockx course on Pluralsight that covers this scenario on Azure Service Bus, but the same principal would apply to Mass Transit or any other asynchronous communication between services using a message bus. Here's a link to the section: Securing Microservices in ASP.NET Core

Kevin's technique is to include the access token (JWT) as a property on the bus message and to then validate this in the consumer using IdentityModel.

To summarise:

In the Producer:

  1. Get the Access Token from the request (e.g. HttpContext.GetUserAccessTokenAsync()).
  2. Set this as a property in the message before sending.

In the Consumer:

  1. Use IdentityModel to get the IdP Discovery Document
  2. Extract the public signing keys from the discovery response (these must be converted to RsaSecurityKey)
  3. Call JwtSecurityTokenHandler.ValidateToken() to validate the JWT from the message. This returns a ClaimsPrincipal if successful.

If you're concerned about Access Token expiration, you can make use of the datetime that the message was enqueued as part of the token validation logic in the consumer.

Here's how the validator works (simplified):

var discoveryDocumentResponse = await httpClient.GetDiscoveryDocumentAsync("https://my.authority.com");
            
var issuerSigningKeys = new List<SecurityKey>();

foreach (var webKey in discoveryDocumentResponse.KeySet.Keys)
{
    var e = Base64Url.Decode(webKey.E);
    var n = Base64Url.Decode(webKey.N);

    var key = new RsaSecurityKey(new RSAParameters
        { Exponent = e, Modulus = n })
                {
                        KeyId = webKey.Kid
                };

    issuerSigningKeys.Add(key);
}

var tokenValidationParameters = new TokenValidationParameters()
{
        ValidAudience = "my-api-audience",
        ValidIssuer = "https://my.authority.com",
        IssuerSigningKeys = issuerSigningKeys        
};

var claimsPrincipal = new JwtSecurityTokenHandler().ValidateToken(tokenToValidate,
                    tokenValidationParameters, out var rawValidatedToken);

return claimsPrincipal;
Eldridge answered 16/9, 2021 at 15:35 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.