dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.41k stars 10k forks source link

8.0 BlazorWebAppOidc Roles claim not being respected on [Authorize(Roles="asdfdsf")] Attributte #55314

Closed travaille-dev closed 5 months ago

travaille-dev commented 6 months ago

Is there an existing issue for this?

Describe the bug

I'm working through the Blazor 8 OIDC web app example and I've ran into a snag where I'm trying to select what roles have access to specific pages.

I'm able to access and view my claims (including roles) with the basic [Authorize] attribute, but when I specify [Authorize(Roles="Admin")] I receive an access denied redirection.

I've updated the UserInfo Class to the following to get the role claim added into what's stored clientside.

using System.Security.Claims;

namespace BlazorWebAppOidc.Client;

// Add properties to this class and update the server and client AuthenticationStateProviders
// to expose more information about the authenticated user to the client.
public sealed class UserInfo
{
    public required string Email { get; init; }
    public required string Name { get; init; }

    public required string Roles { get; init; }

    public required string UserId { get; init; }

    private const string UserIdClaimType = "preferred_username";
    public const string NameClaimType = "name";
    private const string RoleClaimType = "roles";
    private const string CustomClaimType = "userid";

    public static UserInfo FromClaimsPrincipal(ClaimsPrincipal principal) =>
        new()
        {
            Email = GetRequiredClaim(principal, UserIdClaimType),
            Name = GetRequiredClaim(principal, NameClaimType),
            Roles = GetRequiredClaim(principal, RoleClaimType),
            UserId = (GetRequiredClaim(principal, UserIdClaimType).Split("@")[0])
        };

    public ClaimsPrincipal ToClaimsPrincipal() =>
        new(new ClaimsIdentity(
            [new Claim(UserIdClaimType, Email), 
                new Claim(NameClaimType, Name), 
                new Claim(RoleClaimType, Roles),
                new Claim(CustomClaimType,UserId)
            ],
            authenticationType: nameof(UserInfo),
            nameType: NameClaimType,
            roleType: null));

    private static string GetRequiredClaim(ClaimsPrincipal principal, string claimType) =>
        principal.FindFirst(claimType)?.Value ??
        throw new InvalidOperationException($"Could not find required '{claimType}' claim.");
}

And I've modified the UserClaims PageComponent to the following

@page "/user-claims"
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Roles = "Admin")]

<PageTitle>User Claims</PageTitle>

<h1>User Claims</h1>

@if (_claims.Any())
{
    <ul>
        @foreach (var claim in _claims)
        {
            <li><b>@claim.Type:</b> @claim.Value</li>
        }
    </ul>
}

@code {
    private IEnumerable<Claim> _claims = Enumerable.Empty<Claim>();

    [CascadingParameter]
    private Task<AuthenticationState>? AuthState { get; set; }

    protected override async Task OnInitializedAsync()
    {
        if (AuthState == null)
        {
            return;
        }

        var authState = await AuthState;
        _claims = authState.User.Claims;
    }
}

Expected Behavior

I expect to be able to be able to see the user-claims page when I specify my role in the [Authorize(Roles="")] Attribute

Steps To Reproduce

For this I'm using an Entra App Registration + Enterprise application in order to set up an Admin role and assign my userid to the Admin role.

Those are the prerequisites for getting started, but other than that filling out your clientid/secret/authority should be the same.

I've referenced above the only deviations from the templated code.

Exceptions (if any)

No response

.NET Version

8.0.201

Anything else?

ClaimsView
travaille-dev commented 6 months ago

Hey @halter73 let me know if there's any more information that I can provide to help out.

halter73 commented 6 months ago

If you change roleType: null to roleType: RoleClaimType in your ToClaimsPrincipal() method, [Authorize(Roles = "Admin")] should be respected as long as the user is only in one role.

However, Entra ID can return multiple roles claims, so UserInfo.Roles should probably be updated from a string to a string[]. If you do that, FromClaimsPrincipal() should be updated so Roles = GetRequiredClaim(principal, RoleClaimType), becomes something like Roles = principal.FindAll(RoleClaimType).Select(c => c.Value),. And ToClaimsPrincipal() should be updated to .Concat(Roles.Select(role => new Claim(RoleClaimType, role))) instead of always including a single roles Claim.

Does this give you enough information to resolve your issue?

travaille-dev commented 5 months ago

I've tested out the case of multiple roles and still receive the same invalid error when setting the [Authorize(Roles="Admin") attribute.

Here's my UserInfo

using System.Security.Claims;

namespace RNR.Client;

// Add properties to this class and update the server and client AuthenticationStateProviders
// to expose more information about the authenticated user to the client.
public sealed class UserInfo
{
    public required string Email { get; init; }
    public required string Name { get; init; }

    public required string[] Roles { get; init; }

    public required string UserId { get; init; }

    private const string UserIdClaimType = "preferred_username";
    public const string NameClaimType = "name";
    private const string RoleClaimType = "roles";
    private const string CustomClaimType = "userid";

    public static UserInfo FromClaimsPrincipal(ClaimsPrincipal principal) =>
        new()
        {
            Email = GetRequiredClaim(principal, UserIdClaimType),
            Name = GetRequiredClaim(principal, NameClaimType),
            Roles = principal.FindAll(RoleClaimType).Select(c => c.Value).ToArray(),
            UserId = (GetRequiredClaim(principal, UserIdClaimType).Split("@")[0])
        };

    public ClaimsPrincipal ToClaimsPrincipal()
    {
        return new ClaimsPrincipal(new ClaimsIdentity(
            Roles.Select(role => new Claim(RoleClaimType, role))
                .Concat(new[]
                {
                    new Claim(UserIdClaimType, Email),
                    new Claim(NameClaimType, Name),
                    new Claim(CustomClaimType, UserId)
                }),
            authenticationType: nameof(UserInfo),
            nameType: NameClaimType,
            roleType: RoleClaimType));
    }

    private static string GetRequiredClaim(ClaimsPrincipal principal, string claimType) =>
        principal.FindFirst(claimType)?.Value ??
        throw new InvalidOperationException($"Could not find required '{claimType}' claim.");
}
travaille-dev commented 5 months ago

I believe I found something interesting. I added the following to my user-claims page

@attribute [Authorize]

<PageTitle>User Claims</PageTitle>
<AuthorizeView Roles="Test">
    <Authorized>
        Neat
    </Authorized>
    <NotAuthorized>
        Not Neat
    </NotAuthorized>
</AuthorizeView>

So when I load the page, initially it loads as "Not Neat" and then refreshes into neat.

I am using global clientside interactivity, but it looks like the initial render of the page isn't actually using the FromClaimsPrincipal function before evaluating.

Screenshot 2024-05-02 at 9 51 12 AM Screenshot 2024-05-02 at 9 51 15 AM
travaille-dev commented 5 months ago

Well I think I got it now. Just had to update the oidc options in my base project Program.cs file to use the RoleClaimType as roles instead of role

oidcOptions.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Name;
oidcOptions.TokenValidationParameters.RoleClaimType = "roles";
mkArtakMSFT commented 5 months ago

Glad that you've figured this out, @travaille-dev. Closing, as no further action is pending for us on this one.