1

Evening all. I've been at trying to resolve some authorization/authentication issues with Blazor/HotChocolate/Azure AD B2C for a few days and I believe I have managed to nail it down to the following:

public string GetMe(ClaimsPrincipal claimsPrincipal, [Service] IHttpContextAccessor httpContextAccessor)
        {
            ClaimsPrincipal authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(JwtParser.ParseClaimsFromJwt(httpContextAccessor.HttpContext.Request.Headers["Authorization"])));

            //var a = Request.HttpContext.User;
            // Omitted code for brevity
            var userId = claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier);

            return userId;
        }

In the above, the claimsPrincipal does not recognize the claims from the JWT; however, the code for authenticatedUser does parse the claims. I want to give credit where due, the code for the parsing was found at (Hot Chocolate Authenticated Connected User) The whole idea came from there and has shed a lot of light on the source of this problem so thank you to everyone in that thread.

The code is a total mess right now as I've just been going through trying to nail down the issue. I am not going to clean it up and just post it as is in the hope that future google searchers will run across it if they have the same issue.

Program.cs

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Identity.Web;
using Microsoft.EntityFrameworkCore;
using HotChocolate.AspNetCore.Authorization;
using HotChocolate.Resolvers;
using Microsoft.AspNetCore.Authorization;
using HotChocolate.AspNetCore;
using HotChocolate.Execution;
using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAdB2C"));
builder.Services.AddPooledDbContextFactory<CasesMatter.Server.Data.Contexts.Postgres>
    (options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationHandler, CasesMatter.Server.MinimumAgeHandler>();

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("SalesDepartment",
        policy => policy.Requirements.Add(new SalesDepartmentRequirement()));
    options.AddPolicy("AtLeast21",
        policy => policy.Requirements.Add(new CasesMatter.Server.MinimumAgeRequirement(21)));
});

builder.Services.AddScoped<CasesMatter.Server.Data.Interfaces.IContact, CasesMatter.Server.Data.Access.ContactDAL>();
builder.Services.AddScoped<CasesMatter.Server.GraphQL.ContactResolver>();


builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Services.AddHttpContextAccessor();

builder.Services.AddGraphQLServer()
    //.AddIntrospectionAllowedRule()    
    .RegisterService<CasesMatter.Shared.Data.Interfaces.IContact>()
    .AddAuthorization()
    .AddQueryType<CasesMatter.Server.GraphQL.ContactResolver>()
    //.AddFiltering()
    //.AddSorting()
    //.AddProjections()
    .AddHttpRequestInterceptor<HttpRequestInterceptor>();

builder.Services.Configure<JwtBearerOptions>(
    JwtBearerDefaults.AuthenticationScheme, options =>
    {
        options.TokenValidationParameters.NameClaimType = "name";
    });

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseBlazorFrameworkFiles();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();


app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");

app.UseEndpoints(endpoints =>
{
    endpoints.MapGraphQL();//.RequireAuthorization();
});

app.Run();

public class SalesDepartmentRequirement : IAuthorizationRequirement 
{ 
    public SalesDepartmentRequirement()
    {
        string a = "";
    }
}

public class SalesDepartmentAuthorizationHandler
    : AuthorizationHandler<SalesDepartmentRequirement, IResolverContext>
{
    public override Task HandleAsync(AuthorizationHandlerContext context)
    {
        return base.HandleAsync(context);
    }

    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SalesDepartmentRequirement requirement, IResolverContext resource)
    {
        throw new NotImplementedException();
    }

    //protected override Task HandleRequirementAsync(
    //    AuthorizationHandlerContext context,
    //    SalesDepartmentRequirement requirement,
    //    IResolverContext resource)
    //{
    //    if (true)
    //    {
    //        context.Succeed(requirement);
    //    }

    //    return Task.CompletedTask;
    //}
}

public class HttpRequestInterceptor : DefaultHttpRequestInterceptor
{
    public override ValueTask OnCreateAsync(HttpContext context,
        IRequestExecutor requestExecutor, IQueryRequestBuilder requestBuilder,
        CancellationToken cancellationToken)
    {
        var identity = new ClaimsIdentity();
        identity.AddClaim(new Claim(ClaimTypes.Country, "us"));

        context.User.AddIdentity(identity);

        return base.OnCreateAsync(context, requestExecutor, requestBuilder,
            cancellationToken);
    }
}

ContactResolver.cs


using HotChocolate.AspNetCore.Authorization;
using System.Security.Claims;

namespace CasesMatter.Server.GraphQL
{
    public class ContactResolver
    {
        //[UsePaging]
        //[UseProjection]
        //[UseFiltering]
        //[UseSorting]
        [Authorize(Policy = "SalesDepartment")]
        [Authorize(Policy = "AtLeast21")]
        [GraphQLDescription("Gets a list of contacts having a display name like the text provided.")]
        public List<Data.DBO.Contact> GetContacts([Service] Data.Interfaces.IContact service)
            => service.GetContacts();

