Securing ASP.NET Core Razor Pages, Web APIs with Azure B2C external and Azure AD internal identities

This article shows how to implement an ASP.NET Core Razor page to authenticate against Azure B2C and use Web APIs from a second ASP.NET Core application which are also protected using Azure B2C App registrations. Azure B2C uses the signin, signup user flow and allows identities to authenticate using an Azure AD single tenant. Two APIs are implemented, one for users and one for administrators. Only identities from the Azure AD tenant can use the administrator API. The authorization implementation which forces this, is supported using an ASP.NET Core policy with a handler.

Code: https://github.com/damienbod/azureb2c-fed-azuread

Setup

The ASP.NET Core applications only use Azure B2C to authenticate and authorize. An ASP.NET Core Razor page application is used for the UI, but this can be any SPA, Blazor app or whatever the preferred tech stack is. The APIs are implemented using ASP.NET Core and this uses Azure B2C to validate and authorize the access tokens. The application accepts two different access tokens from the same Azure B2C identity provider. Each API has a separate scope from the associating Azure App registration. The Admin API uses claims specific to the Azure AD identity to authorize only Azure AD internal users. Other identities cannot use this API and this needs to be validated. The Azure B2C identity provider federates to the Azure AD single tenant App registration.

Setup Azure B2C App registrations

Three Azure App registrations were created in Azure B2C to support the setup above. Two for the APIs and one for the UI. The API Azure App registrations are standard with just a scope definition. The scope access_as_user was exposed in both and the APIs can be used for user access.

The UI Azure App registration is setup to use an Azure B2C user flow and will have access to both APIs. You need to select the options with the user flows.

Add the APIs to the permissions of the Azure app registration for the UI application.

Setup Azure AD App registration

A single tenant Azure App registration needs to be created in the Azure AD for the internal or admin users. The redirect URL for this is https://”your-tenant-specific-path”/oauth2/authresp. This will be used from an Azure B2C social login using the Open ID Connect provider. You also need to define a user secret and use this later. At present only secrets can be defined in this UI. This is problematic because the secrets have a max expiry of 2 years, if defining this in the portal.

Setup Azure B2C identity provider

A custom identity provider needs to be created to access the single tenant Azure AD for the admin identity authentication. Select the Identity providers in Azure B2C and create a new Open ID Connect custom IDP. Add the data to match the Azure App registration created in the previous step.

Setup Azure B2C user flow

Now a signin, signup user flow can be created to implement the Azure B2C authentication process and the registration process. This will allow local Azure B2C guest users and also the internal administrator users from the Azure AD tenant. The idp claim is required and idp_access_token claim if you require user data from the Azure AD identity. Add the required claims when creating the user flow. The claims can be added when creating the user flow in the User attributes and token claims section. Select the custom Open ID Connect provider and add this to the flow as well.

The user flow is now setup. The Azure App registrations can now be used to login and use either API as required. The idp and the idp_access_token are added for the Azure AD sign-in and this can be validated when using the admin API.

Implementing ASP.NET Core Razor page with Microsoft.Identity.Web

The ASP.NET Core application is secured using the Microsoft.Identity.Web and the Microsoft.Identity.Web.UI Nuget packages. These packages implement the Open ID connect clients and handles the Azure B2C specific client handling. The AddMicrosoftIdentityWebAppAuthentication method is used to add this and the AzureAdB2C configuration is defined to read the configuration from the app.settings, user secrets, key vault or whatever deployment is used. The rest is standard ASP.NET Core setup.

public void ConfigureServices(IServiceCollection services)
{
 services.AddTransient<AdminApiOneService>();
 services.AddTransient<UserApiOneService>();
 services.AddHttpClient();

 services.AddOptions();

 string[] initialScopes = Configuration.GetValue<string>("UserApiOne:ScopeForAccessToken")?.Split(' ');

 services.AddMicrosoftIdentityWebAppAuthentication(Configuration, "AzureAdB2C")
  .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
  .AddInMemoryTokenCaches();

 services.AddRazorPages().AddMvcOptions(options =>
 {
  var policy = new AuthorizationPolicyBuilder()
   .RequireAuthenticatedUser()
   .Build();
  options.Filters.Add(new AuthorizeFilter(policy));
 }).AddMicrosoftIdentityUI();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
 if (env.IsDevelopment())
 {
  app.UseDeveloperExceptionPage();
 }
 else
 {
  app.UseExceptionHandler("/Error");
  app.UseHsts();
 }

 app.UseHttpsRedirection();
 app.UseStaticFiles();

 app.UseRouting();

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

 app.UseEndpoints(endpoints =>
 {
  endpoints.MapRazorPages();
  endpoints.MapControllers();
 });
}

