Authorize By Group in Azure Active Directory B2C
Asked Answered
W

8

46

I am trying to figure out how to authorize using groups in Azure Active Directory B2C. I can Authorize via User, for example:

[Authorize(Users="Bill")]

However, this is not very effective and I see very few use-cases for this. An alternate solution would be Authorizing via Role. However for some reason that does not seem to work. It does not work if I give a user the Role "Global Admin" for example, and try:

[Authorize(Roles="Global Admin")]

Is there a way to authorize via Groups or Roles?

Wynd answered 28/10, 2016 at 9:35 Comment(0)
U
27

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.

Ulrich answered 28/10, 2016 at 10:29 Comment(3)
could you maybe show those 5-6 lines? I've been trying to cobble together the answer to this question for a few days now, and I'm already at over 100 lines of code (and its not working yet either!). If its as easy as 5 or 6 lines to hook up the notification, query the graph for users group data, and add the groups to ClaimsPrincipal roles, I'm clearly barking up the wrong tree. I'd really appreciate some redirection!Hemicycle
How do you access "Azure B2C Settings"? I've found no place to add a Group to an Azure B2C tentant, though, oddly, I can add a user to a group (even though no groups exist).Begird
@Donald Airey It has been moved to a seperate entry 'Groups' in the Azure Portal.Reinert
P
51

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"? :)

Picrotoxin answered 2/1, 2017 at 0:20 Comment(14)
This is an outstanding write-up. Thanks!Archoplasm
Such beauty, such clarity, much swag!Gypsum
@ChristerBrannstrom Thanks! - I'm very glad it has helped a few folks.Picrotoxin
@AlexLobakov hey, i;m trying your solution and getting an error of "NotSupportedException: Specified method is not supported. HandleSignInAsync". is this something you can explain better to me so i can solve itSocket
There are many moving pieces that might go wrong, but check out the "Some Common Pitfalls" section in this post: rimdev.io/openid-connect-and-asp-net-core-1-0 See if one of those applies in your case. Also, make sure to add cookie authentication before adding OIDC: app.UseCookieAuthentication(....)Picrotoxin
This is a great answer, but how did you figure out the final powershell step? I'm getting: Get-MsolServicePrincipal : Access Denied. You do not have permissions to call this cmdlet.Exude
I've solved my PowerShell issues by re-writing your PowerShell above using the Azure Active Directory 2 module: powershellgallery.com/packages/AzureAD/2.0.0.131Exude
Great answer, very clear. Thanks for taking the time to share this.Hutchings
Explain this to me: you need to provide an administrator id and secret in order to query for group membership. How is an unprivileged user supposed to use get this information? Do you bake the admin credentials into a config file or the app when you deploy it?Begird
See the very last line in the PowerShell script above. It grants the role of "Company Administrator" to the application principal, which is what makes the application capable of reading group membership (and writing it, too, by the way). You're not "baking" the admin credentials into the app, - you're just using them once when executing the PowerShell script to grant AAD privileges to the application itself (based on its appID). Also, at the time of that original writing, there was no other reliable way to accomplish the same, but things may have changed since then.Picrotoxin
Great write-up. Depending on your use case, you might find increased performance by grabbing the group's membership on application start-up, and caching it in a collection. Then when the user logs in, spin the collection and see if the user is one of the group members. Keeps from calling Graph on every login.Bizet
I really appreciate your answer! Thank you for all this work. I struggle with the implementation in .NET Core 2.1, and one of the issues I run in is the aadClient. Where is this client defined? I suppose as a private in the Startup class, but then how can I access it from a configuration class? Do I need to inject it as a singleton?Reinert
I'm also struggling with aadClient, do I have to create that object before I can recreate a new aadClient or it should be defined on the web config. Please assist if you managed to find a solutionBodily
What is the services object 'services.AddAuthorization(options => options.AddPolicy("ADMIN_ONLY", policy => policy.RequireClaim("<your_custom_claim_type>", "Administrators"))); ' codeBodily
U
27

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.

