Authorize By Group in Azure Active Directory B2C

Obtaining group memberships for a user from Azure AD requires quite a bit more than just "a couple lines of code", so I thought I'd share what finally worked for me to save others a few days worth of hair-pulling and head-banging.

Let's begin by adding the following dependencies to project.json:

"dependencies": {
    ...
    "Microsoft.IdentityModel.Clients.ActiveDirectory": "3.13.8",
    "Microsoft.Azure.ActiveDirectory.GraphClient": "2.0.2"
}

The first one is necessary as we need to authenticate our application in order for it to be able to access AAD Graph API. The second one is the Graph API client library we'll be using to query user memberships. It goes without saying that the versions are only valid as of the time of this writing and may change in the future.

Next, in the Configure() method of the Startup class, perhaps just before we configure OpenID Connect authentication, we create the Graph API client as follows:

var authContext = new AuthenticationContext("https://login.microsoftonline.com/<your_directory_name>.onmicrosoft.com");
var clientCredential = new ClientCredential("<your_b2c_app_id>", "<your_b2c_secret_app_key>");
const string AAD_GRAPH_URI = "https://graph.windows.net";
var graphUri = new Uri(AAD_GRAPH_URI);
var serviceRoot = new Uri(graphUri, "<your_directory_name>.onmicrosoft.com");
this.aadClient = new ActiveDirectoryClient(serviceRoot, async () => await AcquireGraphAPIAccessToken(AAD_GRAPH_URI, authContext, clientCredential));

WARNING: DO NOT hard-code your secret app key but instead keep it in a secure place. Well, you already knew that, right? :)

The asynchronous AcquireGraphAPIAccessToken() method that we handed to the AD client constructor will be called as necessary when the client needs to obtain authentication token. Here's what the method looks like:

private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl, AuthenticationContext authContext, ClientCredential clientCredential)
{
    AuthenticationResult result = null;
    var retryCount = 0;
    var retry = false;

    do
    {
        retry = false;
        try
        {
            // ADAL includes an in-memory cache, so this will only send a request if the cached token has expired
            result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential);
        }
        catch (AdalException ex)
        {
            if (ex.ErrorCode == "temporarily_unavailable")
            {
                retry = true;
                retryCount++;
                await Task.Delay(3000);
            }
        }
    } while (retry && (retryCount < 3));

    if (result != null)
    {
        return result.AccessToken;
    }

    return null;
}

Note that it has a built-in retry mechanism for handling transient conditions, which you may want to tailor to your application's needs.

Now that we have taken care of application authentication and AD client setup, we can go ahead and tap into OpenIdConnect events to finally make use of it. Back in the Configure() method where we'd typically call app.UseOpenIdConnectAuthentication() and create an instance of OpenIdConnectOptions, we add an event handler for the OnTokenValidated event:

new OpenIdConnectOptions()
{
    ...         
    Events = new OpenIdConnectEvents()
    {
        ...
        OnTokenValidated = SecurityTokenValidated
    },
};

The event is fired when access token for the signing-in user has been obtained, validated and user identity established. (Not to be confused with the application's own access token required to call AAD Graph API!) It looks like a good place for querying Graph API for user's group memberships and adding those groups onto the identity, in the form of additional claims:

private Task SecurityTokenValidated(TokenValidatedContext context)
{
    return Task.Run(async () =>
    {
        var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid");
        if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
        {
            var pagedCollection = await this.aadClient.Users.GetByObjectId(oidClaim.Value).MemberOf.ExecuteAsync();

            do
            {
                var directoryObjects = pagedCollection.CurrentPage.ToList();
                foreach (var directoryObject in directoryObjects)
                {
                    var group = directoryObject as Group;
                    if (group != null)
                    {
                        ((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String));
                    }
                }
                pagedCollection = pagedCollection.MorePagesAvailable ? await pagedCollection.GetNextPageAsync() : null;
            }
            while (pagedCollection != null);
        }
    });
}

Used here is the Role claim type, however you could use a custom one.

Having done the above, if you're using ClaimType.Role, all you need to do is decorate your controller class or method like so:

[Authorize(Role = "Administrators")]

That is, of course, provided you have a designated group configured in B2C with a display name of "Administrators".

If, however, you chose to use a custom claim type, you'd need to define an authorization policy based on the claim type by adding something like this in the ConfigureServices() method, e.g.:

services.AddAuthorization(options => options.AddPolicy("ADMIN_ONLY", policy => policy.RequireClaim("<your_custom_claim_type>", "Administrators")));

and then decorate a privileged controller class or method as follows:

[Authorize(Policy = "ADMIN_ONLY")]

Ok, are we done yet? - Well, not exactly.

If you ran your application and tried signing in, you'd get an exception from Graph API claiming "Insufficient privileges to complete the operation". It may not be obvious, but while your application authenticates successfully with AD using its app_id and app_key, it doesn't have the privileges required to read the details of users from your AD. In order to grant the application such access, I chose to use the Azure Active Directory Module for PowerShell

The following script did the trick for me:

$tenantGuid = "<your_tenant_GUID>"
$appID = "<your_app_id>"

$userVal = "<admin_user>@<your_AD>.onmicrosoft.com"
$pass = "<admin password in clear text>"
$Creds = New-Object System.Management.Automation.PsCredential($userVal, (ConvertTo-SecureString $pass -AsPlainText -Force))

Connect-MSOLSERVICE -Credential $Creds
$msSP = Get-MsolServicePrincipal -AppPrincipalId $appID -TenantID $tenantGuid

$objectId = $msSP.ObjectId

Add-MsolRoleMember -RoleName "Company Administrator" -RoleMemberType ServicePrincipal -RoleMemberObjectId $objectId

And now we're finally done! How's that for "a couple lines of code"? :)


