Search code examples
c#asp.net-corejwtauth0

ASP.NET Core 2.0 app.UseAuthentication not executing for every request


I have a really weird issue which I have spent an entire day debugging and nowhere close to solving. I am in the process of upgrading my application from ASP.NET Core 1.x to 2.1. As part of doing this, I am having to re-wire the Authentication and Authorization mechanism. We use JWTBearer Authentication, and I am using postman to fire an API call, which executes the pipeline and I can see the AuthHandler executing. However if I fire the same request again, the AuthHandler does not execute and debugger "steps-over" the "context.AuthenticateAsync" call and returns the previous result. For the sake of elaborating, I have written a custom auth handler which is a copy paste of JWTAuthHandler. The code to create a custom handler is based off the answer here.

using Microsoft.AspNetCore.Authentication.JwtBearer;
public class CustomAuthOptions : JwtBearerOptions
{
}

using Microsoft.AspNetCore.Authentication;

public static class CustomAuthExtensions
{
    public static AuthenticationBuilder AddCustomAuth(this AuthenticationBuilder builder, Action<CustomAuthOptions> configureOptions)
    {
        return builder.AddScheme<CustomAuthOptions, CustomAuthHandler>("CustomScheme", configureOptions);
    }
}


public class CustomAuthHandler : AuthenticationHandler<CustomAuthOptions>
{
    private OpenIdConnectConfiguration _configuration;

    public CustomAuthHandler(IOptionsMonitor<CustomAuthOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }

    /// <summary>
    /// The handler calls methods on the events which give the application control at certain points where processing is occurring. 
    /// If it is not provided a default instance is supplied which does nothing when the methods are called.
    /// </summary>
    protected new JwtBearerEvents Events
    {
        get => (JwtBearerEvents)base.Events;
        set => base.Events = value;
    }

    protected override Task<object> CreateEventsAsync() => Task.FromResult<object>(new JwtBearerEvents());

    /// <summary>
    /// Searches the 'Authorization' header for a 'Bearer' token. If the 'Bearer' token is found, it is validated using <see cref="TokenValidationParameters"/> set in the options.
    /// </summary>
    /// <returns></returns>
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        string token = null;
        try
        {
            // Give application opportunity to find from a different location, adjust, or reject token
            var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options);

            // event can set the token
            await Events.MessageReceived(messageReceivedContext);
            if (messageReceivedContext.Result != null)
            {
                return messageReceivedContext.Result;
            }

            // If application retrieved token from somewhere else, use that.
            token = messageReceivedContext.Token;

            if (string.IsNullOrEmpty(token))
            {
                string authorization = Request.Headers["Authorization"];

                // If no authorization header found, nothing to process further
                if (string.IsNullOrEmpty(authorization))
                {
                    return AuthenticateResult.NoResult();
                }

                if (authorization.StartsWith("CustomAuth ", StringComparison.OrdinalIgnoreCase))
                {
                    token = authorization.Substring("CustomAuth ".Length).Trim();
                }

                // If no token found, no further work possible
                if (string.IsNullOrEmpty(token))
                {
                    return AuthenticateResult.NoResult();
                }
            }

