Using Azure security groups in ASP.NET Core with an Azure B2C Identity Provider

This article shows how to implement authorization in an ASP.NET Core application which uses Azure security groups for the user definitions and Azure B2C to authenticate. Microsoft Graph API is used to access the Azure group definitions for the signed in user. The client credentials flow is used to authorize the Graph API client with an application scope definition. This is not optimal, the delegated user flows would be better. By allowing applications rights for the defined scopes using Graph API, you are implicitly making the application an administrator of the tenant as well for the defined scopes.

Code: https://github.com/damienbod/azureb2c-fed-microsoft-entra-id

History

2024-01-02 Updated to .NET 8, Graph SDK 5

Blogs in this series

Two Azure AD security groups were created to demonstrate this feature with Azure B2C authentication. The users were added to the admin group and the user group as required. The ASP.NET Core application uses an ASP.NET Core Razor page which should only be used by admin users, i.e. people in the group. To validate this in the application, Microsoft Graph API is used to get groups for the signed in user and an ASP.NET Core handler, requirement and policy uses the group claim created from the Azure group to force the authorization.

The groups are defined in the same tenant as the Azure B2C.

A separate Azure App registration is used to define the application Graph API scopes. The User.Read.All application scope is used. In the demo, a client secret is used, but a certificate can also be used to access the API.

The Microsoft.Graph library is used as a client for Graph SDK 5 API. This is referenced using the Microsoft.Identity.Web.GraphServiceClient package.

The MsGraphService class implements the Microsoft Graph API client. A ClientSecretCredential instance is used as the AuthProvider and the definitions for the client are read from the application configurations and the user secrets in development, or Azure Key Vault. The user-id from the name identifier claim is used to get the Azure groups for the signed-in user. The claim namespaces gets added using the Microsoft client, this can be deactivated if required. I usually use the default claim names but as the is an Azure IDP, I left the Microsoft defaults which adds the extra stuff to the claims. The Graph API GetMemberGroups method returns the group IDs for the signed in identity. The API client can be exported into a separate singleton class and use managed identities for production.

using Azure.Identity;
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.Graph.Users.Item.GetMemberGroups;

namespace AzureB2CUI.Services;

public class MsGraphService
{
    private readonly GraphServiceClient _graphServiceClient;

    public MsGraphService(IConfiguration configuration)
    {
        string[]? scopes = configuration.GetValue<string>("GraphApi:Scopes")?.Split(' ');
        var tenantId = configuration.GetValue<string>("GraphApi:TenantId");

        // Values from app registration
        var clientId = configuration.GetValue<string>("GraphApi:ClientId");
        var clientSecret = configuration.GetValue<string>("GraphApi:ClientSecret");

        var options = new TokenCredentialOptions
        {
            AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
        };

        // https://docs.microsoft.com/dotnet/api/azure.identity.clientsecretcredential
        var clientSecretCredential = new ClientSecretCredential(
            tenantId, clientId, clientSecret, options);

        _graphServiceClient = new GraphServiceClient(clientSecretCredential, scopes);
    }

    public async Task<User?> GetGraphApiUser(string userId)
    {
        return await _graphServiceClient.Users[userId]
                .GetAsync();
    }

    public async Task<AppRoleAssignmentCollectionResponse?> GetGraphApiUserAppRoles(string userId)
    {
        return await _graphServiceClient.Users[userId]
                .AppRoleAssignments
                .GetAsync();
    }

    public async Task<GetMemberGroupsPostResponse?> GetGraphApiUserMemberGroups(string userId)
    {
        var requestBody = new GetMemberGroupsPostRequestBody
        {
            SecurityEnabledOnly = true,
        };

        return await _graphServiceClient.Users[userId]
            .GetMemberGroups
            .PostAsGetMemberGroupsPostResponseAsync(requestBody);
    }
}

The .default scope is used to access the Graph API using the client credential client.

"GraphApi": {
    "TenantId": "f611d805-cf72-446f-9a7f-68f2746e4724",
    "ClientId": "1d171c13-236d-4c2b-ac10-0325be2cbc74",
    "Scopes": ".default"
    //"ClientSecret": "--in-user-settings--"
},

The user and the application are authenticated using Azure B2C and an Azure App registration. Using Azure B2C, only a certain set of claims can be returned which cannot be adapted easily. Once signed-in, we want to include the Azure security group claims in the claims principal. To do this, the Graph API is used to find the claims for the user and add the claims to the claims principal using the IClaimsTransformation implementation. This is where the MsGraphService is used.

using RegisterUsersAzureB2CMsGraph.Services;
using System.Security.Claims;

namespace RegisterUsersAzureB2CMsGraph;

public class MsGraphClaimsTransformation
{
    private readonly MsGraphService _msGraphService;

    public MsGraphClaimsTransformation(MsGraphService msGraphService)
    {
        _msGraphService = msGraphService;
    }

    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        ClaimsIdentity claimsIdentity = new();
        var groupClaimType = "group";
        if (!principal.HasClaim(claim => claim.Type == groupClaimType))
        {
            var objectidentifierClaimType = "http://schemas.microsoft.com/identity/claims/objectidentifier";
            var objectIdentifier = principal.Claims.FirstOrDefault(t => t.Type == objectidentifierClaimType);

            if (objectIdentifier != null)
            {
                var groupIds = await _msGraphService.GetGraphApiUserMemberGroups(objectIdentifier.Value);

                foreach (var groupId in groupIds!.Value!.ToList())
                {
                    claimsIdentity.AddClaim(new Claim(groupClaimType, groupId));
                }
            }
        }