        public string GetMe(ClaimsPrincipal claimsPrincipal, [Service] IHttpContextAccessor httpContextAccessor)
        {
            ClaimsPrincipal authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(JwtParser.ParseClaimsFromJwt(httpContextAccessor.HttpContext.Request.Headers["Authorization"])));

            //var a = Request.HttpContext.User;
            // Omitted code for brevity
            var userId = claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier);

            return userId;
        }
    }

    public static class JwtParser
    {
        public static IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
        {
            List<Claim> claims = new();
            string payload = jwt.Split('.')[1];
            byte[] jsonBytes = ParseBase64WithoutPadding(payload);
            Dictionary<string, object> keyValuePairs = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
            ExtractRolesFromJwt(claims, keyValuePairs);
            claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));
            return claims;
        }

        private static void ExtractRolesFromJwt(List<Claim> claims, Dictionary<string, object> keyValuePairs)
        {
            keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles);
            if (roles is null) return;
            string[] parsedRoles = roles.ToString().Trim().TrimStart('[').TrimEnd(']').Split(',');
            if (parsedRoles.Length > 1)
            {
                foreach (string parsedRole in parsedRoles)
                {
                    claims.Add(new Claim(ClaimTypes.Role, parsedRole.Trim('"')));
                }
            }
            else
            {
                claims.Add(new Claim(ClaimTypes.Role, parsedRoles[0]));
            }
            keyValuePairs.Remove(ClaimTypes.Role);
        }

        private static byte[] ParseBase64WithoutPadding(string base64)
        {
            switch (base64.Length % 4)
            {
                case 2:
                    base64 += "==";
                    break;
                case 3:
                    base64 += "=";
                    break;
            }
            return Convert.FromBase64String(base64);
        }
    }
}

The longer story was that I started trying to setup authorization and continued struggling with why calls to the HotChocolate resolver methods were not triggering the Authorize policies I had in place. After pounding around on it a while, it dawned on me that it could be because there was actually an Authentication issue and the user was not authenticated according to HotChocolate's interpretation. I pulled the JWT and plugged it into postman and ran the query against the host. I found that the "authenticatedUser" variable actually had all the claims from the JWT while the claimsPrincipal had none. The claimsPrincipal is not arriving at the resolver with the identity information.

Thus, my ultimate question, does anyone know how I have failed?

Lucas
  • 478
  • 4
  • 15
  • Did you ever manage to get the ClaimsPrincipal injected properly? I'm just struggling with this myself... – jhaagsma Aug 26 '22 at 21:07
  • 1
    I ended up using a TokenValidationManager. in program.cs: app.UseTokenValidationManager(); in another file: public static class TokenValidationManagerExtensions { public static IApplicationBuilder UseTokenValidationManager( this IApplicationBuilder builder) { return builder.UseMiddleware(); } } then creating the TokenValidationManager class and in the InvokeAsync method, just going through the claims. – Lucas Aug 29 '22 at 02:16

1 Answers1

0

I had the same problem when using multiple authentication schemes. There is a reported issue on GitHub: Authorization fails on multiple authentication schemes unless RequireAuthorization() is set.

To solve that I had to add a HttpRequestInterceptor to force the user authentication:

public class HttpRequestInterceptor : DefaultHttpRequestInterceptor
{
    private readonly IPolicyEvaluator _policyEvaluator;
    private readonly IAuthorizationPolicyProvider _policyProvider;

    public HttpRequestInterceptor(IPolicyEvaluator policyEvaluator,
        IAuthorizationPolicyProvider policyProvider)
    {
        _policyEvaluator = policyEvaluator;
        _policyProvider = policyProvider;
    }

    public override async ValueTask OnCreateAsync(HttpContext context,
        IRequestExecutor requestExecutor, IQueryRequestBuilder requestBuilder,
        CancellationToken cancellationToken)
    {
        await _policyEvaluator.AuthenticateAsync(await _policyProvider.GetDefaultPolicyAsync(), context);
        await base.OnCreateAsync(context, requestExecutor, requestBuilder, cancellationToken);
    }
}

And I registered the interceptor after the GraphQL Server:

services
    .AddGraphQLServer()
    .AddHttpRequestInterceptor<HttpRequestInterceptor>();

Other extreme solution would have been to enable Global authorization using the RequireAuthorization() middleware:

app.UseEndpoints(endpoints =>
{
    endpoints.MapGraphQL().RequireAuthorization();
});
Rafael Neto
  • 1,036
  • 10
  • 16