            if (_configuration == null && Options.ConfigurationManager != null)
            {
                _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted);
            }

            var validationParameters = Options.TokenValidationParameters.Clone();
            if (_configuration != null)
            {
                var issuers = new[] { _configuration.Issuer };
                validationParameters.ValidIssuers = validationParameters.ValidIssuers?.Concat(issuers) ?? issuers;

                validationParameters.IssuerSigningKeys = validationParameters.IssuerSigningKeys?.Concat(_configuration.SigningKeys)
                    ?? _configuration.SigningKeys;
            }

            List<Exception> validationFailures = null;
            SecurityToken validatedToken;
            foreach (var validator in Options.SecurityTokenValidators)
            {
                if (validator.CanReadToken(token))
                {
                    ClaimsPrincipal principal;
                    try
                    {
                        principal = validator.ValidateToken(token, validationParameters, out validatedToken);
                    }
                    catch (Exception ex)
                    {
                        ////Logger.TokenValidationFailed(ex);

                        // Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the event.
                        if (Options.RefreshOnIssuerKeyNotFound && Options.ConfigurationManager != null
                            && ex is SecurityTokenSignatureKeyNotFoundException)
                        {
                            Options.ConfigurationManager.RequestRefresh();
                        }

                        if (validationFailures == null)
                        {
                            validationFailures = new List<Exception>(1);
                        }
                        validationFailures.Add(ex);
                        continue;
                    }

                    ////Logger.TokenValidationSucceeded();

                    var tokenValidatedContext = new TokenValidatedContext(Context, Scheme, Options)
                    {
                        Principal = principal,
                        SecurityToken = validatedToken
                    };

                    await Events.TokenValidated(tokenValidatedContext);
                    if (tokenValidatedContext.Result != null)
                    {
                        return tokenValidatedContext.Result;
                    }

                    if (Options.SaveToken)
                    {
                        tokenValidatedContext.Properties.StoreTokens(new[]
                        {
                            new AuthenticationToken { Name = "access_token", Value = token }
                        });
                    }

                    tokenValidatedContext.Success();
                    return tokenValidatedContext.Result;
                }
            }

            if (validationFailures != null)
            {
                var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options)
                {
                    Exception = (validationFailures.Count == 1) ? validationFailures[0] : new AggregateException(validationFailures)
                };

                await Events.AuthenticationFailed(authenticationFailedContext);
                if (authenticationFailedContext.Result != null)
                {
                    return authenticationFailedContext.Result;
                }

                return AuthenticateResult.Fail(authenticationFailedContext.Exception);
            }

            return AuthenticateResult.Fail("No SecurityTokenValidator available for token: " + token ?? "[null]");
        }
        catch (Exception ex)
        {
            ////Logger.ErrorProcessingMessage(ex);

            var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options)
            {
                Exception = ex
            };

            await Events.AuthenticationFailed(authenticationFailedContext);
            if (authenticationFailedContext.Result != null)
            {
                return authenticationFailedContext.Result;
            }

            throw;
        }
    }

    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        var authResult = await HandleAuthenticateOnceSafeAsync();
        var eventContext = new JwtBearerChallengeContext(Context, Scheme, Options, properties)
        {
            AuthenticateFailure = authResult?.Failure
        };

        // Avoid returning error=invalid_token if the error is not caused by an authentication failure (e.g missing token).
        if (Options.IncludeErrorDetails && eventContext.AuthenticateFailure != null)
        {
            eventContext.Error = "invalid_token";
            eventContext.ErrorDescription = CreateErrorDescription(eventContext.AuthenticateFailure);
        }

        await Events.Challenge(eventContext);
        if (eventContext.Handled)
        {
            return;
        }

        Response.StatusCode = 401;

        if (string.IsNullOrEmpty(eventContext.Error) &&
            string.IsNullOrEmpty(eventContext.ErrorDescription) &&
            string.IsNullOrEmpty(eventContext.ErrorUri))
        {
            Response.Headers.Append(HeaderNames.WWWAuthenticate, Options.Challenge);
        }
        else
        {
            // https://tools.ietf.org/html/rfc6750#section-3.1
            // WWW-Authenticate: Bearer realm="example", error="invalid_token", error_description="The access token expired"
            var builder = new StringBuilder(Options.Challenge);
            if (Options.Challenge.IndexOf(" ", StringComparison.Ordinal) > 0)
            {
                // Only add a comma after the first param, if any
                builder.Append(',');
                builder.Append(',');
            }
            if (!string.IsNullOrEmpty(eventContext.Error))
            {
                builder.Append(" error=\"");
                builder.Append(eventContext.Error);
                builder.Append("\"");
            }
            if (!string.IsNullOrEmpty(eventContext.ErrorDescription))
            {
                if (!string.IsNullOrEmpty(eventContext.Error))
                {
                    builder.Append(",");
                }

                builder.Append(" error_description=\"");
                builder.Append(eventContext.ErrorDescription);
                builder.Append('\"');
            }
            if (!string.IsNullOrEmpty(eventContext.ErrorUri))
            {
                if (!string.IsNullOrEmpty(eventContext.Error) ||
                    !string.IsNullOrEmpty(eventContext.ErrorDescription))
                {
                    builder.Append(",");
                }

                builder.Append(" error_uri=\"");
                builder.Append(eventContext.ErrorUri);
                builder.Append('\"');
            }

            Response.Headers.Append(HeaderNames.WWWAuthenticate, builder.ToString());
        }
    }

    private static string CreateErrorDescription(Exception authFailure)
    {
        IEnumerable<Exception> exceptions;
        if (authFailure is AggregateException agEx)
        {
            exceptions = agEx.InnerExceptions;
        }
        else
        {
            exceptions = new[] { authFailure };
        }

        var messages = new List<string>();

        foreach (var ex in exceptions)
        {
            // Order sensitive, some of these exceptions derive from others
            // and we want to display the most specific message possible.
            switch (ex)
            {
                case SecurityTokenInvalidAudienceException _:
                    messages.Add("The audience is invalid");
                    break;
                case SecurityTokenInvalidIssuerException _:
                    messages.Add("The issuer is invalid");
                    break;
                case SecurityTokenNoExpirationException _:
                    messages.Add("The token has no expiration");
                    break;
                case SecurityTokenInvalidLifetimeException _:
                    messages.Add("The token lifetime is invalid");
                    break;
                case SecurityTokenNotYetValidException _:
                    messages.Add("The token is not valid yet");
                    break;
                case SecurityTokenExpiredException _:
                    messages.Add("The token is expired");
                    break;
                case SecurityTokenSignatureKeyNotFoundException _:
                    messages.Add("The signature key was not found");
                    break;
                case SecurityTokenInvalidSignatureException _:
                    messages.Add("The signature is invalid");
                    break;
            }
        }

        return string.Join("; ", messages);
    }
}

