Azure Active Directory (AAD) groups, Administrator Roles, and App Roles

Azure Active Directory (AAD) provides several authorization approaches that can be combined with ASP.NET Core Identity:

  • Groups
    • Security
    • Microsoft 365
    • Distribution
  • Roles
    • AAD Administrator Roles
    • App Roles

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

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

  • CLIENT: Standalone Blazor WebAssembly apps or the Client app of a hosted Blazor solution.
  • SERVER: Standalone ASP.NET Core server API/web API apps or the Server app of a hosted Blazor solution.

Scopes

To permit Microsoft Graph API calls for user profile, role assignment, and group membership data, the CLIENT is configured with (https://graph.microsoft.com/User.Read) Graph API permission (scope) in the Azure portal.

A SERVER app that calls Graph API for role and group membership data is provided GroupMember.Read.All (https://graph.microsoft.com/GroupMember.Read.All) Graph API permission (scope) in the Azure portal.

These scopes are required in addition to the scopes required in AAD deployment scenarios described by the topics listed in the first section of this article.

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 the Azure portal for CLIENT and SERVER apps, set the groupMembershipClaims attribute to All. A value of All results in obtaining all of the security groups, distribution groups, and roles that the signed-in user is a member of.

  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.
  5. Select the Save button.
"groupMembershipClaims": "All",

Custom user account

Assign users to AAD security groups and AAD Administrator Roles in the Azure portal.

The examples in this article:

  • Assume that a user is assigned to the AAD Billing Administrator role in the Azure portal AAD 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:

Assign an empty array to each array property so that checking for null isn't required when these properties are used in foreach loops.

CustomUserAccount.cs:

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

public class CustomUserAccount : RemoteUserAccount
{
    [JsonPropertyName("roles")]
    public string[] Roles { get; set; } = Array.Empty<string>();

    [JsonPropertyName("wids")]
    public string[] Wids { get; set; } = Array.Empty<string>();

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

Add a package reference to the CLIENT app's project file for Microsoft.Graph.

Add the Graph SDK utility classes and configuration in the Graph SDK section of the Use Graph API with ASP.NET Core Blazor WebAssembly article. In the GraphClientExtensions class, specify the User.Read scope for the access token in the AuthenticateRequestAsync method:

var result = await TokenProvider.RequestAccessToken(
    new AccessTokenRequestOptions()
    {
        Scopes = new[] { "https://graph.microsoft.com/User.Read" }
    });

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)
  • AAD Administrator Role claims (directoryRole)
  • An example user profile data claim for the user's mobile phone number (mobilePhone)
  • AAD Group claims (directoryGroup)

CustomAccountFactory.cs:

using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Graph;

public class CustomAccountFactory
    : AccountClaimsPrincipalFactory<CustomUserAccount>
{
    private readonly ILogger<CustomAccountFactory> logger;
    private readonly IServiceProvider serviceProvider;

    public CustomAccountFactory(IAccessTokenProviderAccessor accessor,
        IServiceProvider serviceProvider,
        ILogger<CustomAccountFactory> logger)
        : base(accessor)
    {
        this.serviceProvider = serviceProvider;
        this.logger = logger;
    }
    public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
        CustomUserAccount account,
        RemoteAuthenticationUserOptions options)
    {
        var initialUser = await base.CreateUserAsync(account, options);

        if (initialUser.Identity.IsAuthenticated)
        {
            var userIdentity = (ClaimsIdentity)initialUser.Identity;

            foreach (var role in account.Roles)
            {
                userIdentity.AddClaim(new Claim("appRole", role));
            }

            foreach (var wid in account.Wids)
            {
                userIdentity.AddClaim(new Claim("directoryRole", wid));
            }

            try
            {
                var graphClient = ActivatorUtilities
                    .CreateInstance<GraphServiceClient>(serviceProvider);

                var requestMe = graphClient.Me.Request();
                var user = await requestMe.GetAsync();

                if (user != null)
                {
                    userIdentity.AddClaim(new Claim("mobilePhone",
                        user.MobilePhone));
                }

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

                if (memberships != null)
                {
                    foreach (var entry in memberships)
                    {
                        if (entry.ODataType == "#microsoft.graph.group")
                        {
                            userIdentity.AddClaim(
                                new Claim("directoryGroup", entry.Id));
                        }
                    }
                }
            }
            catch (ServiceException exception)
            {
                logger.LogError("Graph API service failure: {Message}",
                    exception.Message);
            }
        }

        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 AAD Administrator Roles (#microsoft.graph.directoryRole type) because the GUID values returned by the Microsoft Identity Platform 2.0 are AAD Administrator Role entity IDs and not Role Template IDs. Entity IDs aren't stable across tenants in Microsoft Identity Platform 2.0 and shouldn't be used to create authorization policies for users in apps. Always use Role Template IDs for AAD Administrator Roles provided by wids claims.

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

Program.cs:

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.Extensions.Configuration;

...

builder.Services.AddMsalAuthentication<RemoteAuthenticationState,
    CustomUserAccount>(options =>
{
    ...
})
.AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, CustomUserAccount,
    CustomAccountFactory>();

...

builder.Services.AddGraphClient();

Authorization configuration

In the CLIENT app, create a policy for each App Role, AAD Administrator Role, or security group in Program.Main. The following example creates a policy for the AAD 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 AAD Administrator Roles, see Role template IDs in the Azure 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' AAD 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 logged in, they're redirected to the AAD sign-in page and then back to the component after they sign in.

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

Pages/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 void 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, AAD Administrator Roles, and App Roles when an access token contains groups, wids, and http://schemas.microsoft.com/ws/2008/06/identity/claims/role claims. The following example creates a policy for the AAD Billing Administrator role in Startup.ConfigureServices using the wids (well-known IDs/Role Template IDs) claims:

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

For the complete list of IDs for AAD 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 How to: Add app roles in your application and receive them in the token in the Azure 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 hosted Blazor WebAssembly app or a client-server pair of standalone apps (a standalone Blazor WebAssembly app and a standalone 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 roles claims.

Note

Although you can't assign roles to groups without an Azure AD Premium account, you can assign roles to users and receive a roles claim for users with a standard Azure account. The guidance in this section doesn't require an AAD Premium account.

Multiple roles are assigned in the Azure portal by re-adding a user for each additional role assignment.

The CustomAccountFactory shown in the Custom user account section is set up to act on a roles 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 roles claim because it's automatically removed by the framework.

In Program.Main 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 Startup.ConfigureServices 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:

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

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

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">
            ...
        </AuthorizeView>
    </AuthorizeView>
    
  • 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"))
    

Additional resources