Azure Functions, MSAL, and Role-Based Access

Elliott Johnson 11 Reputation points
2021-03-01T18:05:43.293+00:00

This one's got some moving parts, so I'll try to boil it down as much as possible.

I have to apps working together: A frontend written in React and a backend written in C# Azure Functions (.NET Core 3.1).

Both apps are registered correctly in our AD tenant. The Azure Functions instance is set up to only accept requests from the front-end React app. I have roles defined in both applications through the Enterprise Applications blade. In the frontend, I can see all roles assigned to the user on their idToken's roles claim. For the sake of argument, let's say I've assigned Site.View and Site.Administer to myself (I've assigned these roles in BOTH applications, not just the frontend). Both of these are visible on the frontend.

Now, let's segue to my Functions setup. They're all HTTP-triggered, and I'm using the ClaimsPrincipal binding to automatically get the ClaimsPrincipal from the incoming Bearer Token.

When I call the backend, everything looks pretty hunky-dory from the frontend... most of the time. I was, however, getting some super-random 401 Unauthorized responses. After a whole bunch of debugging, I finally figured out the issue. It seems that the roles claim on the backend only contains one role: The role most recently assigned to the user. So, for example, if I assigned myself Site.Administer AND THEN assigned myself Site.View, only Site.View would show up. If I did it the other way around, I'd only have Site.Administer.

I'm at a loss here. Is it just that the ClaimsPrincipal binding isn't compatible with v2 tokens? I've included some relevant code snippets below:

ValidateRoles code:

// Global definition
    public static bool ValidateRoles(ClaimsPrincipal principal, IList<string> allowedRoles, ILogger log)
    {
      Claim roleClaim = principal.FindFirst("roles");
      string[] roles = roleClaim.Value.Split(' ');
      if (roleClaim == null || !(roles.Intersect(allowedRoles).Count() > 0))
      {
        throw new UnauthorizedAccessException("The current user does not have the roles required to access this endpoint.");
      }
      log.LogInformation($"Authorizing user based on roles: {String.Join(", ", roles)}");
      return true;
    }

//Definition I'm using for the current group of endpoints
    public static readonly IList<string> Roles = new List<string> {
      Globals.Roles.AmenitiesAdmin,
      Globals.Roles.GlobalAdmin,
    };

    public static bool ValidateRoles(ClaimsPrincipal principal, ILogger log)
    {
      return Globals.ValidateRoles(principal, Roles, log);
    }

Example Azure Function:

public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "amenities/addAmenityRules")] HttpRequest req,
        ClaimsPrincipal principal,
        ILogger log)
    {
      try
      {
        AmenityEndpointGlobals.ValidateRoles(principal, log);
      }
      catch (UnauthorizedAccessException e)
      {
        log.LogError($"Denied user access based on roles: {principal.FindFirst("roles")}");
        return new UnauthorizedObjectResult(new Dictionary<string, string> { { "error", e.Message } });
      }
      catch (Exception e)
      {
        log.LogError(e.Message);
        return new StatusCodeResult(StatusCodes.Status500InternalServerError);
      }
    // other code
    }
Microsoft Entra ID
Microsoft Entra ID
A Microsoft Entra identity service that provides identity management and access control capabilities. Replaces Azure Active Directory.
19,473 questions
0 comments No comments
{count} votes

2 answers

Sort by: Most helpful
  1. Elliott Johnson 11 Reputation points
    2021-03-01T20:12:50.83+00:00

    I finally made some headway! This is SUPER weird.

    Using some code I found on GitHub (included below), I manually validated the token. For some reason, the role claims seem to be broken out one-by-one rather than included as a list in the "roles" claim (as it is on the front-end and in the documentation).

    Under the ClaimsPrincipal.Claims property, there will be two claims like this:
    {http://schemas.microsoft.com/ws/2008/06/identity/claims/role: Site.View}

    Where the key is that schema definition.

    I'm currently pushing to staging to see if the function binding is pulling back the same thing where each role has its own claim.

    My validation code:

    public static async Task<bool> ValidateRoles(HttpRequest req, IList<string> allowedRoles, ILogger log)
        {
          var accessToken = GetAccessToken(req);
          var claimsPrincipal = await ValidateAccessToken(accessToken, log);
          Claim roleClaim = claimsPrincipal.FindFirst("roles");
          string[] roles = roleClaim?.Value?.Split(' ');
          if (roleClaim == null || !(roles.Intersect(allowedRoles).Count() > 0))
          {
            log.LogError($"Denied user access based on roles: {String.Join(", ", roles)}");
            throw new UnauthorizedAccessException("The current user does not have the roles required to access this endpoint.");
          }
          log.LogInformation($"Authorizing user based on roles: {String.Join(", ", roles)}");
          return true;
        }
    
        private static string GetAccessToken(HttpRequest req)
        {
          var authorizationHeader = req.Headers?["Authorization"];
          string[] parts = authorizationHeader?.ToString().Split(null) ?? new string[0];
          if (parts.Length == 2 && parts[0].Equals("Bearer"))
            return parts[1];
          return null;
        }
    
        private static async Task<ClaimsPrincipal> ValidateAccessToken(string accessToken, ILogger log)
        {
          var audience = AuthConfig.audience;
          var clientID = AuthConfig.clientId;
          var tenant = AuthConfig.tenant;
          var tenantid = AuthConfig.tenantId;
          var aadInstance = AuthConfig.aadInstance;
          var authority = AuthConfig.authority;
          var validIssuers = AuthConfig.validIssuers;
    
          // Debugging purposes only, set this to false for production
          Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true;
    
          ConfigurationManager<OpenIdConnectConfiguration> configManager =
              new ConfigurationManager<OpenIdConnectConfiguration>(
                  $"{authority}/.well-known/openid-configuration",
                  new OpenIdConnectConfigurationRetriever());
    
          OpenIdConnectConfiguration config = null;
          config = await configManager.GetConfigurationAsync();
    
          ISecurityTokenValidator tokenValidator = new JwtSecurityTokenHandler();
    
          // Initialize the token validation parameters
          TokenValidationParameters validationParameters = new TokenValidationParameters
          {
            // App Id URI and AppId of this service application are both valid audiences.
            ValidAudiences = new[] { audience, clientID },
    
            // Support Azure AD V1 and V2 endpoints.
            ValidIssuers = validIssuers,
            IssuerSigningKeys = config.SigningKeys
          };
    
          try
          {
            SecurityToken securityToken;
            var claimsPrincipal = tokenValidator.ValidateToken(accessToken, validationParameters, out securityToken);
            return claimsPrincipal;
          }
          catch (Exception ex)
          {
            log.LogError(ex.ToString());
          }
          return null;
        }
    
    0 comments No comments

  2. Elliott Johnson 11 Reputation points
    2021-03-01T21:25:02.21+00:00

    Looks like I've found the answer, at least to part of the question. This small change in ValidateAccessToken has rectified the problem (in that I'm now seeing all of the roles I should):

    var roles = claimsPrincipal.FindAll("http://schemas.microsoft.com/ws/2008/06/identity/claims/role")?.Select(claim => claim.Value);
    if (roles == null || !(roles.Intersect(allowedRoles).Count() > 0)) 
    {
      //etc
    }
    

    However, this does seem to break the documentation on v2.0 tokens, which says the roles should be included in a "roles" claim (not spread across multiple claims).

    Unless breaking them out into multiple claims is something JwtSecurityTokenHandler is doing?

    Would appreciate a response on that.