Ulrich answered 28/10, 2016 at 10:29 Comment(3)
could you maybe show those 5-6 lines? I've been trying to cobble together the answer to this question for a few days now, and I'm already at over 100 lines of code (and its not working yet either!). If its as easy as 5 or 6 lines to hook up the notification, query the graph for users group data, and add the groups to ClaimsPrincipal roles, I'm clearly barking up the wrong tree. I'd really appreciate some redirection!Hemicycle
How do you access "Azure B2C Settings"? I've found no place to add a Group to an Azure B2C tentant, though, oddly, I can add a user to a group (even though no groups exist).Begird
@Donald Airey It has been moved to a seperate entry 'Groups' in the Azure Portal.Reinert
A
5

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

Anthropomorphosis answered 11/5, 2017 at 19:49 Comment(1)
And now Ticket property is gone so it must be changed to ((ClaimsIdentity) context.Principal.IdentityHavre
H
4

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

Havre answered 23/10, 2019 at 10:4 Comment(0)
F
4

There is an official sample: Azure AD B2C: Role-Based Access Control available here from the Azure AD team.

But yes, the only solution seems to be a custom implementation by reading user groups with the help of MS Graph.

Front answered 24/3, 2021 at 14:12 Comment(0)
S
4

I really like the answer from @AlexLobakov but I wanted an updated answer for .NET 6 and also something that was testable but still implemented the caching features. I also wanted the roles to be sent to my front end, be compatible with any SPA like React and use standard Azure AD B2C User flows for Role-based access control (RBAC) in my application.

I also missed a start to finish guide, so many variables that can go wrong and you end up with an application not working.

Start with creating a new ASP.NET Core Web API in Visual Studio 2022 with the following settings:

You should get a dialogue like this after creation:

If you don't see this then right click on the project in Visual Studio and click on Overview and then Connected services.

Create a new App registration in your Azure AD B2C or use an existing. I registered a new one for this demo purpose.

After creating the App registration Visual Studio got stuck on Dependency configuration progress so the rest will be configured manually:

Log on to https://portal.azure.com/, Switch directory to your AD B2C, select your new App registration and then click on Authentication. Then click on Add a platform and select Web.

Add a Redirect URI and Front-channel logout URL for localhost.

Example:

https://localhost:7166/signin-oidc

https://localhost:7166/logout

If you choose Single-page application instead it will look nearly the same. However you then need to add a code_challenge as described below. A full example for this will not be shown.

Is Active Directory not supporting Authorization Code Flow with PKCE?

Authentication should look something like this:

Click on Certificates & secrets and create a new Client secret.

Click on Expose an API and then edit Application ID URI.

Default value should look something like this api://11111111-1111-1111-1111-111111111111. Edit it to be https://youradb2c.onmicrosoft.com/11111111-1111-1111-1111-111111111111. There should be a scope named access_as_user. Create if it is not there.

Now click on API permissions:

Four Microsoft Graph permissions are needed.

Two Application:

GroupMember.Read.All
User.Read.All

Two Delegated:

offline_access
openid

You also need your access_as_user permission from My APIs. When this is done click on Grant admin consent for .... Should look like this:

If you don't have a User Flow already then create either a Sign up and sign in or a Sign in and select Recommended. My user flow is default B2C_1_signin.

Verify that your AD B2C user is a member of the group you want to authenticate against:

enter image description here

Now you can go back to your application and verify that you can get a code to login. Use this sample and it should redirect with a code:

https://<tenant-name>.b2clogin.com/tfp/<tenant-name>.onmicrosoft.com/<user-flow-name>/oauth2/v2.0/authorize?
client_id=<application-ID>
&nonce=anyRandomValue
&redirect_uri=https://localhost:7166/signin-oidc
&scope=https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-1111-111111111111/access_as_user
&response_type=code

If it works you should be redirected to something like this after login:

https://localhost:7166/signin-oidc?code=

If you get an error that says:

AADB2C99059: The supplied request must present a code_challenge

Then you have probably selected platform Single-page application and needs to add a code_challenge to the request like: &code_challenge=123. This is not enough because you also need to validate the challenge later otherwise you will get the error below when running my code.

AADB2C90183: The supplied code_verifier is invalid

Now open your application and appsettings.json. Default should look something like this:

  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "qualified.domain.name",
    "TenantId": "22222222-2222-2222-2222-222222222222",
    "ClientId": "11111111-1111-1111-11111111111111111",

    "Scopes": "access_as_user",
    "CallbackPath": "/signin-oidc"
  },

