Microsoft Entra (ME-ID) groups, Administrator Roles, and App Roles

Note

This isn't the latest version of this article. For the current release, see the ASP.NET Core 8.0 version of this article.

This article explains how to configure Blazor WebAssembly to use Microsoft Entra ID groups and roles.

Microsoft Entra (ME-ID) provides several authorization approaches that can be combined with ASP.NET Core Identity:

  • Groups
    • Security
    • Microsoft 365
    • Distribution
  • Roles
    • ME-ID Administrator Roles
    • App Roles

The guidance in this article applies to the Blazor WebAssembly ME-ID deployment scenarios described in the following topics:

The article's guidance provides instructions for client and server apps:

  • CLIENT: Standalone Blazor WebAssembly apps.
  • SERVER: ASP.NET Core server API/web API apps. You can ignore the SERVER guidance throughout the article for a standalone Blazor WebAssembly app.
  • CLIENT: Standalone Blazor WebAssembly apps or the Client app of a hosted Blazor solution.
  • SERVER: ASP.NET Core server API/web API apps or the Server app of a hosted Blazor solution. You can ignore the SERVER guidance throughout the article for a standalone Blazor WebAssembly app.

The examples in this article take advantage of new .NET/C# features. When using the examples with .NET 7 or earlier, minor modifications are required. However, the text and code examples that pertain to interacting with ME-ID and Microsoft Graph are the same for all versions of ASP.NET Core.

Prerequisite

The guidance in this article implements the Microsoft Graph API per the Graph SDK guidance in Use Graph API with ASP.NET Core Blazor WebAssembly. Follow the Graph SDK implementation guidance to configure the app and test it to confirm that the app can obtain Graph API data for a test user account. Additionally, see the Graph API article's security article cross-links to review Microsoft Graph security concepts.

When testing with the Graph SDK locally, we recommend using a new in-private/incognito browser session for each test to prevent lingering cookies from interfering with tests. For more information, see Secure an ASP.NET Core Blazor WebAssembly standalone app with Microsoft Entra ID.

Scopes

To permit Microsoft Graph API calls for user profile, role assignment, and group membership data:

  • A CLIENT app is configured with the User.Read scope (https://graph.microsoft.com/User.Read) in the Azure portal.
  • A SERVER app is configured with the GroupMember.Read.All scope (https://graph.microsoft.com/GroupMember.Read.All) in the Azure portal.

The preceding scopes are required in addition to the scopes required in ME-ID deployment scenarios described by the topics listed earlier (Standalone with Microsoft Accounts or Standalone with ME-ID).

The preceding scopes are required in addition to the scopes required in ME-ID deployment scenarios described by the topics listed earlier (Standalone with Microsoft Accounts, Standalone with ME-ID, and Hosted with ME-ID).

For more information, see the Microsoft Graph permissions reference.

Note

The words "permission" and "scope" are used interchangeably in the Azure portal and in various Microsoft and external documentation sets. This article uses the word "scope" throughout for the permissions assigned to an app in the Azure portal.

Group Membership Claims attribute

In the app's manifest in the Azure portal for CLIENT and SERVER apps, set the groupMembershipClaims attribute to All. A value of All results in ME-ID sending all of the security groups, distribution groups, and roles of the signed-in user in the well-known IDs claim (wids):

  1. Open the app's Azure portal registration.
  2. Select Manage > Manifest in the sidebar.
  3. Find the groupMembershipClaims attribute.
  4. Set the value to All ("groupMembershipClaims": "All").
  5. Select the Save button if you made changes.

Custom user account

Assign users to ME-ID security groups and ME-ID Administrator Roles in the Azure portal.

The examples in this article:

  • Assume that a user is assigned to the ME-ID Billing Administrator role in the Azure portal ME-ID tenant for authorization to access server API data.
  • Use authorization policies to control access within the CLIENT and SERVER apps.

In the CLIENT app, extend RemoteUserAccount to include properties for:

CustomUserAccount.cs:

using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

namespace BlazorSample;

public class CustomUserAccount : RemoteUserAccount
{
    [JsonPropertyName("roles")]
    public List<string>? Roles { get; set; }

    [JsonPropertyName("wids")]
    public List<string>? Wids { get; set; }

    [JsonPropertyName("oid")]
    public string? Oid { get; set; }
}

Add a package reference to the CLIENT app for Microsoft.Graph.

Note

For guidance on adding packages to .NET apps, see the articles under Install and manage packages at Package consumption workflow (NuGet documentation). Confirm correct package versions at NuGet.org.

Add the Graph SDK utility classes and configuration in the Graph SDK guidance of the Use Graph API with ASP.NET Core Blazor WebAssembly article. Specify the User.Read scope for the access token as the article shows in its example wwwroot/appsettings.json file.

Add the following custom user account factory to the CLIENT app. The custom user factory is used to establish:

  • App Role claims (appRole) (covered in the App Roles section).
  • ME-ID Administrator Role claims (directoryRole).
  • Example user profile data claims for the user's mobile phone number (mobilePhone) and office location (officeLocation).
  • ME-ID Group claims (directoryGroup).
  • An ILogger (logger) for convenience in case you wish to log information or errors.

CustomAccountFactory.cs:

The following example assumes that the project's app settings file includes an entry for the base URL:

{
  "MicrosoftGraph": {
    "BaseUrl": "https://graph.microsoft.com/{VERSION}",
    ...
  }
}

In the preceding example, the {VERSION} placeholder is the version of the MS Graph API (for example: v1.0).

using System.Security.Claims;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using Microsoft.Graph;
using Microsoft.Kiota.Abstractions.Authentication;

namespace BlazorSample;

public class CustomAccountFactory(IAccessTokenProviderAccessor accessor,
        IServiceProvider serviceProvider,
        ILogger<CustomAccountFactory> logger,
        IConfiguration config)
    : AccountClaimsPrincipalFactory<CustomUserAccount>(accessor)
{
    private readonly ILogger<CustomAccountFactory> logger = logger;
    private readonly IServiceProvider serviceProvider = serviceProvider;
    private readonly string? baseUrl = 
        config.GetSection("MicrosoftGraph")["BaseUrl"];

    public override async ValueTask<ClaimsPrincipal> CreateUserAsync(
        CustomUserAccount account,
        RemoteAuthenticationUserOptions options)
    {
        var initialUser = await base.CreateUserAsync(account, options);

        if (initialUser.Identity is not null &&
            initialUser.Identity.IsAuthenticated)
        {
            var userIdentity = initialUser.Identity as ClaimsIdentity;

            if (userIdentity is not null && !string.IsNullOrEmpty(baseUrl))
            {
                account?.Roles?.ForEach((role) =>
                {
                    userIdentity.AddClaim(new Claim("appRole", role));
                });

                account?.Wids?.ForEach((wid) =>
                {
                    userIdentity.AddClaim(new Claim("directoryRole", wid));
                });

                try
                {
                    var client = new GraphServiceClient(
                        new HttpClient(),
                        serviceProvider
                            .GetRequiredService<IAuthenticationProvider>(),
                        baseUrl);

                    var user = await client.Me.GetAsync();

                    if (user is not null)
                    {
                        userIdentity.AddClaim(new Claim("mobilephone",
                            user.MobilePhone ?? "(000) 000-0000"));
                        userIdentity.AddClaim(new Claim("officelocation",
                            user.OfficeLocation ?? "Not set"));
                    }

                    var requestMemberOf = client.Users[account?.Oid].MemberOf;
                    var memberships = await requestMemberOf.Request().GetAsync();

                    if (memberships is not null)
                    {
                        foreach (var entry in memberships)
                        {
                            if (entry.ODataType == "#microsoft.graph.group")
                            {
                                userIdentity.AddClaim(
                                    new Claim("directoryGroup", entry.Id));
                            }
                        }
                    }
                }
                catch (AccessTokenNotAvailableException exception)
                {
                    exception.Redirect();
                }
            }
        }

        return initialUser;
    }
}
using System.Security.Claims;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using Microsoft.Graph;

namespace BlazorSample;

public class CustomAccountFactory(IAccessTokenProviderAccessor accessor,
        IServiceProvider serviceProvider,
        ILogger<CustomAccountFactory> logger)
    : AccountClaimsPrincipalFactory<CustomUserAccount>(accessor)
{
    private readonly ILogger<CustomAccountFactory> logger = logger;
    private readonly IServiceProvider serviceProvider = serviceProvider;

    public override async ValueTask<ClaimsPrincipal> CreateUserAsync(
        CustomUserAccount account,
        RemoteAuthenticationUserOptions options)
    {
        var initialUser = await base.CreateUserAsync(account, options);

        if (initialUser.Identity is not null &&
            initialUser.Identity.IsAuthenticated)
        {
            var userIdentity = initialUser.Identity as ClaimsIdentity;

            if (userIdentity is not null)
            {
                account?.Roles?.ForEach((role) =>
                {
                    userIdentity.AddClaim(new Claim("appRole", role));
                });

                account?.Wids?.ForEach((wid) =>
                {
                    userIdentity.AddClaim(new Claim("directoryRole", wid));
                });

                try
                {
                    var client = ActivatorUtilities
                        .CreateInstance<GraphServiceClient>(serviceProvider);
                    var request = client.Me.Request();
                    var user = await request.GetAsync();

                    if (user is not null)
                    {
                        userIdentity.AddClaim(new Claim("mobilephone",
                            user.MobilePhone ?? "(000) 000-0000"));
                        userIdentity.AddClaim(new Claim("officelocation",
                            user.OfficeLocation ?? "Not set"));
                    }

                    var requestMemberOf = client.Users[account?.Oid].MemberOf;
                    var memberships = await requestMemberOf.Request().GetAsync();

                    if (memberships is not null)
                    {
                        foreach (var entry in memberships)
                        {
                            if (entry.ODataType == "#microsoft.graph.group")
                            {
                                userIdentity.AddClaim(
                                    new Claim("directoryGroup", entry.Id));
                            }
                        }
                    }
                }
                catch (AccessTokenNotAvailableException exception)
                {
                    exception.Redirect();
                }
            }
        }

        return initialUser;
    }
}

The preceding code doesn't include transitive memberships. If the app requires direct and transitive group membership claims, replace the MemberOf property (IUserMemberOfCollectionWithReferencesRequestBuilder) with TransitiveMemberOf (IUserTransitiveMemberOfCollectionWithReferencesRequestBuilder).

The preceding code ignores group membership claims (groups) that are ME-ID Administrator Roles (#microsoft.graph.directoryRole type) because the GUID values returned by the Microsoft identity platform are ME-ID Administrator Role entity IDs and not Role Template IDs. Entity IDs aren't stable across tenants in Microsoft identity platform and shouldn't be used to create authorization policies for users in apps. Always use Role Template IDs for ME-ID Administrator Roles provided by wids claims.

In the CLIENT app, configure the MSAL authentication to use the custom user account factory.

Confirm that the Program file uses the Microsoft.AspNetCore.Components.WebAssembly.Authentication namespace:

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

Update the AddMsalAuthentication call to the following. Note that the Blazor framework's RemoteUserAccount is replaced by the app's CustomUserAccount for the MSAL authentication and account claims principal factory:

builder.Services.AddMsalAuthentication<RemoteAuthenticationState,
    CustomUserAccount>(options =>
    {
        builder.Configuration.Bind("AzureAd",
            options.ProviderOptions.Authentication);
    })
    .AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, CustomUserAccount,
        CustomAccountFactory>();

Confirm the presence of the Graph SDK code described by the Use Graph API with ASP.NET Core Blazor WebAssembly article and that the wwwroot/appsettings.json configuration is correct per the Graph SDK guidance:

var baseUrl = string.Join("/", 
    builder.Configuration.GetSection("MicrosoftGraph")["BaseUrl"], 
    builder.Configuration.GetSection("MicrosoftGraph")["Version"]);
var scopes = builder.Configuration.GetSection("MicrosoftGraph:Scopes")
    .Get<List<string>>();

builder.Services.AddGraphClient(baseUrl, scopes);

wwwroot/appsettings.json:

{
  "MicrosoftGraph": {
    "BaseUrl": "https://graph.microsoft.com",
    "Version: "v1.0",
    "Scopes": [
      "user.read"
    ]
  }
}

Authorization configuration

In the CLIENT app, create a policy for each App Role, ME-ID Administrator Role, or security group in the Program file. The following example creates a policy for the ME-ID Billing Administrator role:

builder.Services.AddAuthorizationCore(options =>
{
    options.AddPolicy("BillingAdministrator", policy => 
        policy.RequireClaim("directoryRole", 
            "b0f54661-2d74-4c50-afa3-1ec803f12efe"));
});

For the complete list of IDs for ME-ID Administrator Roles, see Role template IDs in the Entra documentation. For more information on authorization policies, see Policy-based authorization in ASP.NET Core.

In the following examples, the CLIENT app uses the preceding policy to authorize the user.

The AuthorizeView component works with the policy:

<AuthorizeView Policy="BillingAdministrator">
    <Authorized>
        <p>
            The user is in the 'Billing Administrator' ME-ID Administrator Role
            and can see this content.
        </p>
    </Authorized>
    <NotAuthorized>
        <p>
            The user is NOT in the 'Billing Administrator' role and sees this
            content.
        </p>
    </NotAuthorized>
</AuthorizeView>

Access to an entire component can be based on the policy using an [Authorize] attribute directive (AuthorizeAttribute):

@page "/"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Policy = "BillingAdministrator")]

If the user isn't authorized, they're redirected to the ME-ID sign-in page.

A policy check can also be performed in code with procedural logic.

CheckPolicy.razor:

@page "/checkpolicy"
@using Microsoft.AspNetCore.Authorization
@inject IAuthorizationService AuthorizationService

<h1>Check Policy</h1>

<p>This component checks a policy in code.</p>

<button @onclick="CheckPolicy">Check 'BillingAdministrator' policy</button>

<p>Policy Message: @policyMessage</p>

@code {
    private string policyMessage = "Check hasn't been made yet.";

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

    private async Task CheckPolicy()
    {
        var user = (await authenticationStateTask).User;

        if ((await AuthorizationService.AuthorizeAsync(user, 
            "BillingAdministrator")).Succeeded)
        {
            policyMessage = "Yes! The 'BillingAdministrator' policy is met.";
        }
        else
        {
            policyMessage = "No! 'BillingAdministrator' policy is NOT met.";
        }
    }
}

Authorize server API/web API access

A SERVER API app can authorize users to access secure API endpoints with authorization policies for security groups, ME-ID Administrator Roles, and App Roles when an access token contains groups, wids, and role claims. The following example creates a policy for the ME-ID Billing Administrator role in the Program file using the wids (well-known IDs/Role Template IDs) claims:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("BillingAdministrator", policy => 
        policy.RequireClaim("wids", "b0f54661-2d74-4c50-afa3-1ec803f12efe"));
});

