Tutorial: How to call a protected web API with an application permission token in Azure AD B2C

Introduction

This post covers an end to end scenario where a front end console application authenticates to Azure AD B2C using client credentials OAuth2 grant flow and calls a .Net backend web API. The samples in this post are built on .Net 6 framework.

App Registrations

There are 2 App Registrations required in this tutorial: a front-end console app and a back-end web API. The samples also require either a user flow or a custom policy. I use a “sign-up or sign-in” user flow for both applications.

Back-end web API:

Follow steps 2.1 and 2.2 from this documentation to register and expose an application scope (permission) for the web API. For my web API sample, I have the following app roles defined:

Take note of the Application ID URI of the Web API in the “Expose an API” section since this is used in the console application.

Front-end console application:

Follow step 2 from this documentation to register a client application, create an application secret, and configure its API permission to add application permissions from the web API. Grant Admin Consent to those permissions.

Note: Azure AD B2C provides the application permissions in the access token’s ‘scp’ claim.

Application Code

Front-end Console application

Complete sample for the console app is here. The application uses MSAL.Net to authenticate to Azure AD B2C using client credentials grant flow and calls the web API

Below is the Program.cs file. Fill out line 6 to 11 with information from your console application registration. For the scopes in line 13, this is the web API scope in this format: “<web API Application ID URI>/.default”. The webApiUrl variable on line 14 is the web API’s protected endpoint, which can be called with a Bearer token in the Authorization header. The code has logging functionality commented out. You can enable logging to gain insight if you encounter any problems with AAD Authentication.

// See https://aka.ms/new-console-template for more information
using Microsoft.Identity.Client;
using System.Net.Http.Headers;
using File = System.IO.File;

var clientId = "<Client App ID>";
var clientSecret = "<Client App Secret>";
var tenantName = "<tenant>.onmicrosoft.com";  //for example "contosob2c.onmicrosoft.com"
var PolicySignUpSignIn = "<sign-up or sign-in userflow>"; //for example "b2c_1_susi"
var AuthorityBase = $"https://<tenant>.b2clogin.com/tfp/{tenantName}/";  // for example "https://contosob2c.b2clogin.com/tfp/contosob2c.onmicrosoft.com/"
var Authority = $"{AuthorityBase}{PolicySignUpSignIn}";

string[] scopes = { $"<web API URI>/.default" };
string webApiUrl = "https://localhost:7137/WeatherForecast";
string accessToken = "";

// for logging purpose
/*
void Log(LogLevel level, string message, bool containsPii)
{
    string logs = $"{level} {message}{Environment.NewLine}";
    File.AppendAllText(System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalLogs.txt", logs);
}
*/

IConfidentialClientApplication app;
app = ConfidentialClientApplicationBuilder.Create(clientId)
               .WithClientSecret(clientSecret)
               .WithB2CAuthority(Authority)
               // for logging purpose
               // .WithLogging(Log, LogLevel.Verbose, true)
               .Build();

try
{
    var result = await app.AcquireTokenForClient(scopes).ExecuteAsync();
    accessToken = result.AccessToken;
}
catch (Exception ex)
{
    Console.WriteLine (ex.Message);
    Console.WriteLine(ex.StackTrace);

}


var httpClient = new HttpClient();
HttpResponseMessage response;
try
{
    var request = new HttpRequestMessage(HttpMethod.Get, webApiUrl);
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    response = await httpClient.SendAsync(request);
    var content = await response.Content.ReadAsStringAsync();
    Console.WriteLine("Http Response Code: " + response.StatusCode.ToString());
    Console.WriteLine("Http Respone: " + content);
}
catch (Exception ex)
{
   Console.WriteLine(ex.ToString());
}

Web API

Use the following steps to create a .Net 6 web API with Azure AD B2C authentication. Documentation for dotnet utility is here

  1. from the command prompt run ‘dotnet new webapi -au IndividualB2C -f net6.0 -o “c:\temp\b2cwebapi”‘ to create a web API project
  2. In the appsettings.json file change the section named from “AzureAd” to “AzureAdB2C”
  3. Fill out the AzureAdB2C section with information from the web API app registration. Fill out the scopes section with any permissions exposed by the web API. These are in the token’s ‘scp’ claim.