We need a few more values so it should look like this in the end:

  "AzureAd": {
    "Instance": "https://<tenant-name>.b2clogin.com/",
    "Domain": "<tenant-name>.onmicrosoft.com",
    "TenantId": "22222222-2222-2222-2222-222222222222",
    "ClientId": "11111111-1111-1111-11111111111111111",
    "SignUpSignInPolicyId": "B2C_1_signin",
    "ClientSecret": "--SECRET--",
    "ApiScope": "https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-11111111111111111/access_as_user",
    "TokenUrl": "https://<tenant-name>.b2clogin.com/<tenant-name>.onmicrosoft.com/B2C_1_signin/oauth2/v2.0/token",
    "Scopes": "access_as_user",
    "CallbackPath": "/signin-oidc"
  },

I store ClientSecret in Secret Manager.

https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows#manage-user-secrets-with-visual-studio

Now create these new classes:

AppSettings:

namespace AzureADB2CWebAPIGroupTest
{
    public class AppSettings
    {
        public AzureAdSettings AzureAd { get; set; } = new AzureAdSettings();

    }

    public class AzureAdSettings
    {
        public string Instance { get; set; }

        public string Domain { get; set; }

        public string TenantId { get; set; }

        public string ClientId { get; set; }

        public string IssuerSigningKey { get; set; }

        public string ValidIssuer { get; set; }

        public string ClientSecret { get; set; }

        public string ApiScope { get; set; }

        public string TokenUrl { get; set; }

    }
}

Adb2cTokenResponse:

namespace AzureADB2CWebAPIGroupTest
{
    public class Adb2cTokenResponse
    {
        public string access_token { get; set; }
        public string id_token { get; set; }
        public string token_type { get; set; }
        public int not_before { get; set; }
        public int expires_in { get; set; }
        public int ext_expires_in { get; set; }
        public int expires_on { get; set; }
        public string resource { get; set; }
        public int id_token_expires_in { get; set; }
        public string profile_info { get; set; }
        public string scope { get; set; }
        public string refresh_token { get; set; }
        public int refresh_token_expires_in { get; set; }
    }
}

CacheKeys:

namespace AzureADB2CWebAPIGroupTest
{
    public static class CacheKeys
    {
        public const string GraphApiAccessToken = "_GraphApiAccessToken";
    }
}

GraphApiService:

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Graph;
using System.Text.Json;

namespace AzureADB2CWebAPIGroupTest
{
    public class GraphApiService
    {
        private readonly IHttpClientFactory _clientFactory;
        private readonly IMemoryCache _memoryCache;
        private readonly AppSettings _settings;
        private readonly string _accessToken;

        public GraphApiService(IHttpClientFactory clientFactory, IMemoryCache memoryCache, AppSettings settings)
        {
            _clientFactory = clientFactory;
            _memoryCache = memoryCache;
            _settings = settings;

            string graphApiAccessTokenCacheEntry;

            // Look for cache key.
            if (!_memoryCache.TryGetValue(CacheKeys.GraphApiAccessToken, out graphApiAccessTokenCacheEntry))
            {
                // Key not in cache, so get data.
                var adb2cTokenResponse = GetAccessTokenAsync().GetAwaiter().GetResult();

                graphApiAccessTokenCacheEntry = adb2cTokenResponse.access_token;

                // Set cache options.
                var cacheEntryOptions = new MemoryCacheEntryOptions()
                    .SetAbsoluteExpiration(TimeSpan.FromSeconds(adb2cTokenResponse.expires_in));

                // Save data in cache.
                _memoryCache.Set(CacheKeys.GraphApiAccessToken, graphApiAccessTokenCacheEntry, cacheEntryOptions);
            }

            _accessToken = graphApiAccessTokenCacheEntry;
        }

