0

We're developing an application that uses a back-end built on .Net Core 2.2 Web API. Most of our controllers merely require the [Authorize] attribute with no policy specified. However, some endpoints are going to require the user to be in a particular Azure AD Security Group. For those cases, I implemented policies like this in the Startup.cs file:

var name = "PolicyNameIndicatingGroup";
var id = Guid.NewGuid; // Actually, this is set to the object ID of the group in AD.

services.AddAuthorization(
    options =>
    {
        options.AddPolicy(
            name,
            policyBuilder => policyBuilder.RequireClaim(
                "groups",
                id.ToString()));
    });

Then, on controllers requiring this type of authorization, I have:

[Authorize("PolicyNameIndicatingGroup")]
public async Task<ResponseBase<string>> GroupProtectedControllerMethod() {}

The problem is that our users are all in a large number of groups. This causes the Graph API to return no group claims at all, and instead a simple hasGroups boolean claim set to true. Therefore, no one has any groups, and thus cannot pass authorization. This no-groups issue can be read about here.

This string-based policy registration, lackluster as it may be, seems to be what the .Net Core people are recommending, yet it falls flat if the groups aren't populated on the User Claims. I'm not really seeing how to circumnavigate the issue. Is there some special way to set up the AppRegistration for my API so that it does get all of the groups populated on the User Claims?

Update:

In the solution, I do have a service that calls Graph to get the user's groups. However, I can't figure out how to call it before it's too late. In other words, when the user hits the AuthorizeAttribute on the controller to check for the policy, the user's groups have not yet been populated, so the protected method always blocks them with a 403.

My attempt consisted of making a custom base controller for all of my Web API Controllers. Within the base controller's constructor, I'm calling a method that checks the User.Identity (of type ClaimsIdentity) to see if it's been created and authenticated, and, if so, I'm using the ClaimsIdentity.AddClaim(Claim claim) method to populate the user's groups, as retrieved from my Graph call. However, when entering the base controller's constructor, the User.Identity hasn't been set up yet, so the groups don't get populated, as previously described. Somehow, I need the user's groups to be populated before I ever get to constructing the controller.

bubbleking
  • 3,329
  • 3
  • 29
  • 49

2 Answers2

1

I found an answer to this solution thanks to some tips from someone on the ASP.NET Core team. This solution involves implementing an IClaimsTransformation (in the Microsoft.AspNetCore.Authentication namespace). To quote my source:

[IClaimsTransformation] is a service you wire into the request pipeline which will run after every authentication and you can use it to augment the identity as you like. That would be where you’d do your Graph API call [...]."

So I wrote the following implementation (see an important caveat below the code):

public class AdGroupClaimsTransformer : IClaimsTransformation
{
    private const string AdGroupsAddedClaimType = "adGroupsAlreadyAdded";
    private const string ObjectIdClaimType = "http://schemas.microsoft.com/identity/claims/objectidentifier";

    private readonly IGraphService _graphService; // My service for querying Graph
    private readonly ISecurityService _securityService; // My service for querying custom security information for the application

    public AdGroupClaimsTransformer(IGraphService graphService, ISecurityService securityService)
    {
        _graphService = graphService;
        _securityService = securityService;
    }

    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        var claimsIdentity = principal.Identity as ClaimsIdentity;
        var userIdentifier = FindClaimByType(claimsIdentity, ObjectIdClaimType);
        var alreadyAdded = AdGroupsAlreadyAdded(claimsIdentity);

        if (claimsIdentity == null || userIdentifier == null || alreadyAdded)
        {
            return Task.FromResult(principal);
        }

        var userSecurityGroups = _graphService.GetSecurityGroupsByUserId(userIdentifier).Result;
        var allSecurityGroupModels = _securityService.GetSecurityGroups().Result.ToList();

        foreach (var group in userSecurityGroups)
        {
            var groupIdentifier = allSecurityGroupModels.Single(m => m.GroupName == group).GroupGuid.ToString();

            claimsIdentity.AddClaim(new Claim("groups", groupIdentifier));
        }

        claimsIdentity.AddClaim(new Claim(AdGroupsAddedClaimType, "true"));

        return Task.FromResult(principal);
    }

    private static string FindClaimByType(ClaimsIdentity claimsIdentity, string claimType)
    {
        return claimsIdentity?.Claims?.FirstOrDefault(c => c.Type.Equals(claimType, StringComparison.Ordinal))
            ?.Value;
    }

    private static bool AdGroupsAlreadyAdded(ClaimsIdentity claimsIdentity)
    {
        var alreadyAdded = FindClaimByType(claimsIdentity, AdGroupsAddedClaimType);
        var parsedSucceeded = bool.TryParse(alreadyAdded, out var valueWasTrue);

        return parsedSucceeded && valueWasTrue;
    }
}

Within my Startup.cs, in the ConfigureServices method, I register the implementation like this:

services.AddTransient<IClaimsTransformation, AdGroupClaimsTransformer>();

The Caveat

You may have noticed that my implementation is written defensively to make sure the transformation will not be run a second time on a ClaimsPrincipal that has already undergone the procedure. The potential issue here is that calls to the IClaimsTransformation might occur multiple times, and that might be bad in some scenarios. You can read more about this here.

bubbleking
  • 3,329
  • 3
  • 29
  • 49
  • great find! Thanks for the tip – bbqchickenrobot Apr 06 '21 at 01:16
  • Do you know what package the ISecurityService lives in ? – bbqchickenrobot Apr 06 '21 at 01:39
  • @bbqchickenrobot - That's my own service for accessing a custom security model. In my case, I was storing details in a DB regarding my application permissions and how I've associated them with some of the Groups in my Azure AD instance. You could use whatever security model you want and use Claims Transformation to add any sort of claims to your Claims Principal. – bubbleking Apr 07 '21 at 22:27
0

You can use the Microsoft Graph API to query the user's groups instead:

POST https://graph.microsoft.com/v1.0/directoryObjects/{object-id}/getMemberGroups
Content-type: application/json

{
   "securityEnabledOnly": true
}

Reference: https://learn.microsoft.com/en-us/graph/api/directoryobject-getmembergroups?view=graph-rest-1.0&tabs=http

The scenario will be:

  1. Your client app will acquire access token (A) for accessing your back-end Web API.
  2. Your Web API application will acquire access token (B) for accessing the Microsoft Graph API with the access token (A) using OAuth 2.0 On-Behalf-Of flow. Access token (B) will be used to get the user's groups.
  3. Web API validates the user's group using a policy (recommended) or custom attribute.

The protocol diagram and sample request are listed in this article using the Azure AD V2.0 Endpoint. This article is for the V1.0 endpoint. Here are code samples for .Net Core.

bubbleking
  • 3,329
  • 3
  • 29
  • 49
Nan Yu
  • 26,101
  • 9
  • 68
  • 148
  • My problem is at #3. I have a service call to use token B to get the user's groups. I'm setting things up within Startup so that I can build the Authorization Policies. I'm using both the standard attribute and a custom attribute (on two different methods, to see if either works). To the former, I'm passing the string policy name. To the latter, I'm passing an enum which gets mapped to the string and passed to the AuthorizeAttribute. The problem is, the user has not been established at the time the attribute is hit, so no groups are returned. I'll update my question with these details. – bubbleking Jul 19 '19 at 18:37
  • Try moving the group query and add to principle logic in custom attribute . – Nan Yu Jul 22 '19 at 01:16