Alex's answer is essential to figure out a working solution, thanks for pointing to the right direction.

However it uses app.UseOpenIdConnectAuthentication() which was long time depreciated already in Core 2 and completely removed in Core 3 (Migrate authentication and Identity to ASP.NET Core 2.0)

The fundamental task we must implement is attach an event handler to OnTokenValidated using OpenIdConnectOptions which is used by ADB2C Authentication under the hood. We must do this without interfering any other configuration of ADB2C.

Here is my take:

// My (and probably everyone's) existing code in Startup:
services.AddAuthentication(AzureADB2CDefaults.AuthenticationScheme)
        .AddAzureADB2C(options => Configuration.Bind("AzureAdB2C", options));

// This adds the custom event handler, without interfering any existing functionality:
services.Configure<OpenIdConnectOptions>(AzureADB2CDefaults.OpenIdScheme,
options =>
{
    options.Events.OnTokenValidated =
        new AzureADB2CHelper(options.Events.OnTokenValidated).OnTokenValidated;
});

All implementation is encapsulated in a helper class to keep Startup class clean. The original event handler is saved and called in case if it is not null (it is not btw)

public class AzureADB2CHelper
{
    private readonly ActiveDirectoryClient _activeDirectoryClient;
    private readonly Func<TokenValidatedContext, Task> _onTokenValidated;
    private const string AadGraphUri = "https://graph.windows.net";


    public AzureADB2CHelper(Func<TokenValidatedContext, Task> onTokenValidated)
    {
        _onTokenValidated = onTokenValidated;
        _activeDirectoryClient = CreateActiveDirectoryClient();
    }

    private ActiveDirectoryClient CreateActiveDirectoryClient()
    {
        // TODO: Refactor secrets to settings
        var authContext = new AuthenticationContext("https://login.microsoftonline.com/<yourdomain, like xxx.onmicrosoft.com>");
        var clientCredential = new ClientCredential("<yourclientcredential>", @"<yourappsecret>");


        var graphUri = new Uri(AadGraphUri);
        var serviceRoot = new Uri(graphUri, "<yourdomain, like xxx.onmicrosoft.com>");
        return new ActiveDirectoryClient(serviceRoot,
            async () => await AcquireGraphAPIAccessToken(AadGraphUri, authContext, clientCredential));
    }

    private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl,
        AuthenticationContext authContext,
        ClientCredential clientCredential)
    {
        AuthenticationResult result = null;
        var retryCount = 0;
        var retry = false;

        do
        {
            retry = false;
            try
            {
                // ADAL includes an in-memory cache, so this will only send a request if the cached token has expired
                result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential);
            }
            catch (AdalException ex)
            {
                if (ex.ErrorCode != "temporarily_unavailable")
                {
                    continue;
                }

                retry = true;
                retryCount++;
                await Task.Delay(3000);
            }
        } while (retry && retryCount < 3);

        return result?.AccessToken;
    }

    public Task OnTokenValidated(TokenValidatedContext context)
    {
        _onTokenValidated?.Invoke(context);
        return Task.Run(async () =>
        {
            try
            {
                var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid");
                if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
                {
                    var pagedCollection = await _activeDirectoryClient.Users.GetByObjectId(oidClaim.Value).MemberOf
                        .ExecuteAsync();

                    do
                    {
                        var directoryObjects = pagedCollection.CurrentPage.ToList();
                        foreach (var directoryObject in directoryObjects)
                        {
                            if (directoryObject is Group group)
                            {
                                ((ClaimsIdentity) context.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role,
                                    group.DisplayName, ClaimValueTypes.String));
                            }
                        }

                        pagedCollection = pagedCollection.MorePagesAvailable
                            ? await pagedCollection.GetNextPageAsync()
                            : null;
                    } while (pagedCollection != null);
                }
            }
            catch (Exception e)
            {
                Debug.WriteLine(e);
            }
        });
    }
}

You will need the appropriate packages I am using the following ones:

<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.0" />
<PackageReference Include="Microsoft.Azure.ActiveDirectory.GraphClient" Version="2.1.1" />
<PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="5.2.3" />

Catch: You must give your application permission to read AD. As of Oct 2019 this application must be a 'legacy' app and not the newest B2C application. Here is a very good guide: Azure AD B2C: Use the Azure AD Graph API


This will work, however you have to write a couple of lines of code in your authentication logic in order to achieve what you're looking for.

First of all, you have to distinguish between Roles and Groups in Azure AD (B2C).

User Role is very specific and only valid within Azure AD (B2C) itself. The Role defines what permissions a user does have inside Azure AD .

Group (or Security Group) defines user group membership, which can be exposed to the external applications. The external applications can model Role based access control on top of Security Groups. Yes, I know it may sound a bit confusing, but that's what it is.

So, your first step is to model your Groups in Azure AD B2C - you have to create the groups and manually assign users to those groups. You can do that in the Azure Portal (https://portal.azure.com/):

illustartion of azure portal

Then, back to your application, you will have to code a bit and ask the Azure AD B2C Graph API for users memberships once the user is successfully authenticated. You can use this sample to get inspired on how to get users group memberships. It is best to execute this code in one of the OpenID Notifications (i.e. SecurityTokenValidated) and add users role to the ClaimsPrincipal.

Once you change the ClaimsPrincipal to have Azure AD Security Groups and "Role Claim" values, you will be able to use the Authrize attribute with Roles feature. This is really 5-6 lines of code.

Finally, you can give your vote for the feature here in order to get group membership claim without having to query Graph API for that.


i implmented this as written , but as of May 2017 the line

((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String));

needs to be changed to

((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName));

To make it work with latest libs

Great work to the author

Also if your having a problem with Connect-MsolService giving bad username and password update to latest lib