        public async Task<List<string>> GetUserGroupsAsync(string oid)
        {
            var authProvider = new AuthenticationProvider(_accessToken);
            GraphServiceClient graphClient = new GraphServiceClient(authProvider, new HttpClientHttpProvider(_clientFactory.CreateClient()));

            //Requires GroupMember.Read.All and User.Read.All to get everything we want
            var groups = await graphClient.Users[oid].MemberOf.Request().GetAsync();

            if (groups == null)
            {
                return null;
            }

            var graphGroup = groups.Cast<Microsoft.Graph.Group>().ToList();

            return graphGroup.Select(x => x.DisplayName).ToList();
        }

        private async Task<Adb2cTokenResponse> GetAccessTokenAsync()
        {
            var client = _clientFactory.CreateClient();

            var kvpList = new List<KeyValuePair<string, string>>();
            kvpList.Add(new KeyValuePair<string, string>("grant_type", "client_credentials"));
            kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
            kvpList.Add(new KeyValuePair<string, string>("scope", "https://graph.microsoft.com/.default"));
            kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));

#pragma warning disable SecurityIntelliSenseCS // MS Security rules violation
            var req = new HttpRequestMessage(HttpMethod.Post, $"https://login.microsoftonline.com/{_settings.AzureAd.Domain}/oauth2/v2.0/token")
            { Content = new FormUrlEncodedContent(kvpList) };
#pragma warning restore SecurityIntelliSenseCS // MS Security rules violation

            using var httpResponse = await client.SendAsync(req);

            var response = await httpResponse.Content.ReadAsStringAsync();

            httpResponse.EnsureSuccessStatusCode();

            var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response);

            return adb2cTokenResponse;
        }
    }

    public class AuthenticationProvider : IAuthenticationProvider
    {
        private readonly string _accessToken;

        public AuthenticationProvider(string accessToken)
        {
            _accessToken = accessToken;
        }

        public Task AuthenticateRequestAsync(HttpRequestMessage request)
        {
            request.Headers.Add("Authorization", $"Bearer {_accessToken}");

            return Task.CompletedTask;
        }
    }

    public class HttpClientHttpProvider : IHttpProvider
    {
        private readonly HttpClient http;

        public HttpClientHttpProvider(HttpClient http)
        {
            this.http = http;
        }

        public ISerializer Serializer { get; } = new Serializer();

        public TimeSpan OverallTimeout { get; set; } = TimeSpan.FromSeconds(300);

        public void Dispose()
        {
        }

        public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
        {
            return http.SendAsync(request);
        }

        public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
            HttpCompletionOption completionOption,
            CancellationToken cancellationToken)
        {
            return http.SendAsync(request, completionOption, cancellationToken);
        }
    }
}

At the moment only accessToken for GraphServiceClient is stored in memorycache but if the application requires better performance a users groups could also be cached.

Add a new class:

Adb2cUser:

namespace AzureADB2CWebAPIGroupTest
{
    public class Adb2cUser
    {
        public Guid Id { get; set; }

        public string GivenName { get; set; }

        public string FamilyName { get; set; }

        public string Email { get; set; }

        public List<string> Roles { get; set; }

        public Adb2cTokenResponse Adb2cTokenResponse { get; set; }
    }
}

and struct:

namespace AzureADB2CWebAPIGroupTest
{
    public struct ADB2CJwtRegisteredClaimNames
    {
        public const string Emails = "emails";

        public const string Name = "name";
    }
}

And now add a new API Controller

LoginController:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;