The Micorosoft.Identity.Web package uses the AzureAdB2C settings for the configuration. This example is using Azure B2C and the configuration for Azure B2C is different to an Azure AD configuration. The Instance MUST be set to domain of the Azure B2C tenant and the SignUpSignInPolicyId must be set to use the user flow as required. A signin, signup user flow is used here. The rest is common for be Azure AD and Azure B2C settings. The ScopeForAccessToken matches the two Azure App registrations created for the APIs.

"AzureAdB2C": {
 "Instance": "https://b2cdamienbod.b2clogin.com",
 "ClientId": "8cbb1bd3-c190-42d7-b44e-42b20499a8a1",
 "Domain": "b2cdamienbod.onmicrosoft.com",
 "SignUpSignInPolicyId": "B2C_1_signup_signin",
 "TenantId": "f611d805-cf72-446f-9a7f-68f2746e4724",
 "CallbackPath": "/signin-oidc",
 "SignedOutCallbackPath ": "/signout-callback-oidc"
},
"UserApiOne": {
 "ScopeForAccessToken": "https://b2cdamienbod.onmicrosoft.com/723191f4-427e-4f77-93a8-0a62dac4e080/access_as_user",
 "ApiBaseAddress": "https://localhost:44395"
},
"AdminApiOne": {
 "ScopeForAccessToken": "https://b2cdamienbod.onmicrosoft.com/5f4e8bb1-3f4e-4fc6-b03c-12169e192cd7/access_as_user",
 "ApiBaseAddress": "https://localhost:44395"
},

The Admin Razor page uses the AuthorizeForScopes to authorize for the API it uses. This Razor page uses the API service to access the admin API. No authorization is implemented in the UI to validate the identity. Normally the page would be hidden if the identity is not an administrator , I left this out in so that it is easier to validate this in the API as this is only a demo.

namespace AzureB2CUI.Pages
{
    [AuthorizeForScopes(Scopes = new string[] { "https://b2cdamienbod.onmicrosoft.com/5f4e8bb1-3f4e-4fc6-b03c-12169e192cd7/access_as_user" })]
    public class CallAdminApiModel : PageModel
    {
        private readonly AdminApiOneService _apiService;

        public JArray DataFromApi { get; set; }
        public CallAdminApiModel(AdminApiOneService apiService)
        {
            _apiService = apiService;
        }

        public async Task OnGetAsync()
        {
            DataFromApi = await _apiService.GetApiDataAsync().ConfigureAwait(false);
        }
    }
}

The API service uses the ITokenAcquisition to get an access token for the defined scope. If the identity and the Azure App registration are authorized to access the API, then an access token is returned for the identity. This is sent using a HttpClient created using the IHttpClientFactory interface.

using Microsoft.Extensions.Configuration;
using Microsoft.Identity.Web;
using Newtonsoft.Json.Linq;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace AzureB2CUI
{
    public class AdminApiOneService
    {
        private readonly IHttpClientFactory _clientFactory;
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly IConfiguration _configuration;

        public AdminApiOneService(IHttpClientFactory clientFactory, 
            ITokenAcquisition tokenAcquisition, 
            IConfiguration configuration)
        {
            _clientFactory = clientFactory;
            _tokenAcquisition = tokenAcquisition;
            _configuration = configuration;
        }

        public async Task<JArray> GetApiDataAsync()
        {

            var client = _clientFactory.CreateClient();

            var scope = _configuration["AdminApiOne:ScopeForAccessToken"];
            var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope }).ConfigureAwait(false);

            client.BaseAddress = new Uri(_configuration["AdminApiOne:ApiBaseAddress"]);
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            var response = await client.GetAsync("adminaccess").ConfigureAwait(false);
            if (response.IsSuccessStatusCode)
            {
                var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
                var data = JArray.Parse(responseContent);

                return data;
            }

            throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
        }
    }
}

Implementing the APIs with Microsoft.Identity.Web

The ASP.NET Core project implements the two separate APIs using separate authentication schemes and policies. The AddMicrosoftIdentityWebApiAuthentication method configures services for the user API using the default JWT scheme “Bearer” and the second scheme is setup for the “BearerAdmin” JWT bearer auth for the admin API. All API calls require an authenticated user which is setup in the AddControllers using a global policy. The AddAuthorization method is used to add an authorization policy for the admin API. The IsAdminHandler handler is used to fulfil the IsAdminRequirement requirement.