And then the Startup.cs to hook it up:

public class Startup
{
    /// <summary>
    /// Initializes a new instance of the <see cref="Startup"/> class.
    /// </summary>
    /// <param name="configuration">The configuration.</param>
    public Startup(IConfiguration configuration)
    {
        this.Configuration = configuration;
        ConfigureLogging();
    }

    /// <summary>
    /// Gets the configuration.
    /// </summary>
    /// <value>
    /// The configuration.
    /// </value>
    public IConfiguration Configuration { get; }

    /// <summary>
    /// Gets or sets the Container
    /// </summary>
    private IUnityContainer Container { get; set; }

    public static void Main(string[] args)
    {
        BuildWebHost(args).Run();
    }

    public static IWebHost BuildWebHost(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>()
            .Build();

    public IServiceProvider ConfigureServices(IServiceCollection services)
    {
        var logger = Logger.For(this).ForAction(nameof(ConfigureServices));

        services.Configure<GzipCompressionProviderOptions>(options => options.Level = CompressionLevel.Optimal);
        services.AddResponseCompression();

        logger.Info("Configuring JWT Bearer Token Authorization...");
        services.AddAuthentication(options =>
                {
                    // the scheme name has to match the value we're going to use in AuthenticationBuilder.AddScheme(...)
                    options.DefaultAuthenticateScheme = "CustomScheme";
                    options.DefaultChallengeScheme = "CustomScheme";
                })
            .AddCustomAuth(options => {
                    options.Audience = this.Configuration.ObtainConfiguredString(ConfigurationKeys.ValidAudienceId);
                    options.Authority = this.Configuration.ObtainConfiguredString(ConfigurationKeys.IssuerId);
                    options.SaveToken = false;
                    options.TokenValidationParameters = new TokenValidationParameters().WithConfiguredParameters(this.Configuration);
                });

        logger.Info("Adding Authorization policies to Services...");
        services.AddAuthorization(
            options =>
                {
                    options.DefaultPolicy = new AuthorizationPolicyBuilder("CustomScheme").RequireAuthenticatedUser().Build();                        
                });

        services.AddTransient<IHttpContextAccessor, HttpContextAccessor>();
        services.AddTransient<IAuthenticationHandler, CustomAuthHandler>();

        EnableCors(services);

        logger.Info("Adding MVC support to Services...");
        services.AddMvc(config =>
            {
                var defaultPolicy = new AuthorizationPolicyBuilder(new[] { "CustomScheme" })
                    .RequireAuthenticatedUser()
                    .Build();
                config.Filters.Add(new AuthorizeFilter(defaultPolicy));
            });

        Container = new UnityContainer();

        logger.Info("Registering other Services with UnityContainer...");
        Container.RegisterServices(Configuration);

        // Configure Microsoft DI for Unity resolution
        logger.Info("Configuring ASP.Net Core service resolution to use UnityContainer...");
        return services.UseUnityResolution(Container, s => s.BuildServiceProvider());
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    /// <summary>
    /// The Configure
    /// </summary>
    /// <param name="app">The app<see cref="IApplicationBuilder"/></param>
    /// <param name="env">The env<see cref="IHostingEnvironment"/></param>
    /// <param name="loggerFactory">The loggerFactory<see cref="ILoggerFactory"/></param>
    /// <param name="memoryCache">The memoryCache<see cref="IMemoryCache"/></param>
    /// <param name="contextAccessor">The contextAccessor<see cref="IHttpContextAccessor"/></param>
    /// <param name="authzClient">The authzClient<see cref="IAuthzClient"/></param>
    public void Configure(
        IApplicationBuilder app,
        IHostingEnvironment env,
        ILoggerFactory loggerFactory,
        IMemoryCache memoryCache,
        IHttpContextAccessor contextAccessor,
        IAuthzClient authzClient)
    {
        var logger = Logger.For(this).ForAction(nameof(Configure));

        logger.Info("Configuring ASP.Net Core logging framework...");

        loggerFactory.AddConsole(this.Configuration.GetSection("Logging"));
        loggerFactory.AddDebug();

        var corsEnabled = this.Configuration.ObtainConfiguredBooleanWithDefault(ConfigurationKeys.EnableCors, false);
        if (corsEnabled)
        {
            app.UseCors("CorsPolicy");
        }

        logger.Info("Configuring ASP.Net Core custom status page...");
        app.UseStatusCodePagesWithReExecute("/error/{0}");

        if (env.IsDevelopment())
        {
            logger.Info("Configuring development middle-ware...");
            app.UseDeveloperExceptionPage();
            app.UseBrowserLink();
        }

        logger.Info("Configuring standard ASP.Net Core behaviors...");
        app.UseDefaultFiles();
        app.UseStaticFiles();

        ////app.UseAuthentication();
        app.Use(async (context, next) =>
            {
                if (!context.User.Identity.IsAuthenticated)
                {
                    var result = await context.AuthenticateAsync("CustomScheme");
                    if (result?.Principal != null)
                    {
                        context.User = result.Principal;
                    }
                }

                await next.Invoke();
            });

        app.UseMvc();
        app.WithRequestLogging();          
    }

    private void EnableCors(IServiceCollection service)
    {
        var logger = Logger.For(this).ForAction(nameof(EnableCors));
        var corsEnabled = this.Configuration.ObtainConfiguredBooleanWithDefault(ConfigurationKeys.EnableCors, false);

        if (corsEnabled)
        {
            logger.Verbose("Configuring ASP.Net Core CORS support...");

            service.AddCors(
                options =>
                    {
                        options.AddPolicy("CorsPolicy",
                            builder =>
                                {
                                    builder.AllowAnyOrigin();
                                    builder.AllowAnyHeader();
                                    builder.AllowAnyMethod();
                                    builder.AllowCredentials();
                                });
                    });
        }
    }
    }
}

Can someone please tell me what I am doing wrong? First time around when I fire the postman request with the correct AuthorizationHeader with the access token this line executes the CustomAuthHandler:

var result = await context.AuthenticateAsync("CustomScheme");

However the second time around the debugger steps over that code? This is driving me up the wall. I must be missing something basic!

EDIT: In the Core 1.x version, ConfigureServices was setup as

public IServiceProvider ConfigureServices(IServiceCollection services)
    {
        var logger = Logger.For(this).ForAction(nameof(ConfigureServices));

        logger.Verbose("Adding MVC support to Services...");
        // Add framework services.
        services.AddMvc();

        logger.Verbose("Adding Authorization policies to Services...");
        services.AddAuthorization(
            options =>
            {
                options.AddPolicy(
                    "SomePermission",
                    policy => policy.RequireClaim("claimUrl", "Some Permission"));
            });

        services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

        container = new UnityContainer();

        logger.Verbose("Registering other Services with UnityContainer...");
        container.RegisterServices(Configuration);

        // Configure Microsoft DI for Unity resolution
        logger.Verbose("Configuring ASP.Net Core service resolution to use UnityContainer...");
        return services.UseUnityResolution(container, s => s.BuildServiceProvider());
    }

And Configure() was wired up as follows