The complete web API project is here.

The project has a protected controller with the following attributes:

[Authorize]
[ApiController]
[Route("[controller]")]
[RequiredScope(RequiredScopesConfigurationKey = "AzureAdB2C:Scopes")]
public class WeatherForecastController : ControllerBase

Here is the Program.cs file:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAdB2C"));

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

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

app.MapControllers();

app.Run();

Implementing SwaggerUI and API for Azure AD

Do not forget the basic principles of Open ID Connect and OAuth2. When you want to protect an API with OAuth2 and Azure AD, you must pass an access token that will be validated. So if you want to test with SwaggerUI, on accessing the API portion, SwaggerUI must be configured to authenticate, acquire an access token, and pass it to the API.

Before we get started, ensure you create two app registrations in Azure AD, one for the client i.e. SwaggerUI as a Single Page Application, and one for the API, i.e. your Swagger API.
https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app

Ensure on the API app registration, you have configured to expose an API..

https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-expose-web-apis

So to configure SwaggerUI…

builder.Services.AddSwaggerGen(options =>
{

    var AzureAdMetadata = builder.Configuration["SwaggerClient:Metadata"]!;

    options.AddSecurityDefinition("Oauth2", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        //Description = "Please enter a valid token",
        Name = "Authorization",
        Type = SecuritySchemeType.OpenIdConnect,
        BearerFormat = "JWT",
        Scheme = "Bearer",
        OpenIdConnectUrl = new Uri(AzureAdMetadata),
        
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Oauth2" }
            },
            new string[]{}
        }
    });
});

// ...

app.UseSwagger();
app.UseSwaggerUI(options =>
{
    options.OAuthClientId(builder.Configuration["SwaggerClient:ClientId"]);
    options.OAuth2RedirectUrl(builder.Configuration["SwaggerClient:RedirectUrl"]);
    options.OAuthScopes(new[] { builder.Configuration["SwaggerClient:Scopes"], "openid", "profile", "offline_access" });
    options.OAuthUsePkce();
    options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
    //options.RoutePrefix = string.Empty;
});

Don’t forget to add your redirect URI “https://YOUR_APP_PATH/oauth2-redirect.html” as a single page application reply address on the client application registration.

Your appsettings.json will look something like this…

{
  /*
The following identity settings need to be configured
before the project can be successfully executed.
For more info see https://aka.ms/dotnet-template-ms-identity-platform
*/
  "SwaggerAPI": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "your_domain.onmicrosoft.com",
    "TenantId": "YOUR_TENANT_ID",
    "ClientId": "YOUR_API_APP_ID",
    "Audience": "AUD_CLAIM_FROM_ACCESS_TOKEN"
  },
  "SwaggerClient": {
    "Metadata": "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0/.well-known/openid-configuration",
    "ClientId": "YOUR_CLIENT_APP_ID",
    "RedirectUrl": "https://YOUR_APP_PATH/oauth2-redirect.html",
    "Scopes": "SCOPE_VALUE_FOR_YOUR_API"
  },

And your authentication code if using Microsoft Identity Web would look like this…

builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration, "SwaggerAPI");

When all said and done, after launching your Swagger app, you should now see an Authorize button at the top of your page. This will load the “Swagger Client” to acquire the access token. Then, when you test your API calls from the Swagger UI, it will automatically pass the access token.

Adding multiple issuer and audience validation in C#

You have an custom developed Web app or Web API using Asp.Net or Asp.Net Core and you want to control which issuers have access to your app. There are a couple ways to do this.

Use Multiple Authentication schemes

One way to do this and is probably the most recommended way is to perform what is documented here…

https://github.com/AzureAD/microsoft-identity-web/wiki/multiple-authentication-schemes

In this solution, you’ll want to have different Web App or API endpoints for your different users based on the authentication scheme to be used. Otherwise meaning having different entry points.

We have another blog that talk more about how to implement multiple authentication schemes for B2C apps that need to handle multiple custom B2C policies…

https://blogs.aaddevsup.xyz/2022/10/how-to-resolve-idx10501-errors-in-a-b2c-microsoft-identity-web-application/

Simple Multiple Issuer validation

If you really want to stick to one entry point, here is another option.

If you have a simple scenario where the Identity Provider is Azure Active Directory and the same signing keys can be used, you can simply add a list of Issuers you can validate… (Keep in mind each tenant in Azure AD will have a different issuer value)

# Add AAD JWT Bearer Authentication
builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration);

builder.Services.Configure<JwtBearerOptions>(options =>
{ 
  var tvp = new TokenValidationParameters
  {
    ValidIssuers =  builder.Configuration.GetSection("AzureAd:ValidIssuers").Get<List<string>>(),
  };

  options.TokenValidationParameters = tvp;
});

Your appsettings.json should look something like this…

{
  "AzureAd": {
    //... 
    // Other Settings
    //...

    "ValidIssuers": [
      // Validate AAD v2 tokens
      "https://login.microsoftonline.com/{TENANT_1}/v2.0/", 
      "https://login.microsoftonline.com/{TENANT_2}/v2.0/" 
      // Validate AAD v1 tokens
      "https://sts.windows.net/{TENANT_1}/", 
      "https://sts.windows.net/{TENANT_2}/"
    ]
}

Complex Multiple Issuer Validation (and signature validation)

When you want to validate multiple tokens from different issuers and the signing keys are different, this gets tricky. Now you have to consider how are you going to discover the signing keys.

The following code snippet is just an example of custom logic you could build…

builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{ 
    // IdentityModelEventSource.ShowPII is used for debugging
    IdentityModelEventSource.ShowPII = true;

    var tvp = new TokenValidationParameters
    {
        # Event handler for deciding which signing keys to use
        IssuerSigningKeyResolver = (s, token, kid, tvp) => {
            IConfigurationManager<OpenIdConnectConfiguration> configManager = null!;

            // Support AAD/B2C
            if (token.Issuer.Contains("login.microsoftonline.com") || token.Issuer.Contains("sts.windows.net") || token.Issuer.Contains("b2clogin.com"))
            {
                configManager = new ConfigurationManager<OpenIdConnectConfiguration>($"{token.Issuer}.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever());
            }

            // Support other IdP
            else
            {
                configManager = new ConfigurationManager<OpenIdConnectConfiguration>(builder.Configuration["OtherIdp:Metadata"], new OpenIdConnectConfigurationRetriever());
            }

            OpenIdConnectConfiguration config = configManager.GetConfigurationAsync(System.Threading.CancellationToken.None).GetAwaiter().GetResult();
            return config.SigningKeys;
        },
        ValidAudiences =  builder.Configuration.GetSection("OtherIdp:ValidAudiences").Get<List<string>>(),
        ValidIssuers = builder.Configuration.GetSection("OtherIdp:ValidIssuers").Get<List<string>>(),
    };

Multiple Audience Validation

If your tokens are coming with different “aud” claim, you can add additional Audience validations…

builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration, "AzureAd");

builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{ 
  //...
  // Other token validation parameters
  //...

  var tvp = new TokenValidationParameters
  {
    //...
    // Other token validation parameters
    //...

    ValidAudiences =  builder.Configuration.GetSection("AzureAd:ValidAudiences").Get<List<string>>(),
  };

  options.TokenValidationParameters = tvp;

Your appsettings.json should look something like this…

{
  "AzureAd": {
    //... 
    // Other Settings
    //...

    "ValidAudiences": [
      "api://c60eeff9-1329-4ddb-ba4a-7e6555391c4d",
      "c60eeff9-1329-4ddb-ba4a-7e6555391c4d",
      "api://e92f9573-6f44-46b2-a4a9-1936d5f16ab5",
      "e92f9573-6f44-46b2-a4a9-1936d5f16ab5",
    ]
}

Add Azure AD roles claim support in WebAssembly Authentication

You are developing a WebAssembly authentication app and trying to implement Roles based access control. You are getting a similar error like…

  • You are not authorized to access this resource.
  • Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]Authorization failed. These requirements were not met: RolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (ROLE_NAME)

The WebAssembly Authentication stack appears to cast the roles claim into a single string. We need this User Factory to modify its behavior so that each role has its own unique value.

Create the Custom User Factory

First, create a custom User Factory (CustomUserFactory.cs)…

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using System.Security.Claims;
using System.Text.Json;

public class CustomUserFactory : AccountClaimsPrincipalFactory<RemoteUserAccount>
{
    public CustomUserFactory(IAccessTokenProviderAccessor accessor)
        : base(accessor)
    {
    }

    public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
        RemoteUserAccount account,
        RemoteAuthenticationUserOptions options)
    {
        var user = await base.CreateUserAsync(account, options);
        var claimsIdentity = (ClaimsIdentity?)user.Identity;

        if (account != null && claimsIdentity != null)
        {
            MapArrayClaimsToMultipleSeparateClaims(account, claimsIdentity);
        }

        return user;
    }

    private void MapArrayClaimsToMultipleSeparateClaims(RemoteUserAccount account, ClaimsIdentity claimsIdentity)
    {
        foreach (var prop in account.AdditionalProperties)
        {
            var key = prop.Key;
            var value = prop.Value;
            if (value != null && (value is JsonElement element && element.ValueKind == JsonValueKind.Array))
            {
                // Remove the Roles claim with an array value and create a separate one for each role.
                claimsIdentity.RemoveClaim(claimsIdentity.FindFirst(prop.Key));
                var claims = element.EnumerateArray().Select(x => new Claim(prop.Key, x.ToString()));
                claimsIdentity.AddClaims(claims);
            }
        }
    }
}

Add the roles mapping and CustomUserFactory to your authentication Middleware

If you’re using AddOidcAuthentication…

builder.Services.AddOidcAuthentication(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions);
    options.ProviderOptions.AdditionalProviderParameters.Add("domain_hint", "contoso.com");
    options.ProviderOptions.DefaultScopes.Add("User.Read");
    options.UserOptions.RoleClaim = "roles";
    options.ProviderOptions.ResponseType = "code";
}).AddAccountClaimsPrincipalFactory<CustomUserFactory>();

If you’re using AddMsalAuthentication…

builder.Services.AddMsalAuthentication(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
    options.ProviderOptions.AdditionalScopesToConsent.Add("user.read");
    options.ProviderOptions.DefaultAccessTokenScopes.Add("api://{your-api-id}");
    options.UserOptions.RoleClaim = "roles";
}).AddAccountClaimsPrincipalFactory<CustomUserFactory>();

Protect your page

Now you can add the Authorize attribute to your blazor pages…

@attribute [Authorize(Roles="access_as_user")]

More Information

To learn more about WebAssembly Authentication…

https://learn.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/standalone-with-authentication-library?view=aspnetcore-7.0&tabs=visual-studio

Sample that has this solution implemented…

https://github.com/willfiddes/aad-webassembly-auth

Don’t forget to add App Roles to your app registration, add your user to the app role, and configure your application to use the app role you have created.

How to resolve “No account or login hint was passed to the AcquireTokenSilent” with a Web App and no persistent token cache

You have implemented Microsoft Authentication Library or Microsoft Identity Web and now you are seeing the following error message:

No account or login hint was passed to the AcquireTokenSilent

The root cause is because the Token Cache is empty when you are trying to acquire a token silently when account was attempted to be pulled from MSAL.

So on Web Applications like Asp.Net or Asp.Net Core, this is generally when the user is still passing a authentication cookie to the web app but the web app has restarted or their is high memory usage where the memory gets cleared out therefore the MSAL token cache is empty. Also keep in mind, MSAL does have a default cache size where MSAL will start clearing old entries from its cache.

There are a few solutions to this. We would recommend implementing persistent token cache…

Either implement a persistent cache using something like SQL server or file based storage…
https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-acquire-cache-tokens#:~:text=MSAL%20caches%20a%20token%20after%20it%27s%20been%20acquired.,the%20session%20cookie%20that%27s%20in%20the%20browser%2C%20however.

However in this article we will focus on how to reject the authentication cookie.

Asp.Net

You can implement a Cookie Authentication event to check if the currently signed in user is in the MSAL token cache and if they are not, reject the cookie and force them to sign in again.

The following implementation does depend you have the following helpers:

https://github.com/Azure-Samples/ms-identity-aspnet-webapp-openidconnect/blob/master/WebApp/Utils/MsalAppBuilder.cs

https://github.com/Azure-Samples/ms-identity-aspnet-webapp-openidconnect/blob/master/WebApp/Utils/AuthenticationConfig.cs

And the Microsoft.Identity.Web.TokenCache nuget package installed.

app.UseCookieAuthentication(new CookieAuthenticationOptions
{
    Provider = new CookieAuthenticationProvider()
    {
        OnValidateIdentity = async context =>
        {
            // To Resolve: No account or login hint was passed to the AcquireTokenSilent call.
            IConfidentialClientApplication clientApp = MsalAppBuilder.BuildConfidentialClientApplication();

            var signedInUserIdentity = new ClaimsPrincipal(context.Identity);
            if (await clientApp.GetAccountAsync(signedInUserIdentity.GetAccountId()) == null)
            {
                context.RejectIdentity();
            }
        }
    }
});

Asp.Net Core

First, create the custom Cookie Authentication event with the logic to check if the user can acquire a token successfully…

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Identity.Client;
using System.Threading.Tasks;
using System.Security.Claims;

namespace SampleApp.Services
{
    internal class RejectSessionCookieWhenAccountNotInCacheEvents : CookieAuthenticationEvents
    {
       
     
        public async override Task ValidatePrincipal(CookieValidatePrincipalContext context)
        {

            // Get the registered service that implements MSAL assuming you have one
            var msalInstance = context.HttpContext.RequestServices.GetRequiredService<IMsalService>();

            // Get the MSAL IConfidentialClientApplication object
            IConfidentialClientApplication msalClient = msalInstance.GetClient();

            var accounts = await msalClient.GetAccountsAsync();

            // Get the signed in user account from MSAL if there is one
            var account = await msalClient.GetAccountAsync(accounts.FirstOrDefault());

            // If no account then reject the authentication cookie
            if (account == null)
            {
                context.RejectPrincipal();
            }

            await base.OnValidatePrincipal(context);
        }
    }
}

Finally, register your Custom Cookie Authentication event…

// Register the Custom Cookie Authentication event
Services.Configure<CookieAuthenticationOptions>(cookieScheme, options=>options.Events=new RejectSessionCookieWhenAccountNotInCacheEvents());

Microsoft Identity Web


A good solution is already implemented in Microsoft Identity Web and you simply need to implement the guidance here…
https://github.com/AzureAD/microsoft-identity-web/wiki/Managing-incremental-consent-and-conditional-access

If the above article does not work, you can manually clear the authentication cookie.

First, create the custom Cookie Authentication event with the logic to check if the user can acquire a token successfully…

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace aad_ms_id_web_mvc_app.Services
{
    internal class RejectSessionCookieWhenAccountNotInCacheEvents : CookieAuthenticationEvents
    {
        public async override Task ValidatePrincipal(CookieValidatePrincipalContext context)
        {
            try
            {
                var tokenAcquisition = context.HttpContext.RequestServices.GetRequiredService<ITokenAcquisition>();
                string token = await tokenAcquisition.GetAccessTokenForUserAsync(
                    scopes: new[] { "profile" },
                    user: context.Principal);
            }
            catch (MicrosoftIdentityWebChallengeUserException ex)
               when (AccountDoesNotExistInTokenCache(ex))
            {
                context.RejectPrincipal();
            }
        }

        /// <summary>
        /// Is the exception thrown because there is no account in the token cache?
        /// </summary>
        /// <param name="ex">Exception thrown by <see cref="ITokenAcquisition"/>.GetTokenForXX methods.</param>
        /// <returns>A boolean telling if the exception was about not having an account in the cache</returns>
        private static bool AccountDoesNotExistInTokenCache(MicrosoftIdentityWebChallengeUserException ex)
        {
            return ex.InnerException is MsalUiRequiredException
                                      && (ex.InnerException as MsalUiRequiredException).ErrorCode == "user_null";
        }
    }
}

Finally, register the Custom Cookie Authentication event…

// Add Microsoft Identity Web
services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
                    .AddMicrosoftIdentityWebApp(Configuration.GetSection("AzureAd"))
                        .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
                           .AddMicrosoftGraph(Configuration.GetSection("GraphBeta"))
                           .AddInMemoryTokenCaches();

// Register the Custom Cookie Authentication event
Services.Configure<CookieAuthenticationOptions>(cookieScheme, options=>options.Events=new RejectSessionCookieWhenAccountNotInCacheEvents());