For the complete list of IDs for ME-ID Administrator Roles, see Role template IDs in the Azure documentation. For more information on authorization policies, see Policy-based authorization in ASP.NET Core.

Access to a controller in the SERVER app can be based on using an [Authorize] attribute with the name of the policy (API documentation: AuthorizeAttribute).

The following example limits access to billing data from the BillingDataController to Azure Billing Administrators with a policy name of BillingAdministrator:

using Microsoft.AspNetCore.Authorization;
[Authorize(Policy = "BillingAdministrator")]
[ApiController]
[Route("[controller]")]
public class BillingDataController : ControllerBase
{
    ...
}

For more information, see Policy-based authorization in ASP.NET Core.

App Roles

To configure the app in the Azure portal to provide App Roles membership claims, see Add app roles to your application and receive them in the token in the Entra documentation.

The following example assumes that the CLIENT and SERVER apps are configured with two roles, and the roles are assigned to a test user:

  • Admin
  • Developer

Note

When developing a client-server pair of standalone apps (a standalone Blazor WebAssembly app and an ASP.NET Core server API/web API app), the appRoles manifest property of both the client and the server Azure portal app registrations must include the same configured roles. After establishing the roles in the client app's manifest, copy them in their entirety to the server app's manifest. If you don't mirror the manifest appRoles between the client and server app registrations, role claims aren't established for authenticated users of the server API/web API, even if their access token has the correct entries in the role claims.

Note

When developing a hosted Blazor WebAssembly app or a client-server pair of standalone apps (a standalone Blazor WebAssembly app and an ASP.NET Core server API/web API app), the appRoles manifest property of both the client and the server Azure portal app registrations must include the same configured roles. After establishing the roles in the client app's manifest, copy them in their entirety to the server app's manifest. If you don't mirror the manifest appRoles between the client and server app registrations, role claims aren't established for authenticated users of the server API/web API, even if their access token has the correct entries in the role claims.

Although you can't assign roles to groups without an Microsoft Entra ID Premium account, you can assign roles to users and receive a role claim for users with a standard Azure account. The guidance in this section doesn't require an ME-ID Premium account.

If you have a Premium tier Azure account, Manage > App roles appears in the Azure portal app registration sidebar. Follow the guidance in Add app roles to your application and receive them in the token to configure the app's roles.

If you don't have a Premium tier Azure account, edit the app's manifest in the Azure portal. Follow the guidance in Application roles: Implementation to establish the app's roles manually in the appRoles entry of the manifest file. Save the changes to the file.

The following is an example appRoles entry that creates Admin and Developer roles. These example roles are used later in this section's example at the component level to implement access restrictions:

"appRoles": [
  {
    "allowedMemberTypes": [
      "User"
    ],
    "description": "Administrators manage developers.",
    "displayName": "Admin",
    "id": "584e483a-7101-404b-9bb1-83bf9463e335",
    "isEnabled": true,
    "lang": null,
    "origin": "Application",
    "value": "Admin"
  },
  {
    "allowedMemberTypes": [
      "User"
    ],
    "description": "Developers write code.",
    "displayName": "Developer",
    "id": "82770d35-2a93-4182-b3f5-3d7bfe9dfe46",
    "isEnabled": true,
    "lang": null,
    "origin": "Application",
    "value": "Developer"
  }
],

To assign a role to a user (or group if you have a Premium tier Azure account):

  1. Navigate to Enterprise applications in the ME-ID area of the Azure portal.
  2. Select the app. Select Manage > Users and groups from the sidebar.
  3. Select the checkbox for one or more user accounts.
  4. From the menu above the list of users, select Edit assignment.
  5. For the Select a role entry, select None selected.
  6. Choose a role from the list and use the Select button to select it.
  7. Use the Assign button at the bottom of the screen to assign the role.

Multiple roles are assigned in the Azure portal by re-adding a user for each additional role assignment. Use the Add user/group button at the top of the list of users to re-add a user. Use the preceding steps to assign another role to the user. You can repeat this process as many times as needed to add additional roles to a user (or group).

The CustomAccountFactory shown in the Custom user account section is set up to act on a role claim with a JSON array value. Add and register the CustomAccountFactory in the CLIENT app as shown in the Custom user account section. There's no need to provide code to remove the original role claim because it's automatically removed by the framework.

In the Program file of a CLIENT app, specify the claim named "appRole" as the role claim for ClaimsPrincipal.IsInRole checks:

builder.Services.AddMsalAuthentication(options =>
{
    ...

    options.UserOptions.RoleClaim = "appRole";
});

Note

If you prefer to use the directoryRoles claim (ADD Administrator Roles), assign "directoryRoles" to the RemoteAuthenticationUserOptions.RoleClaim.

In the Program file of a SERVER app, specify the claim named "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" as the role claim for ClaimsPrincipal.IsInRole checks:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(options =>
    {
        Configuration.Bind("AzureAd", options);
        options.TokenValidationParameters.RoleClaimType = 
            "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
    },
    options => { Configuration.Bind("AzureAd", options); });

Note

When a single authentication scheme is registered, the authentication scheme is automatically used as the app's default scheme, and it isn't necessary to state the scheme to AddAuthentication or via AuthenticationOptions. For more information, see Overview of ASP.NET Core Authentication and the ASP.NET Core announcement (aspnet/Announcements #490).

Note

If you prefer to use the wids claim (ADD Administrator Roles), assign "wids" to the TokenValidationParameters.RoleClaimType.

After you've completed the preceding steps to create and assign roles to users (or groups if you have a Premium tier Azure account) and implemented the CustomAccountFactory with the Graph SDK, as explained earlier in this article and in Use Graph API with ASP.NET Core Blazor WebAssembly, you should see an appRole claim for each assigned role that a signed-in user is assigned (or roles assigned to groups that they are members of). Run the app with a test user to confirm the claim(s) are present as expected. When testing with the Graph SDK locally, we recommend using a new in-private/incognito browser session for each test to prevent lingering cookies from interfering with tests. For more information, see Secure an ASP.NET Core Blazor WebAssembly standalone app with Microsoft Entra ID.

Component authorization approaches are functional at this point. Any of the authorization mechanisms in components of the CLIENT app can use the Admin role to authorize the user:

Multiple role tests are supported:

  • Require that the user be in either the Admin or Developer role with the AuthorizeView component:

    <AuthorizeView Roles="Admin, Developer">
        ...
    </AuthorizeView>
    
  • Require that the user be in both the Admin and Developer roles with the AuthorizeView component:

    <AuthorizeView Roles="Admin">
        <AuthorizeView Roles="Developer" Context="innerContext">
            ...
        </AuthorizeView>
    </AuthorizeView>
    

    For more information on the Context for the inner AuthorizeView, see ASP.NET Core Blazor authentication and authorization.

  • Require that the user be in either the Admin or Developer role with the [Authorize] attribute:

    @attribute [Authorize(Roles = "Admin, Developer")]
    
  • Require that the user be in both the Admin and Developer roles with the [Authorize] attribute:

    @attribute [Authorize(Roles = "Admin")]
    @attribute [Authorize(Roles = "Developer")]
    
  • Require that the user be in either the Admin or Developer role with procedural code:

    @code {
        private async Task DoSomething()
        {
            var authState = await AuthenticationStateProvider
                .GetAuthenticationStateAsync();
            var user = authState.User;
    
            if (user.IsInRole("Admin") || user.IsInRole("Developer"))
            {
                ...
            }
            else
            {
                ...
            }
        }
    }
    
  • Require that the user be in both the Admin and Developer roles with procedural code by changing the conditional OR (||) to a conditional AND (&&) in the preceding example:

    if (user.IsInRole("Admin") && user.IsInRole("Developer"))
    

Any of the authorization mechanisms in controllers of the SERVER app can use the Admin role to authorize the user:

Multiple role tests are supported:

  • Require that the user be in either the Admin or Developer role with the [Authorize] attribute:

    [Authorize(Roles = "Admin, Developer")]
    
  • Require that the user be in both the Admin and Developer roles with the [Authorize] attribute:

    [Authorize(Roles = "Admin")]
    [Authorize(Roles = "Developer")]
    
  • Require that the user be in either the Admin or Developer role with procedural code:

    static readonly string[] scopeRequiredByApi = new string[] { "API.Access" };
    
    ...
    
    [HttpGet]
    public IEnumerable<ReturnType> Get()
    {
        HttpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi);
    
        if (User.IsInRole("Admin") || User.IsInRole("Developer"))
        {
            ...
        }
        else
        {
            ...
        }
    
        return ...
    }
    
  • Require that the user be in both the Admin and Developer roles with procedural code by changing the conditional OR (||) to a conditional AND (&&) in the preceding example:

    if (User.IsInRole("Admin") && User.IsInRole("Developer"))
    

Because .NET string comparisons are case-sensitive by default, matching role names is also case-sensitive. For example, Admin (uppercase A) is not treated as the same role as admin (lowercase a).

Pascal case is typically used for role names (for example, BillingAdministrator), but the use of Pascal case isn't a strict requirement. Different casing schemes, such as camel case, kebab case, and snake case, are permitted. Using spaces in role names is also unusual but permitted. For example, billing administrator is an unusual role name format in .NET apps but valid.

Additional resources