namespace AzureADB2CWebAPIGroupTest.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    [Authorize]
    public class LoginController : ControllerBase
    {

        private readonly ILogger<LoginController> _logger;
        private readonly IHttpClientFactory _clientFactory;
        private readonly AppSettings _settings;
        private readonly GraphApiService _graphApiService;

        public LoginController(ILogger<LoginController> logger, IHttpClientFactory clientFactory, AppSettings settings, GraphApiService graphApiService)
        {
            _logger = logger;
            _clientFactory = clientFactory;
            _settings = settings;
            _graphApiService=graphApiService;
        }

        [HttpPost]
        [AllowAnonymous]
        public async Task<ActionResult<Adb2cUser>> Post([FromBody] string code)
        {
            var redirectUri = "";

            if (HttpContext != null)
            {
                redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host + "/signin-oidc";
            }

            var kvpList = new List<KeyValuePair<string, string>>();
            kvpList.Add(new KeyValuePair<string, string>("grant_type", "authorization_code"));
            kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
            kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope));
            kvpList.Add(new KeyValuePair<string, string>("code", code));
            kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri));
            kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));

            return await UserLoginAndRefresh(kvpList);

        }

        [HttpPost("refresh")]
        [AllowAnonymous]
        public async Task<ActionResult<Adb2cUser>> Refresh([FromBody] string token)
        {
            var redirectUri = "";

            if (HttpContext != null)
            {
                redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host;
            }

            var kvpList = new List<KeyValuePair<string, string>>();
            kvpList.Add(new KeyValuePair<string, string>("grant_type", "refresh_token"));
            kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
            kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope));
            kvpList.Add(new KeyValuePair<string, string>("refresh_token", token));
            kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri));
            kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));

            return await UserLoginAndRefresh(kvpList);
        }

        private async Task<ActionResult<Adb2cUser>> UserLoginAndRefresh(List<KeyValuePair<string, string>> kvpList)
        {
            var user = await TokenRequest(kvpList);
            if (user == null)
            {
                return Unauthorized();
            }

            //Return access token and user information
            return Ok(user);
        }

        private async Task<Adb2cUser> TokenRequest(List<KeyValuePair<string, string>> keyValuePairs)
        {
            var client = _clientFactory.CreateClient();

#pragma warning disable SecurityIntelliSenseCS // MS Security rules violation
            var req = new HttpRequestMessage(HttpMethod.Post, _settings.AzureAd.TokenUrl)
            { Content = new FormUrlEncodedContent(keyValuePairs) };
#pragma warning restore SecurityIntelliSenseCS // MS Security rules violation

            using var httpResponse = await client.SendAsync(req);

            var response = await httpResponse.Content.ReadAsStringAsync();

            httpResponse.EnsureSuccessStatusCode();

            var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response);

            var handler = new JwtSecurityTokenHandler();
            var jwtSecurityToken = handler.ReadJwtToken(adb2cTokenResponse.access_token);

            var id = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.Sub).Value;

            var groups = await _graphApiService.GetUserGroupsAsync(id);

            var givenName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.GivenName).Value;
            var familyName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.FamilyName).Value;
            //Unless Alternate email have been added in Azure AD there will only be one email here. 
            //TODO Handle multiple emails
            var emails = jwtSecurityToken.Claims.First(claim => claim.Type == ADB2CJwtRegisteredClaimNames.Emails).Value;

            var user = new Adb2cUser()
            {
                Id = Guid.Parse(id),
                GivenName = givenName,
                FamilyName = familyName,
                Email = emails,
                Roles = groups,
                Adb2cTokenResponse = adb2cTokenResponse
            };

            return user;
        }
    }
}

Now it is time to edit Program.cs. Should look something like this for the new minimal hosting model in ASP.NET Core 6.0:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));

Notice that ASP.NET Core 6.0 are using JwtBearerDefaults.AuthenticationScheme and not AzureADB2CDefaults.AuthenticationScheme or AzureADB2CDefaults.OpenIdScheme.

Edit so Program.cs looks like this:

using AzureADB2CWebAPIGroupTest;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Identity.Web;
using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);

//Used for debugging
//IdentityModelEventSource.ShowPII = true;

var settings = new AppSettings();
builder.Configuration.Bind(settings);
builder.Services.AddSingleton(settings);

var services = new ServiceCollection();
services.AddMemoryCache();
services.AddHttpClient();
var serviceProvider = services.BuildServiceProvider();

var memoryCache = serviceProvider.GetService<IMemoryCache>();
var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();

var graphApiService = new GraphApiService(httpClientFactory, memoryCache, settings);

// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(options => {
        builder.Configuration.Bind("AzureAd", options);
        options.TokenValidationParameters.NameClaimType = "name";
        options.TokenValidationParameters.ValidateIssuerSigningKey = true;
        options.TokenValidationParameters.ValidateLifetime = true;
        options.TokenValidationParameters.ValidateIssuer = true;
        options.TokenValidationParameters.ValidateLifetime = true;
        options.TokenValidationParameters.ValidateTokenReplay = true;
        options.Audience = settings.AzureAd.ClientId;
        options.Events = new JwtBearerEvents()
        {
            OnTokenValidated = async ctx =>
            {
                //Runs on every request, cache a users groups if needed
                var oidClaim = ((System.IdentityModel.Tokens.Jwt.JwtSecurityToken)ctx.SecurityToken).Claims.FirstOrDefault(c => c.Type == "oid");
                if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
                {
                    var groups = await graphApiService.GetUserGroupsAsync(oidClaim.Value);

                    foreach (var group in groups)
                    {
                        ((ClaimsIdentity)ctx.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role.ToString(), group));
                    }
                }
            }
        };
    },
    options => {
        builder.Configuration.Bind("AzureAd", options);
    });

builder.Services.AddTransient<GraphApiService>();

builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

Now you can run your application and use the code from earlier in a request like this:

POST /api/login/ HTTP/1.1
Host: localhost:7166
Content-Type: application/json

"code"

You will then receieve a response like this with an access_token:

{
    "id": "31111111-1111-1111-1111-111111111111",
    "givenName": "Oscar",
    "familyName": "Andersson",
    "email": "[email protected]",
    "roles": [
        "Administrator",
    ],
    "adb2cTokenResponse": {
        
    }
}

Adding [Authorize(Roles = "Administrator")] to WeatherForecastController.cs we can now verify that only a user with the correct role is allowed to access this resource using the access_token we got earlier:

If we change to [Authorize(Roles = "Administrator2")] we get a HTTP 403 with the same user:

LoginController can handle refresh tokens as well.

With NuGets Microsoft.NET.Test.Sdk, xunit, xunit.runner.visualstudio and Moq we can also test LoginController and in turn also GraphApiService used for ClaimsIdentity in Program.cs. Unfortunately due body being limited to 30000 charcters the entire test can not be shown.

It basically looks like this:

LoginControllerTest:

using AzureADB2CWebAPIGroupTest.Controllers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using Moq.Protected;
using System.Net;
using Xunit;

namespace AzureADB2CWebAPIGroupTest
{
    public class LoginControllerTest
    {

        [Theory]
        [MemberData(nameof(PostData))]
        public async Task Post(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName)
        {
            var controller = GetLoginController(response);

            var result = await controller.Post(code);

            var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result);
            var okResult = Assert.IsType<OkObjectResult>(result.Result);
            var returnValue = Assert.IsType<Adb2cUser>(okResult.Value);
            Assert.Equal(returnValue.Email, expectedEmail);
            Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name);
        }

        [Theory]
        [MemberData(nameof(RefreshData))]
        public async Task Refresh(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName)
        {
            var controller = GetLoginController(response);

            var result = await controller.Refresh(code);

            var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result);
            var okResult = Assert.IsType<OkObjectResult>(result.Result);
            var returnValue = Assert.IsType<Adb2cUser>(okResult.Value);
            Assert.Equal(returnValue.Email, expectedEmail);
            Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name);
        }
        
        //PostData and RefreshData removed for space

        private LoginController GetLoginController(string expectedResponse)
        {
            var mockFactory = new Mock<IHttpClientFactory>();

            var settings = new AppSettings();

            settings.AzureAd.TokenUrl = "https://example.com";

            var mockMessageHandler = new Mock<HttpMessageHandler>();

            GraphApiServiceMock.MockHttpRequests(mockMessageHandler);

            mockMessageHandler.Protected()
                .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(x => x.RequestUri.AbsoluteUri.Contains(settings.AzureAd.TokenUrl)), ItExpr.IsAny<CancellationToken>())
                .ReturnsAsync(new HttpResponseMessage
                {
                    StatusCode = HttpStatusCode.OK,
                    Content = new StringContent(expectedResponse)
                });

            var httpClient = new HttpClient(mockMessageHandler.Object);

            mockFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(httpClient);

            var logger = Mock.Of<ILogger<LoginController>>();

            var services = new ServiceCollection();
            services.AddMemoryCache();
            var serviceProvider = services.BuildServiceProvider();

            var memoryCache = serviceProvider.GetService<IMemoryCache>();

            var graphService = new GraphApiService(mockFactory.Object, memoryCache, settings);

            var controller = new LoginController(logger, mockFactory.Object, settings, graphService);

            return controller;
        }
    }
}

