Roles not being interpreted correctly by Blazor WASM app

Viewed 33

I'm building an app using Blazor WASM (which I'm new to.) I am using Auth0 for security. When I get the roles claim down to the front end Razor page, it does not interpret the roles correctly, and I'm about at my wits' end.

So! Starting from the server, I use JwtBearer so we can authenticate a couple of APIs using roles. That part works.

In the server's Program.cs:

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

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, c =>
    {
        c.Authority = builder.Configuration["Auth0:Domain"];
        c.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
        {
            ValidAudience = builder.Configuration["Auth0:Audience"],
            ValidIssuer = builder.Configuration["Auth0:Domain"]
        };
    });

var app = builder.Build();
...

Next, the client apparently wants to use OIDC? Okay, weird, but fine...

Client Program.cs

builder.Services.AddOidcAuthentication(options =>
{
    builder.Configuration.Bind("Auth0", options.ProviderOptions);
    options.ProviderOptions.ResponseType = "code";
    options.ProviderOptions.AdditionalProviderParameters.Add("audience", builder.Configuration["Auth0:Audience"]);
    // Without this setting, Blazor wants the role claim to be http://schemas.microsoft.com/ws/2008/06/identity/claims/role.
    options.UserOptions.RoleClaim = "https://thiscompany.com/roles";
});

await builder.Build().RunAsync();

Finally, the Razor page. In this example, I wanted to have it show a login prompt if the user is not logged in (login being detected as Authorization with no roles), while only Admins can see the "hello world" message; it displays an unauthorized message to logged in users with any other roles. I've added some instrumentation to that message so I can see exactly what it's doing.

<AuthorizeView>
    <Authorized>
        <AuthorizeView Roles="Admin">
            <Authorized Context="loggedInUser">
                <PageTitle>Administration Center</PageTitle>
                <h1>Hello, world!</h1>
            </Authorized>
            <NotAuthorized Context="loggedInUser">
                <p>YOU ARE NOT WELCOME HERE</p>
                <p>Claims: <ul>
                    @for(int i = 0; i < Claims(context).Count; i++)
                    {
                        <li>@Claims(context)[i]</li>
                    }
                    </ul>
                </p>
                <p>Role claim: @RoleClaim(context)</p>
                <p>Is Admin in @GetRoleClaimType(context)? @context.User.IsInRole("Admin")</p>
            </NotAuthorized>
        </AuthorizeView>
    </Authorized>
    <NotAuthorized>
        <PageTitle>The Admin-Only App</PageTitle>
        <h1>The Admin-Only App</h1>
        Please log in to access this application.
    </NotAuthorized>
</AuthorizeView>

If I log in and I have the Admin role, I should see "Hello world". Instead, here's what happens:

YOU ARE NOT WELCOME HERE

Claims:

https://thiscompany.com/roles: ["Admin","SomeOtherRole"]

<...other irrelevant claims...>

Role claim: https://thiscompany.com/roles:["Admin","SomeOtherRole"]

Is Admin in https://thiscompany.com/roles? False

My understanding was that it was supposed to decompose the roles array and grant me access, but it doesn't. If I tinker with Auth0 so that I get the claim:

https://thiscompany.com/roles: Admin

Then it works correctly, so I have a sneaking suspicion that the JSON array is getting encoded so that it decodes as a text string. However, using the array of roles as provided is a requirement. I'm really lost in the weeds here and not sure where or how to adjust this so that Blazor can understand the roles claim.

1 Answers

I found the solution. The problem is that Blazor doesn't decompose the roles array, it just takes the raw text and interprets that as the name of the role rather than a JSON object it needs to handle. Blazor expects multiple role claims with the same type, one role per claim.

To decompose the role claim for it, you have to create a custom factory.

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using System.Security.Claims;
using System.Text.Json;

public class CustomUserFactory : AccountClaimsPrincipalFactory<RemoteUserAccount>
{
    public CustomUserFactory(IAccessTokenProviderAccessor accessor)
        : base(accessor)
    {
    }

    public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
        RemoteUserAccount account,
        RemoteAuthenticationUserOptions options)
    {
        var user = await base.CreateUserAsync(account, options);
        var claimsIdentity = (ClaimsIdentity?)user.Identity;

        if (account != null && claimsIdentity != null)
        {
            MapArrayClaimsToMultipleSeparateClaims(account, claimsIdentity);
        }

        return user;
    }

    private void MapArrayClaimsToMultipleSeparateClaims(RemoteUserAccount account, ClaimsIdentity claimsIdentity)
    {
        foreach (var prop in account.AdditionalProperties)
        {
            var key = prop.Key;
            var value = prop.Value;
            if (value != null && (value is JsonElement element && element.ValueKind == JsonValueKind.Array))
            {
                // Remove the Roles claim with an array value and create a separate one for each role.
                claimsIdentity.RemoveClaim(claimsIdentity.FindFirst(prop.Key));
                var claims = element.EnumerateArray().Select(x => new Claim(prop.Key, x.ToString()));
                claimsIdentity.AddClaims(claims);
            }
        }
    }
}

Then you add it to the services immediately after the AddOidcAuthentication call.

builder.Services.AddOidcAuthentication(options =>
{
    builder.Configuration.Bind("Auth0", options.ProviderOptions);
    options.ProviderOptions.ResponseType = "code";
    options.ProviderOptions.AdditionalProviderParameters.Add("audience", builder.Configuration["Auth0:Audience"]);
    // Without this setting, Blazor wants the role claim to be http://schemas.microsoft.com/ws/2008/06/identity/claims/role.
    options.UserOptions.RoleClaim = "https://thiscompany.com/roles";
});
builder.Services.AddApiAuthorization().AddAccountClaimsPrincipalFactory<CustomUserFactory>();

await builder.Build().RunAsync();

Thanks to Marinko Spasojevic for laying out the problem and solution in his article on Code Maze (in the "Supporting Multiple Roles" section). https://code-maze.com/using-roles-in-blazor-webassembly-hosted-applications/

Related