 app.UseAuth0JwtBearerAuthentication(
            new JwtBearerOptions
            {
                AutomaticAuthenticate = true,
                AutomaticChallenge = true,
                TokenValidationParameters =
                        new TokenValidationParameters().WithConfiguredParameters(this.Configuration)
            });

if (env.IsDevelopment())
        {
            logger.Verbose("Configuring development middleware...");
            app.UseDeveloperExceptionPage();
            app.UseBrowserLink();
        }

        logger.Verbose("Configuring standard ASP.Net Core behaviors...");
        app.UseDefaultFiles();
        app.UseMvc();
        app.UseStaticFiles();

Using this version if I execute postman calls, then I get a new ClaimsPrincipal for every request. So what has changed in ASP.NET Core 2.1?


Solution

  • For anyone who faces the same issue; my problem turned out to be Unity. ASP.NET Core 2.0 does not support Unity out of the box, because the ConfigureServices() method in Startup.cs is a replacement for third party DI container like Unity or Autofac. However if you still want to use Unity, you need to Nuget Unity.Microsoft.DependencyInjection to your project. The Github repo has details on how to wire it up.

    Additionally all of the other dependent projects were using Unity 4.0.1 which has IUnityContainer under Microsoft.Practices.Unity, whereas from Unity 5 upwards, the IUnityContainer has been moved to Unity namespace. This was an additional gotcha, whereby even after setting up the DI container as per Github repo's instructions, I was getting exceptions with failed dependency resolutions. The workaround was the create a new UnityContainer using Microsoft.Practices.Unity, let the dependent projects bootstrap it, and then copy those registrations to IUnityContainer under the Unity namespace.

    Startup.cs

    public void ConfigureContainer(IUnityContainer container)
    {
         container.RegisterServices(Configuration);
    }
    

    UnityRegistrations.cs

    public static void RegisterServices(this IUnityContainer container, IConfiguration configuration)
    {
        // Microsoft.Practices.Unity
        var currentContainer = new UnityContainer();
        // Bootstrap this and register dependencies
        // Then copy them over
        foreach (var registration in currentContainer.Registrations)
        {
            container.RegisterType(registration.RegisteredType, registration.MappedToType);
        }
    }