public void ConfigureServices(IServiceCollection services)
{
 services.AddHttpClient();

 services.AddOptions();

 JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
 // IdentityModelEventSource.ShowPII = true;

 services.AddMicrosoftIdentityWebApiAuthentication(
  Configuration, "AzureB2CUserApi");

 services.AddMicrosoftIdentityWebApiAuthentication(
    Configuration, "AzureB2CAdminApi", "BearerAdmin");

 services.AddControllers(options =>
 {
  var policy = new AuthorizationPolicyBuilder()
   .RequireAuthenticatedUser()
    // disabled this to test with users that have no email (no license added)
       // .RequireClaim("email")
   .Build();
  options.Filters.Add(new AuthorizeFilter(policy));
 });

 services.AddSingleton<IAuthorizationHandler, IsAdminHandler>();

 services.AddAuthorization(options =>
 {
  options.AddPolicy("IsAdminRequirementPolicy", policyIsAdminRequirement =>
  {
   policyIsAdminRequirement.Requirements.Add(new IsAdminRequirement());
  });
 });
}

The IsAdminHandler class checks for the idp claim and validates that the single tenant we require for admin identities is used to sign-in. The access token needs to be validated that the token was issued by our Azure B2C and that it has the correct scope. Since this is done in using the Microsoft.Identity.Web attributes, we don’t need to do this here.

public class IsAdminHandler : AuthorizationHandler<IsAdminRequirement>
{
 protected override Task HandleRequirementAsync(
  AuthorizationHandlerContext context, IsAdminRequirement requirement)
 {
  if (context == null)
   throw new ArgumentNullException(nameof(context));
  if (requirement == null)
   throw new ArgumentNullException(nameof(requirement));

  var claimIdentityprovider = context.User.Claims.FirstOrDefault(t => t.Type == "idp");

  // check that our tenant was used to signin
  if (claimIdentityprovider != null 
   && claimIdentityprovider.Value == 
    "https://login.microsoftonline.com/7ff95b15-dc21-4ba6-bc92-824856578fc1/v2.0")
  {
   context.Succeed(requirement);
  }

  return Task.CompletedTask;
 }
}

The AdminAccessController class is used to provide the admin data for admin identities. The BearerAdmin scheme is required and the IsAdminRequirementPolicy policy. The access token admin scope is also validated.

[Authorize(AuthenticationSchemes = "BearerAdmin", Policy = "IsAdminRequirementPolicy")]
[AuthorizeForScopes(Scopes = new string[] { "api://5f4e8bb1-3f4e-4fc6-b03c-12169e192cd7/access_as_user" })]
[ApiController]
[Route("[controller]")]
public class AdminAccessController : ControllerBase
{
 [HttpGet]
 public List<string> Get()
 {
  string[] scopeRequiredByApi = new string[] { "access_as_user" };
  HttpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi);

  return new List<string> { "admin data" };
 }
}

The user API also validates the access token, this time using the default Bearer scheme. No policy is required here, so only the default global authorization filter is used. The user API scope is validated.

[Authorize(AuthenticationSchemes = "Bearer")]
[AuthorizeForScopes(Scopes = new string[] { "api://723191f4-427e-4f77-93a8-0a62dac4e080/access_as_user" })]
[ApiController]
[Route("[controller]")]
public class UserAccessController : ControllerBase

When the application is run, the Azure B2C user flow is used to authenticate and internal or external users can sign-in, sign-up. This view can be customized to match your styles.

Admins can use the admin API and the guest users can use the user APIs.

Notes

This works but it can be improved and there are other ways to achieve this setup. If you require only a subset of identities from the Azure AD tenant, an enterprise app can be used to define the users which can use the Azure AD App registration. Or you can do this with an Azure group and assign this to the app and the users to the group.

You should also force MFA in the application for admins by validating the claims in the token and also the client ID which the token was created for. (as well as in the Azure AD tenant.)

Azure B2C is still using version one access tokens and seems and the federation to Azure AD does not use PKCE.

The Open ID Connect client requires a secret to access the Azure AD App registration. This can only be defined for a max of two years and it is not possible to use managed identities or a certificate. This means you would need to implement a secret rotation script or something so that to solution does not stop working. This is not ideal in Azure and is solved better in other IDPs. It should be possible to define long living secrets using the Powershell module and you update then with every release etc.

It would also be possible to use the Graph API to validate the identity accessing the admin API, user API.

Azure B2C API connectors could also be used to add extra claims to the tokens for usage in the application.

Links:

https://docs.microsoft.com/en-us/azure/active-directory-b2c/overview

https://docs.microsoft.com/en-us/azure/active-directory-b2c/identity-provider-azure-ad-single-tenant?pivots=b2c-user-flow

https://github.com/AzureAD/microsoft-identity-web

https://docs.microsoft.com/en-us/azure/active-directory/develop/microsoft-identity-web

https://docs.microsoft.com/en-us/azure/active-directory-b2c/identity-provider-local

https://docs.microsoft.com/en-us/azure/active-directory/

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/azure-ad-b2c

https://github.com/azure-ad-b2c/azureadb2ccommunity.io

https://github.com/azure-ad-b2c/samples

This content was originally published here.

Categories: Mobile App
vinova: