question

ElliottJohnson-7539 avatar image
0 Votes"
ElliottJohnson-7539 asked ·

Azure Functions, MSAL, and Role-Based Access

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
    }


azure-ad-msalazure-ad-libraries
10 |1000 characters needed characters left characters exceeded

Up to 10 attachments (including images) can be used with a maximum of 3.0 MiB each and 30.0 MiB total.

ElliottJohnson-7539 avatar image
0 Votes"
ElliottJohnson-7539 answered ·

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;
    }
·
10 |1000 characters needed characters left characters exceeded

Up to 10 attachments (including images) can be used with a maximum of 3.0 MiB each and 30.0 MiB total.

ElliottJohnson-7539 avatar image
0 Votes"
ElliottJohnson-7539 answered ·

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.

· 1 ·
10 |1000 characters needed characters left characters exceeded

Up to 10 attachments (including images) can be used with a maximum of 3.0 MiB each and 30.0 MiB total.

@ElliottJohnson-7539 Thanks for your patience on this, I am trying to check internally to see how can we help you.

0 Votes 0 ·