aspnetcore/blazor/security/webassembly/microsoft-entra-id-groups-and-roles-net-5-to-7.md
[!IMPORTANT] This isn't the latest version of this article. For the current ASP.NET Core release, see the latest version of xref:blazor/security/webassembly/meid-groups-roles.
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:
The guidance in this article applies to the Blazor WebAssembly ME-ID deployment scenarios described in the following articles:
The article's guidance provides instructions for client and server apps:
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.
The guidance in this article implements the Microsoft Graph API per the Graph SDK guidance in xref:blazor/security/webassembly/graph-api?pivots=graph-sdk. 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 xref:blazor/security/webassembly/standalone-with-microsoft-entra-id#troubleshoot.
To permit Microsoft Graph API calls for user profile, role assignment, and group membership data:
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.GroupMember.Read.All scope (https://graph.microsoft.com/GroupMember.Read.All) in the Azure portal because access is for the app to obtain information about group membership, not based on individual user authorization to access data about group members.The preceding scopes are required in addition to the scopes required in ME-ID deployment scenarios described by the articles listed earlier (Standalone with Microsoft Accounts, Standalone with ME-ID, and Hosted with ME-ID).
For more information, see Overview of permissions and consent in the Microsoft identity platform and Overview of Microsoft Graph permissions.
Permissions and scopes mean the same thing and are used interchangeably in security documentation and the Azure portal. Unless the text is referring to the Azure portal, this article uses scope/scopes when referring to Graph permissions.
Scopes are case insensitive, so User.Read is the same as user.read. Feel free to use either format, but we recommend a consistent choice across application code.
:::zone pivot="graph-sdk-5"
In the app's manifest in the Azure portal for CLIENT and SERVER apps, set the groupMembershipClaims attribute to DirectoryRole. A value of DirectoryRole results in ME-ID sending all of the roles of the signed-in user in the well-known IDs claim (wids):
groupMembershipClaims attribute.DirectoryRole ("groupMembershipClaims": "DirectoryRole").:::zone-end
:::zone pivot="graph-sdk-4"
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):
groupMembershipClaims attribute.All ("groupMembershipClaims": "All").:::zone-end
Assign users to ME-ID security groups and ME-ID Administrator Roles in the Azure portal.
The examples in this article:
In the CLIENT app, extend xref:Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteUserAccount to include properties for:
Roles: ME-ID App Roles array (covered in the App Roles section)Wids: ME-ID Administrator Roles in well-known IDs claim (wids)Oid: Immutable object identifier claim (oid) (uniquely identifies a user within and across tenants)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.
Add the Graph SDK utility classes and configuration in the Graph SDK guidance of the xref:blazor/security/webassembly/graph-api?pivots=graph-sdk 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:
appRole) (covered in the App Roles section).directoryRole).mobilePhone) and office location (officeLocation).directoryGroup).logger) for convenience in case you wish to log information or errors.CustomAccountFactory.cs:
:::zone pivot="graph-sdk-5"
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()
: AccountClaimsPrincipalFactory<CustomUserAccount>
{
private readonly ILogger<CustomAccountFactory> logger;
private readonly IServiceProvider serviceProvider;
private readonly string? baseUrl =
string.Join("/",
config.GetSection("MicrosoftGraph")["BaseUrl"] ??
"https://graph.microsoft.com",
config.GetSection("MicrosoftGraph")["Version"] ??
"v1.0");
public CustomAccountFactory(IAccessTokenProviderAccessor accessor,
IServiceProvider serviceProvider,
ILogger<CustomAccountFactory> logger)
: base(accessor)
{
this.serviceProvider = serviceProvider;
this.logger = logger;
}
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 graphGroups = await requestMemberOf.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;
}
}
:::zone-end
:::zone pivot="graph-sdk-4"
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()
: AccountClaimsPrincipalFactory<CustomUserAccount>(accessor)
{
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 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;
}
}
:::zone-end
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).
:::zone pivot="graph-sdk-4"
The preceding code ignores group membership claims (groups) that are ME-ID Administrator Roles (#microsoft.graph.directoryRole type) because the GUID values returned by ME-ID are Administrator Role entity IDs and not role Template IDs. Entity IDs aren't stable across tenants and shouldn't be used to create authorization policies for users in apps. Always use Template IDs for ME-ID Administrator Roles provided by wids claims.
:::zone-end
The wids claim (and thus directoryRole claim) with a value of b79fbf4d-3ef9-4689-8143-76b194e85509 exists for non-guest accounts of the tenant. It doesn't refer to an ME-ID Administrator Role Template ID.
In the CLIENT app, configure the MSAL authentication to use the custom user account factory.
Confirm that the Program file uses the xref:Microsoft.AspNetCore.Components.WebAssembly.Authentication?displayProperty=fullName namespace:
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
Update the xref:Microsoft.Extensions.DependencyInjection.MsalWebAssemblyServiceCollectionExtensions.AddMsalAuthentication%2A call to the following. Note that the Blazor framework's xref:Microsoft.AspNetCore.Components.WebAssembly.Authentication.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>();
:::zone pivot="graph-sdk-5"
Confirm the presence of the Graph SDK code described by the xref:blazor/security/webassembly/graph-api?pivots=graph-sdk article and that the wwwroot/appsettings.json configuration is correct per the Graph SDK guidance:
var baseUrl =
string.Join("/",
builder.Configuration.GetSection("MicrosoftGraph")["BaseUrl"] ??
"https://graph.microsoft.com",
builder.Configuration.GetSection("MicrosoftGraph")["Version"] ??
"v1.0");
var scopes = builder.Configuration.GetSection("MicrosoftGraph:Scopes")
.Get<List<string>>() ?? [ "user.read" ];
builder.Services.AddGraphClient(baseUrl, scopes);
wwwroot/appsettings.json:
{
"MicrosoftGraph": {
"BaseUrl": "https://graph.microsoft.com",
"Version": "v1.0",
"Scopes": [
"user.read"
]
}
}
:::zone-end
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 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 for ME-ID Administrator Roles, see Role template IDs in the Entra documentation. For more information on authorization policies, see xref:security/authorization/policies.
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 (xref:Microsoft.AspNetCore.Authorization.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 app roles, where the GUID used for the policy is set in the appRoles element of the app's manifest in the Azure portal, and security groups, where the GUID used for the policy matches the group Object Id in the Azure portal Groups pane.
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 xref:security/authorization/policies.
Access to a controller in the SERVER app can be based on using an [Authorize] attribute with the name of the policy (API documentation: xref:Microsoft.AspNetCore.Authorization.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 xref:security/authorization/policies.
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:
AdminDeveloper[!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
appRolesmanifest 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 manifestappRolesbetween 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 a Microsoft Entra 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.
When working with the default directory, follow the guidance in Add app roles to your application and receive them in the token to configure and assign 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 in this section's example at the component level to implement access restrictions:
"appRoles": [
{
"allowedMemberTypes": [
"User"
],
"description": "Administrators manage developers.",
"displayName": "Admin",
"id": "{ADMIN GUID}",
"isEnabled": true,
"lang": null,
"origin": "Application",
"value": "Admin"
},
{
"allowedMemberTypes": [
"User"
],
"description": "Developers write code.",
"displayName": "Developer",
"id": "{DEVELOPER GUID}",
"isEnabled": true,
"lang": null,
"origin": "Application",
"value": "Developer"
}
],
For the {ADMIN GUID} and {DEVELOPER GUID} placeholders in the preceding example, you can generate GUIDs with an online GUID generator (Google search result for "guid generator").
To assign a role to a user (or group if you have a Premium tier Azure account):
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 xref:System.Security.Claims.ClaimsPrincipal.IsInRole%2A?displayProperty=nameWithType checks:
builder.Services.AddMsalAuthentication(options =>
{
...
options.UserOptions.RoleClaim = "appRole";
});
[!NOTE] If you prefer to use the
directoryRolesclaim (ADD Administrator Roles), assign "directoryRoles" to the xref:Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationUserOptions.RoleClaim?displayProperty=nameWithType.
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 xref:System.Security.Claims.ClaimsPrincipal.IsInRole%2A?displayProperty=nameWithType 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] If you prefer to use the
widsclaim (ADD Administrator Roles), assign "wids" to the xref:Microsoft.IdentityModel.Tokens.TokenValidationParameters.RoleClaimType?displayProperty=nameWithType.
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 xref:blazor/security/webassembly/graph-api?pivots=graph-sdk, 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 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 xref:blazor/security/webassembly/standalone-with-microsoft-entra-id#troubleshoot.
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:
<AuthorizeView Roles="Admin">
[Authorize] attribute directive (xref:Microsoft.AspNetCore.Authorization.AuthorizeAttribute)
@attribute [Authorize(Roles = "Admin")]
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.IsInRole("Admin")) { ... }
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 xref:Microsoft.AspNetCore.Components.Authorization.AuthorizeView, see xref:blazor/security/index#role-based-and-policy-based-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:
[Authorize] attribute directive (xref:Microsoft.AspNetCore.Authorization.AuthorizeAttribute)
[Authorize(Roles = "Admin")]
if (User.IsInRole("Admin")) { ... }
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, 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.
groupMembershipClaims attribute (Entra documentation)