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

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

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

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

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

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.

Sample app

Access the sample app, named BlazorWebAssemblyEntraGroupsAndRoles, through the latest version folder from the repository's root with the following link. The sample is provided for .NET 8 or later. See the sample app's README file for steps on how to run the app.

The sample app includes a UserClaims component for displaying a user's claims. The UserData component displays the user's basic account properties.

View or download sample code (how to download)

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.

ME-ID app registration online tools

This article refers to the Azure portal throughout when prompting you to configure the app's ME-ID app registration, but the Microsoft Entra Admin Center is also a viable option for managing ME-ID app registrations. Either interface can be used, but the guidance in this article specifically covers gestures for the Azure portal.

Scopes

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.

To permit Microsoft Graph API calls for user profile, role assignment, and group membership data, the app is configured with the delegated User.Read scope (https://graph.microsoft.com/User.Read) in the Azure portal because access to read user data is determined by the scopes granted (delegated) to individual users. This scope is required in addition to the scopes required in ME-ID deployment scenarios described by the articles listed earlier (Standalone with Microsoft Accounts or Standalone with ME-ID).

Additional required scopes include:

  • Delegated RoleManagement.Read.Directory scope (https://graph.microsoft.com/RoleManagement.Read.Directory): Allows the app to read the role-based access control (RBAC) settings for your company's directory, on behalf of the signed-in user. This includes reading directory role templates, directory roles, and memberships. Directory role memberships are used to create directoryRole claims in the app for ME-ID built-in Administrator Roles. Admin consent is required.
  • Delegated AdministrativeUnit.Read.All scope (https://graph.microsoft.com/AdministrativeUnit.Read.All): Allows the app to read administrative units and administrative unit membership on behalf of the signed-in user. These memberships are used to create administrativeUnit claims in the app. Admin consent is required.

For more information, see Overview of permissions and consent in the Microsoft identity platform and Overview of Microsoft Graph permissions.

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 app.

Extend RemoteUserAccount to include properties for:

CustomUserAccount.cs:

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

namespace BlazorWebAssemblyEntraGroupsAndRoles;

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

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

Add a package reference to 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, RoleManagement.Read.Directory, and AdministrativeUnit.Read.All scopes for the access token as the article shows in its example wwwroot/appsettings.json file.

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

  • App Role claims (role) (covered in the App Roles section).

  • Example user profile data claims for the user's mobile phone number (mobilePhone) and office location (officeLocation).

  • ME-ID Administrator Role claims (directoryRole).

  • ME-ID Administrative Unit claims (administrativeUnit).

  • ME-ID Group claims (directoryGroup).

  • An ILogger (logger) for convenience in case you wish to log information or errors.

CustomAccountFactory.cs:

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 BlazorWebAssemblyEntraGroupsAndRoles;

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 = string.Join("/",
        config.GetSection("MicrosoftGraph")["BaseUrl"],
        config.GetSection("MicrosoftGraph")["Version"]);

    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.Oid is not null)
            {
                account?.Roles?.ForEach((role) =>
                {
                    userIdentity.AddClaim(new Claim("role", role));
                });

                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 memberOf = client.Users[account?.Oid].MemberOf;

                    var graphDirectoryRoles = await memberOf.GraphDirectoryRole.GetAsync();

                    if (graphDirectoryRoles?.Value is not null)
                    {
                        foreach (var entry in graphDirectoryRoles.Value)
                        {
                            if (entry.RoleTemplateId is not null)
                            {
                                userIdentity.AddClaim(
                                    new Claim("directoryRole", entry.RoleTemplateId));
                            }
                        }
                    }

                    var graphAdministrativeUnits = await memberOf.GraphAdministrativeUnit.GetAsync();

                    if (graphAdministrativeUnits?.Value is not null)
                    {
                        foreach (var entry in graphAdministrativeUnits.Value)
                        {
                            if (entry.Id is not null)
                            {
                                userIdentity.AddClaim(
                                    new Claim("administrativeUnit", entry.Id));
                            }
                        }
                    }

                    var graphGroups = await memberOf.GraphGroup.GetAsync();

                    if (graphGroups?.Value is not null)
                    {
                        foreach (var entry in graphGroups.Value)
                        {
                            if (entry.Id is not null)
                            {
                                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).
  • Sets GUID values in directoryRole claims are ME-ID Administrator Role Template IDs (Microsoft.Graph.Models.DirectoryRole.RoleTemplateId). Template IDs are stable identifiers for creating user authorization policies in apps, which is covered later in this article. Do not use entry.Id for directory role claim values, as they aren't stable across tenants.

Next, 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);
        options.UserOptions.RoleClaim = "role";
    })
    .AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, CustomUserAccount,
        CustomAccountFactory>();

Confirm the presence of the Graph SDK code in the Program file described by the Use Graph API with ASP.NET Core Blazor WebAssembly article:

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);

Important

Confirm in the app's registration in the Azure portal that the following permissions are granted:

  • User.Read
  • RoleManagement.Read.Directory (Requires admin consent)
  • AdministrativeUnit.Read.All (Requires admin consent)

Confirm that the wwwroot/appsettings.json configuration is correct per the Graph SDK guidance.

wwwroot/appsettings.json:

{
  "AzureAd": {
    "Authority": "https://login.microsoftonline.com/{TENANT ID}",
    "ClientId": "{CLIENT ID}",
    "ValidateAuthority": true
  },
  "MicrosoftGraph": {
    "BaseUrl": "https://graph.microsoft.com",
    "Version": "v1.0",
    "Scopes": [
      "User.Read",
      "RoleManagement.Read.Directory",
      "AdministrativeUnit.Read.All"
    ]
  }
}

Provide values for the following placeholders from the app's ME-ID registration in the Azure portal:

  • {TENANT ID}: The Directory (Tenant) Id GUID value.
  • {CLIENT ID}: The Application (Client) Id GUID value.

Authorization configuration

Create a policy for each App Role (by role name), ME-ID built-in Administrator Role (by Role Template Id/GUID), or security group (by Object Id/GUID) in the Program file. The following example creates a policy for the ME-ID built-in 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 (GUIDs) for ME-ID Administrator Roles, see Role template IDs in the ME-ID documentation. For an Azure security or O365 group ID (GUID), see the Object Id for the group in the Azure portal Groups pane of the app's registration. For more information on authorization policies, see Policy-based authorization in ASP.NET Core.

In the following examples, the 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.";
        }
    }
}

Using the preceding approaches, you can also create policy-based access for security groups, where the GUID used for the policy matches the

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 ME-ID documentation.

The following example assumes that the app is configured with two roles, and the roles are assigned to a test user:

  • Admin
  • Developer

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

Take either of the following approaches add app roles in ME-ID:

  • When working with the default directory, follow the guidance in Add app roles to your application and receive them in the token to create ME-ID roles.

  • If you aren't working with the default directory, edit the app's manifest in the Azure portal to establish the app's roles manually in the appRoles entry of the manifest file. The following is an example appRoles entry that creates Admin and Developer roles. These example roles are used later at the component level to implement access restrictions:

    Important

    The following approach is only recommended for apps that aren't registered in the Azure account's default directory. For apps registered in the default directory, see the preceding bullet of this list.

    "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 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, add or confirm the claim named "role" as the role claim for ClaimsPrincipal.IsInRole checks:

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

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

Note

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

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 role 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 claims 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 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"))
    

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