Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add roles to WASM+Identity sample app #140

Merged
merged 2 commits into from
Feb 15, 2024

Conversation

guardrex
Copy link
Collaborator

@guardrex guardrex commented Dec 13, 2023

Addresses dotnet/AspNetCore.Docs#31045

Probably a fancier and more performant way to do this 🙈, but here's something to get us rolling at least for discussion.

Everything I show below is on the PR and ✨ Just Works!™ ✨.

Backend app

First of all, I think it's a good idea to have seeded data for learning and testing with role claims, AND I loathe having to re-register a test user over and over ... and over 😠. I place a SeedData class in here to take care of both. It creates a user, Bob 🤠, with two role claims (Administrator, Manager). We'll get seeded right after the app is built ...

#if DEBUG
using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;

    SeedData.Initialize(services);
}
#endif

IdentityUser has nothing to hold roles, so I kept AppUser. We were going to shed it and use IdentityUser, but it's just as well that I didn't because we need it for roles. I added IEnumerable<IdentityRole> to AppUser ...

class AppUser : IdentityUser
{
    public IEnumerable<IdentityRole>? Roles { get; set; }
}

Add roles to the Identity bits ...

builder.Services.AddIdentityCore<AppUser>()
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<AppDbContext>()
    .AddApiEndpoints();

We need some kind of endpoint for the frontend to tap for the user's roles, so I went with the following Minimal API approach ...

app.MapGet("/roles", (ClaimsPrincipal user) =>
{
    if (user.Identity is not null && user.Identity.IsAuthenticated)
    {
        var identity = (ClaimsIdentity)user.Identity;
        var roles = identity.FindAll(identity.RoleClaimType)
            .Select(c => 
                new
                {
                    c.Issuer, 
                    c.OriginalIssuer, 
                    c.Type, 
                    c.Value, 
                    c.ValueType
                });

        return TypedResults.Json(roles);
    }

    return Results.Unauthorized();
});

Ugh! ... that's ☝️ probably more rotten 🙈 RexHaqs!™ code 🙈. I couldn't get a Claim[] array to play nicely with TypedResults.Json because Claim doesn't have a parameterless ctor and the JSON serializer usually flakes out in a ☠️ loop, perhaps because the claim's Subject has a claims collection. I'm not familiar with source generators/reflection concepts in System.Text.Json serialization, but I did have a little luck with ...

var roles = identity.FindAll(identity.RoleClaimType).ToArray();

var serializerOptions = new JsonSerializerOptions()
{
    ReferenceHandler = ReferenceHandler.Preserve,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

return TypedResults.Json(roles, serializerOptions);

However, that only (apparently) sends down the Administrator role claim. It doesn't send the other role claim for Manager. The simplest way for me to solve this was to select (.Select) what's needed from the claims into an IEnumerable and run the JSON serializer on that to send the role claims.

The PU is welcome to fix this with a proper source generator and make it work with a normal Claim[] array (ToArray). The silly 🦖 will watch and learn!

BlazorWasmAuth app

Added two pages: One requires a Manager role claim, and the other requires an Editor role claim. I added links to them to the NavMenu, and the links show up when merely authenticated. That's by-design because we want to demo that the Editor page can't be reached by Bob 🤠 the test user with only Administrator and Manager role claims.

The action takes place in the CookieAuthenticationStateProvider's GetAuthenticationStateAsync method. I add the following to make claims out of the role claims that come down from tapping the /roles endpoint of the Backend app.

First, something to receive the claims. Again, trying to use a Claim[] array flakes out the JSON serializer without source gen. Using a custom class is the low-hanging-🍎 approach. ... and again, the PU is welcome to fix this to just deserialize with a Claim[] array.

public class RoleClaim
{
    public string? Issuer { get; set; }
    public string? OriginalIssuer { get; set; }
    public string? Type { get; set; }
    public string? Value { get; set; }
    public string? ValueType { get; set; }
}

... and just before the ClaimsIdentity is created in GetAuthenticationStateAsync ...

// tap the roles endpoint for the user's roles
var rolesResponse = await _httpClient.GetAsync("roles");

// throw if request fails
rolesResponse.EnsureSuccessStatusCode();

// read the response into a string
var rolesJson = await rolesResponse.Content.ReadAsStringAsync();

// deserialize the roles string into an array
var roles = JsonSerializer.Deserialize<RoleClaim[]>(rolesJson, jsonSerializerOptions);

// if there are roles, add them to the claims collection
if (roles?.Length > 0)
{
    foreach (var role in roles)
    {
        if (!string.IsNullOrEmpty(role.Type) && !string.IsNullOrEmpty(role.Value))
        {
            claims.Add(new Claim(role.Type, role.Value, role.ValueType, role.Issuer, role.OriginalIssuer));
        }
    }
}

The role claims JSON (formatted for display here) returned by the /roles endpoint when run locally for the example:

[
    {
    "issuer" : "LOCAL AUTHORITY",
    "originalIssuer" : "LOCAL AUTHORITY",
    "name" : "bob@contoso.com",
    "type" : "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
    "value" : "Administrator",
    "valueType" : "http://www.w3.org/2001/XMLSchema#string"
    },
    {
    "issuer" : "LOCAL AUTHORITY",
    "originalIssuer" : "LOCAL AUTHORITY",
    "name" : "bob@contoso.com",
    "type" : "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
    "value" : "Manager",
    "valueType" : "http://www.w3.org/2001/XMLSchema#string"
    }
]

@PedrooNL

This comment was marked as off-topic.

@guardrex

This comment was marked as off-topic.

@PedrooNL

This comment was marked as off-topic.

@guardrex

This comment was marked as off-topic.

@PedrooNL

This comment was marked as off-topic.

@guardrex

This comment was marked as off-topic.

@PedrooNL

This comment was marked as off-topic.

Copy link
Member

@halter73 halter73 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice changes. I really like seeding some data for development.

Ugh! ... that's ☝️ probably more rotten 🙈 RexHaqs!™ code 🙈. I couldn't get a Claim[] array to play nicely with TypedResults.Json because Claim doesn't have a parameterless ctor and the JSON serializer usually flakes out in a ☠️ loop

I like this RexHaq™. I've done stuff very similar to this because you cannot simply JSON serialize a Claim. I also like the usage of anonymous types.

Co-authored-by: Stephen Halter <halter73@gmail.com>
@guardrex guardrex merged commit 8885f1a into main Feb 15, 2024
1 check passed
@guardrex guardrex deleted the guardrex/wasm-plus-identity-sample-with-roles branch February 15, 2024 13:30
@Weebworks
Copy link

Just dropping by to say that I'm immensely grateful for the Roles example and this sample project in general.
ASP.NET Core identity is a beast and a half and I'm so happy I got the roles part sorted now and I can focus on developing features for my application.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants