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?