A GraphApiServiceMock.cs is also needed but it just adds more values like the example with mockMessageHandler.Protected() and static values like public static string DummyUserExternalId = "11111111-1111-1111-1111-111111111111";.

There are other ways to do this but they usually depend on Custom Policies:

https://learn.microsoft.com/en-us/answers/questions/469509/can-we-get-and-edit-azure-ad-b2c-roles-using-ad-b2.html

https://devblogs.microsoft.com/premier-developer/using-groups-in-azure-ad-b2c/

https://learn.microsoft.com/en-us/azure/active-directory-b2c/user-flow-overview

Shawana answered 9/2, 2022 at 17:32 Comment(3)
Would this approach work with an Azure Functions API instead of an ASP.NET Core Web API? Would anything need to be done differently? I’ve been struggling to do all this with an Azure Functions API backend for a while now.Salomie
@Salomie I have not used it with Azure Functions API. I know that a normal Azure AD works out of the box but unsure with AD B2C. learn.microsoft.com/en-us/azure/azure-functions/…Shawana
What I was more so thinking is if something like the GraphApiService in your example where you get the user’s groups could be put into an anonymous Azure Function instead. Just trying to avoid deploying a server app just to get the user’s groups.Salomie
H
3

Based on all the amazing answers here, getting user groups using the new Microsoft Graph API


IConfidentialClientApplication confidentialClientApplication = ConfidentialClientApplicationBuilder
          .Create("application-id")
          .WithTenantId("tenant-id")
          .WithClientSecret("xxxxxxxxx")
          .Build();

ClientCredentialProvider authProvider = new ClientCredentialProvider(confidentialClientApplication);

GraphServiceClient graphClient = new GraphServiceClient(authProvider);


var groups = await graphClient.Users[oid].MemberOf.Request().GetAsync();
Heartthrob answered 20/5, 2020 at 15:33 Comment(2)
ClientCredentialProvider seems not to be present in .net core 5Sachikosachs
you must install packages Install-Package Microsoft.Graph Install-Package Microsoft.Graph.Auth -IncludePrereleaseSachikosachs
L
0

First of all, thank you all for the previous responses. I've spent the entire day to put this to work. I'm using ASPNET Core 3.1 and I was getting the following error when using the solution from previous response:

secure binary serialization is not supported on this platform

I've replaces to REST API queries and I was able to get the groups:

    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))
                {
                    HttpClient http = new HttpClient();

                    var domainName = _azureADSettings.Domain;
                    var authContext = new AuthenticationContext($"https://login.microsoftonline.com/{domainName}");
                    var clientCredential = new ClientCredential(_azureADSettings.ApplicationClientId, _azureADSettings.ApplicationSecret);
                    var accessToken = AcquireGraphAPIAccessToken(AadGraphUri, authContext, clientCredential).Result;

                    var url = $"https://graph.windows.net/{domainName}/users/" + oidClaim?.Value + "/$links/memberOf?api-version=1.6";

                    HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url);
                    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
                    HttpResponseMessage response = await http.SendAsync(request);

                    dynamic json = JsonConvert.DeserializeObject<dynamic>(await response.Content.ReadAsStringAsync());

                    foreach(var group in json.value)
                    {
                        dynamic x = group.url.ToString();

                        request = new HttpRequestMessage(HttpMethod.Get, x + "?api-version=1.6");
                        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
                        response = await http.SendAsync(request);

                        dynamic json2 = JsonConvert.DeserializeObject<dynamic>(await response.Content.ReadAsStringAsync());

                        ((ClaimsIdentity)((ClaimsIdentity)context.Principal.Identity)).AddClaim(new Claim(ClaimTypes.Role.ToString(), json2.displayName.ToString()));
                    }
                }
            }
            catch (Exception e)
            {
                Debug.WriteLine(e);
            }
        });
    }
Lawgiver answered 1/2, 2020 at 23:50 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.