Using AppRoles and Azure AD B2C for RBAC

Update: There is a follow up blog post that builds on this post to build Delegated App Admin using AppRoles.

The goal of this article is to be able to create an Azure AD B2C JWT Token that contains the user’s roles so you can build apps like this

Azure AD B2C’s Identity Experience Framework doesn’t have built in support for RBAC features like roles and groups, but that doesn’t mean you can use them. With a little help of some Graph API code in a tiny Azure Function, you can create RBAC functionality in your B2C protected app.

Register a new Application

Register a new Application in B2C the following way

  • + New Registration.
    • Name = AspnetCoreMsal-AppRole (or whatever you prefer)
    • [x] Accounts in any identity provider or organizational directory
    • Redirect Uri = Web + https://localhost:5001
    • Register
  • Authentication (if you want to test drive your B2C policy from the portal)
    • Add https://jwt.ms as redirect uri
    • Implicit grant and hybrid flows, select both [x] id and [x] access tokens
  • Then edit the App Manifest to add the AppRole as per below.

You create AppRoles by editing the App Manifest as per the documentation here. If you copy-n-paste a text like below, you must replace the id with a new guid as they need to be unique.

"appRoles": [
    {
        "allowedMemberTypes": [
            "User"
        ],
        "description": "Team Managers that can manage their team",
        "displayName": "TeamManager",
        "id": "9653f24c-a455-44dc-aaac-b027d9a8f62a",
        "isEnabled": true,
        "origin": "Application",
        "value": "TeamManager"
    },

Azure Function to do Graph API queries

To query the defined appRoles of an application, you need to do a Graph API query on the service principal object. The client_id will be the AppId which is passed as a parameter in the OIDC authorize flow call to B2C.

https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{client_id}' &$select=id,appId,appRoles

The second query we need to do is on the user object to see what roles the user has assigned. The resourceId is the ObjectId of the service principal and the appRoleId is the id of the appRoles item. So given the result for this user’s appRoleAssignment, we can determind that the user is a TeamManager.

https://graph.microsoft.com/v1.0/users/{objectId}/appRoleAssignments?$select=appRoleId,resourceId

So if we put the two graph queries in an Azure Function, join and filter their results (see the github repo), we can return the following result, given a user trying to sign in to an app. Notice, the Azure Function also returns group membership as we might also be interested in that. We will make B2C consume this result when we come to the B2C Custom Policies further down.

{
  "roles": [
    "TeamManager"
  ],
  "groups": [
    "B2CTeamManagers",
  ]
}

For details how to deploy the Azure Functions, see the github repo https://github.com/cljung/B2C-AppRoles.

Assigning an AppRole to a user

Once we have defined an AppRole in the manifest, how do we assign it to a user? Well, there are two ways. One way is to assign the user directly and the other is to assign it to a group which the user is a member of. Assuming we are using a group, these are the powershell commands to do it. You can also do this in portal.azure.com, but then you need to find your application in the Enterprise Application blade.

# 1. get the user
$user = (Get-AzureADUser -SearchString "Max Peck")

# 2. create the group
$group = New-AzureADGroup -DisplayName "B2CTeamManagers" -MailEnabled $false -SecurityEnabled $true -MailNickName "NotSet"

# 3. add the user as a member of the group
Add-AzureADGroupMember -ObjectId $group.ObjectID -RefObjectId $user.ObjectID

# 4. get the app's service principal
$spo =(Get-AzureADServicePrincipal -Filter "DisplayName eq 'AspnetCoreMsal-Demo'")

# 5. find the role by name
$roleAppAdmin =($spo.AppRoles | Where {$_.DisplayName -eq "TeamManager"})

# 6. Assign the group to the AppRole
New-AzureADGroupAppRoleAssignment -ObjectId $group.ObjectId -PrincipalId $group.ObjectId -ResourceId $spo.ObjectId -Id $roleAppAdmin.id

The first three commands finds the user named Max Peck, creates a group named “B2CTeamManagers” and adds the user as a member of that group. The following three commands finds the Service Principal object, locates the AppRole “TeamManager” and assigns the group to the role. You could do this in Graph API and then the trick would be to make a PATCH call to the groups appRoleAssignment endpoint.

PATCH  https://graph.microsoft.com/groups/{objectId}/appRoleAssignments

B2C Custom Policy

In order to create a B2C Custom Policy that can generate an JWT access_token with the roles claim in it, you start by downloading the SocialAndLocalAccounts templates from the starter pack in github. Then you make the usual modifications with replacing yourtenant.onmicrosoft.com with your tenant name and you update the AppId guids in TrustFrameworkExtensions.xml (2×2 places!). This is basic if you have worked with B2C. If you need help setting up your B2C tenant, you should follow this doc page.

Then, in TrustFrameworkExtensions.xml, we need to make several additions. First, we need to define the claims groups and roles. They both have the data type stringCollection. Put this text after the <BuildingBlocks> xml element.

<ClaimsSchema>
    <ClaimType Id="groups">
    <DisplayName>Comma delimited list of group names</DisplayName>
    <DataType>stringCollection</DataType>
    <UserInputType>Readonly</UserInputType>
    </ClaimType>
    <ClaimType Id="roles">
    <DisplayName>Comma delimited list of AppRoleAssignment names</DisplayName>
    <DataType>stringCollection</DataType>
    <UserInputType>Readonly</UserInputType>
    </ClaimType>
</ClaimsSchema>

Next, we need to add a TechnicalProfile to talk to our Azure Function. For this, locate the ending </ClaimsProviders> and add before it the following

<!-- /////////////////////// REST APIs ////////////////////////// -->
<ClaimsProvider>
    <DisplayName>REST APIs</DisplayName>
    <TechnicalProfiles>
    <TechnicalProfile Id="GetUserAppRoleAssignment">
        <DisplayName>Retrieves security groups assigned to the user</DisplayName>
        <Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.RestfulProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
        <Metadata>
        <Item Key="ServiceUrl">https://yourname.azurewebsites.net/api/GetAppRoleAssignmentsMSGraph?code=...</Item>
        <Item Key="AuthenticationType">None</Item>
        <Item Key="SendClaimsIn">Body</Item>
        <Item Key="AllowInsecureAuthInProduction">true</Item>
        <Item Key="IncludeClaimResolvingInClaimsHandling">true</Item>
        </Metadata>
        <InputClaims>
        <InputClaim Required="true" ClaimTypeReferenceId="objectId" />
        <!-- this B2C tenant id -->
        <InputClaim ClaimTypeReferenceId="tenantId" DefaultValue="{Policy:TenantObjectId}" />
        <!-- The App we're signing in to -->
        <InputClaim ClaimTypeReferenceId="client_id" PartnerClaimType="clientId"  DefaultValue="{OIDC:ClientId}" />
        <!-- specify that we want both roles and groups back -->
        <InputClaim ClaimTypeReferenceId="scope" DefaultValue="roles groups" AlwaysUseDefaultValue="true" />
        </InputClaims>
        <OutputClaims>
        <OutputClaim ClaimTypeReferenceId="roles" />
        <OutputClaim ClaimTypeReferenceId="groups" />
        <OutputClaim ClaimTypeReferenceId="tenantId" DefaultValue="{Policy:TenantObjectId}" AlwaysUseDefaultValue="true" />
        </OutputClaims>
        <UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
    </TechnicalProfile>
    </TechnicalProfiles>
</ClaimsProvider>

Note that we are using to so called Claims Resolvers as a way to pass in the id of the current tenant ({Policy:TenantObjectId}) and the AppId ({OIDC:ClientId}). We also pass in a parameter named scope which is used to indicate to the Azure Functions that we want it to query for both group membership and user appRoleAssignements. In this example, we want to have both in the JWT access_token.

Then in file SignupOrSignin.xml we need to make sure the Azure Function is called and that we return the claims in the token. For this reason make the following two changes.

The first change is to change step 7 in the preconfigured SignUpOrSignin UserJourney to call our Azure Function. Then we renumber the SendClaims/JwtIssuer step to be step number 8.

  <UserJourneys>
    <UserJourney Id="SignUpOrSignIn">
      <OrchestrationSteps>
        <!-- get AppRoleAssignment -->
        <OrchestrationStep Order="7" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="GetUserAppRoleAssignment" TechnicalProfileReferenceId="GetUserAppRoleAssignment" />
          </ClaimsExchanges>
        </OrchestrationStep>

        <OrchestrationStep Order="8" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />
      </OrchestrationSteps>
      <ClientDefinition ReferenceId="DefaultWeb" />
    </UserJourney>
  </UserJourneys>

The second change is to add the claims to the JWT token, and that we do by adding the claims to OutputClaims

<OutputClaims>
    <OutputClaim ClaimTypeReferenceId="displayName" />
    <OutputClaim ClaimTypeReferenceId="givenName" />
    <OutputClaim ClaimTypeReferenceId="surname" />
    <OutputClaim ClaimTypeReferenceId="signInName" />                                         <!-- LocalAccount: whatever used to sign in with-->
    <OutputClaim ClaimTypeReferenceId="signInNames.emailAddress" PartnerClaimType="email" />  <!-- LocalAccount: email -->
    <OutputClaim ClaimTypeReferenceId="email" />                                              <!-- Other IDP: email -->
    <OutputClaim ClaimTypeReferenceId="groups" />
    <OutputClaim ClaimTypeReferenceId="roles" />        
    <OutputClaim ClaimTypeReferenceId="objectId" PartnerClaimType="sub" />
    <OutputClaim ClaimTypeReferenceId="identityProvider" />
    <OutputClaim ClaimTypeReferenceId="tenantId" AlwaysUseDefaultValue="true" DefaultValue="{Policy:TenantObjectId}" />
</OutputClaims>

With those changes, you can upload the B2C policies to your tenant.

Create your ASP.Net Core webapp

If you want something ready to git clone, you can use this official sample. If you prefer to create one from scratch, this is how you do it.

md AspnetCoreMsal-demo
cd AspnetCoreMsal-demo
dotnet new mvc --auth SingleOrg --client-id <AppId> --tenant-id <tenantId>

You need to update your appsettings.json to look something like this. TenantId and ClientId should be auto-filled, but Instance and Domain needs to be updated and SignupSignInPolicyId needs to be added.

  "AzureAdB2C": {
    "Instance": "https://yourtenant.b2clogin.com/",
    "Domain": "yourtenant.onmicrosoft.com",
    "TenantId": "...your TenantId...",
    "ClientId": "...your AppId...",
    "ClientSecret": "... your App secret ...",
    "SignUpSignInPolicyId": "B2C_1a_signup_signin",
    "CallbackPath": "/signin-oidc"
  },

Then change method ConfigureServices in Startup.cs to be as belo

public void ConfigureServices(IServiceCollection services)
{
services.AddMicrosoftIdentityWebAppAuthentication(Configuration, "AzureAdB2C");

    services.Configure<OpenIdConnectOptions>(
                OpenIdConnectDefaults.AuthenticationScheme, options =>
                {
                   options.TokenValidationParameters.RoleClaimType = "roles";
                });

    services.AddAuthorization(policies =>
    {
        policies.AddPolicy("TeamManager", p =>
        {
            p.RequireClaim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "TeamManager");
        });
    });
    services.AddControllersWithViews(options =>
    {
        var policy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
        options.Filters.Add(new AuthorizeFilter(policy));
    });
    services.AddRazorPages()
        .AddMicrosoftIdentityUI();
}

Then add a View named TeamManager.cshtml and make sure the file contents look like this.

@{
    ViewData["Title"] = "Team Manager";
}
<h1>@ViewData["Title"]</h1>

<p>This is a page only for users with the role of Team Managers</p>

In file HomeController.cs, add this so we navigate to the Team Manager page.

[Authorize(Policy = "TeamManager")]
public IActionResult TeamManager() {
       return View();
}

And finally, in _Layout.cshtml, add the menu item for Team Managers after the Privacy menu item. It will be conditional and only show for logged in users with the role TeamManager.

<li class="nav-item">
    <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>
@if (User.Identity.IsAuthenticated && User.HasClaim(System.Security.Claims.ClaimTypes.Role, "TeamManager")) {
<li class="nav-item">
    <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="TeamManager">Team Manager</a>
</li>
}

Then, build your Visual Studio project and run it. I do not run it via IISExpress. If you do, you need to change the redirect uri as the port number will not be the same.