        principal.AddIdentity(claimsIdentity);
        return principal;
    }
}

The startup class adds the services and the authorization definitions for the ASP.NET Core Razor page application. The IsAdminHandlerUsingAzureGroups authorization handler is added and this is used to validate the Azure security group claim.

public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
    var services = builder.Services;
    var configuration = builder.Configuration;

    services.AddTransient<AdminApiService>();
    services.AddTransient<UserApiService>();
    services.AddScoped<MsGraphService>();
    services.AddScoped<MsGraphClaimsTransformation>();
    services.AddHttpClient();

    services.AddOptions();

    string[]? initialScopes = configuration.GetValue<string>("UserApiOne:ScopeForAccessToken")?.Split(' ');

    services.AddMicrosoftIdentityWebAppAuthentication(configuration, "AzureAdB2C")
        .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
        .AddInMemoryTokenCaches();

    services.Configure<MicrosoftIdentityOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
    {
        options.Events.OnTokenValidated = async context =>
        {
            if (_applicationServices != null && context.Principal != null)
            {
                using var scope = _applicationServices.CreateScope();
                context.Principal = await scope.ServiceProvider
                    .GetRequiredService<MsGraphClaimsTransformation>()
                    .TransformAsync(context.Principal);
            }
        };
    });

    services.AddControllers();

    services.AddRazorPages().AddMvcOptions(options =>
    {
        var policy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
        options.Filters.Add(new AuthorizeFilter(policy));
    }).AddMicrosoftIdentityUI();

    services.AddSingleton<IAuthorizationHandler, IsAdminHandlerUsingAzureGroups>();

    services.AddAuthorization(options =>
    {
        options.AddPolicy("IsAdminPolicy", policy =>
        {
            policy.Requirements.Add(new IsAdminRequirement());
        });
    });
    return builder.Build();
}

The IsAdminHandlerUsingAzureGroups implements the AuthorizationHandler class with the IsAdminRequirement requirement. This handler checks for the administrator group definition from the Azure tenant.

using Microsoft.AspNetCore.Authorization;

namespace RegisterUsersAzureB2CMsGraph.Authz;

public class IsAdminHandlerUsingAzureGroups : AuthorizationHandler<IsAdminRequirement>
{
    private readonly string _adminGroupId;

    public IsAdminHandlerUsingAzureGroups(IConfiguration configuration)
    {
        _adminGroupId = configuration.GetValue<string>("AzureGroups:AdminGroupId")!;
    }
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IsAdminRequirement requirement)
    {
        ArgumentNullException.ThrowIfNull(context);
        ArgumentNullException.ThrowIfNull(requirement);

        var claimIdentityprovider = context.User.Claims.FirstOrDefault(t => t.Type == "group"
            && t.Value == _adminGroupId);

        if (claimIdentityprovider != null)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

The policy for this can be used anywhere in the application.

[Authorize(Policy = "IsAdminPolicy")]
[AuthorizeForScopes(Scopes = ["https://b2cdamienbod.onmicrosoft.com/5f4e8bb1-3f4e-4fc6-b03c-12169e192cd7/access_as_user"])]
public class CallAdminApiModel : PageModel
{

If a user tries to call the Razor page which was created for admin users, then an Access denied is returned. Of course, in a real application, the menu for this would also be hidden if the user is not an admin and does not fulfil the policy.

If the user is an admin and a member of the Azure security group, the data and the Razor page can be opened and viewed.

By using Azure security groups, it is really easily for IT admins to add or remove users from the admin role. This can be easily managed using Powershell scripts. It is a pity that Microsoft Graph API is required to use the Azure security groups when authenticating using Azure B2C. This is much more simple to use when authenticating using Azure AD.

Links

Managing Azure B2C users with Microsoft Graph API

https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/graph-api

https://docs.microsoft.com/en-us/graph/sdks/choose-authentication-providers?tabs=CS#client-credentials-provider

https://docs.microsoft.com/en-us/azure/active-directory-b2c/overview

https://docs.microsoft.com/en-us/azure/active-directory-b2c/identity-provider-azure-ad-single-tenant

https://github.com/AzureAD/microsoft-identity-web

https://docs.microsoft.com/en-us/azure/active-directory/develop/microsoft-identity-web

https://docs.microsoft.com/en-us/azure/active-directory-b2c/identity-provider-local

https://docs.microsoft.com/en-us/azure/active-directory/

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/azure-ad-b2c

https://github.com/azure-ad-b2c/azureadb2ccommunity.io

https://github.com/azure-ad-b2c/samples

5 comments

  1. […] Using Azure security groups in ASP.NET Core with an Azure B2C Identity Provider – Damien Bowden […]

  2. […] Using Azure security groups in ASP.NET Core with an Azure B2C Identity Provider (Damien Bowden) […]

  3. […] Roadmap (Aleksandra Aganezova) Converting DOCX Form Fields to Smart HTML Forms (Bjoern Meyer) Using Azure security groups in ASP.NET Core with an Azure B2C Identity Provider (Damien Bowden) Building a Micro Web API with Azure Functions and SQLite (Jeremy Morgan) Analyzing […]

  4. […] groups based on their role, by finding the users group , u know what’s his role , here is an example of implementing this kind of authorization on .Net5 web api and web […]

  5. […] Using Azure security groups in ASP.NET Core with an Azure B2C Identity Provider […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.