‘Update your browser’ message when using apps that leverage ADAL/MSAL

You might see the following message…

Update your browser
Your browser is not supported or up-to-date. Try updating it, or else download and install the latest version of Microsoft Edge.
You could also try to access https://aka.ms/mysecurityinfo from another device.

As a quick solution for the user.  Have the user register for MFA ahead of time before using the app. Simply open a supported Browser like Edge or Chrome and have the user navigate to…

https://myapps.microsoft.com

Overview

You might see this when your using an App that integrate or have underlying components that depend on ADAL or MSAL. The solution will depend on the developers implementation. Entra ID Multifactor and self-service password reset registration wizard does not support older versions of Internet Explorer.

ADAL and MSAL when using embedded browser in lower version of .NET uses WinForms which is based on IE 7 components. The recommended solution would be to migrate to MSAL and use the Broker.

Developer or application vendor will need to…

First and foremost, upgrade to the latest version of MSAL.

There is no solution for ADAL

Preferably, enable Web Account Manager (WAM) support with MSAL…
https://learn.microsoft.com/en-us/entra/identity-platform/scenario-desktop-acquire-token-wam

var pca = PublicClientApplicationBuilder.Create("client_id").WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows))

If for some reason WAM can’t be used (for example on Windows Server), then you can use WebView2 (based on Edge). To do this, the application must target the framework .net6+ Windows (this is configured on the project file)…

<TargetFramework>net6.0-windows10.0.22621.0</TargetFramework>

If your unable to use WAM or can’t target .net 6+ Windows, then use the system browser…
https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/using-web-browsers#how-to-use-the-default-system-browser

var result = await pca.AcquireTokenInteractive(s_scopes).WithUseEmbeddedWebView(false)

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();

Troubleshooting CORS to Azure AD/Entra ID

You are developing an app and see one of the following CORS related errors in the console logs…

  • Access to XMLHttpRequest at ‘https://login.microsoftonline.com/tenant_id/oauth2/v2.0/authorize?client_id=&redirect_uri=signin-oidc&response_type=id_token&scope=openid%20profile&response_mode=form_post&nonce=6370sdfj&state=sdfsdfds-sdfsdfsdf-sd-sdfsdf-T3qwNWW2jRHM&x-client-SKU=ID_NETSTANDARD2_0&x-client-ver=5.5.0.0‘ (redirected from ‘xxx’) from origin ‘yyyy’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.
  • Access to fetch at ‘https://contosob2c.b2clogin.com/tfp/tenant_id/b2c_1_v2_susi_defaultpage/v2.0/.well-known/openid-configuration‘ from origin ‘http://localhost:4200‘ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.
  • CORS policy: Response to preflight request doesn’t pass access control check: No ‘Access-Control-Allow-Origin’

Notice it starts with “https://login.microsoftonline.com“, you might have a Azure B2C scenario, so in that case it might start with “https://youdomain.b2clogin.com/…

It is outside of scope for this article If the error is not generated by Azure AD/Entra ID, and the error looks something like this…

Access to XMLHttpRequest at ‘https://app.contoso.com/…

We do not provide guidance on how to resolve CORS for external domains. More than likely, you will need to enable CORS in that environment.

The following articles provides “some” additional guidance…

Let’s get started

First we need to understand what is CORS…

Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources. CORS also relies on a mechanism by which browsers make a “preflight” request to the server hosting the cross-origin resource, in order to check that the server will permit the actual request. In that preflight, the browser sends headers that indicate the HTTP method and headers that will be used in the actual request.

For more information about CORS…

https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

Browsers will not allow Cross Origin requests when the resource does not have the supporting headers. Cross Origin requests originate from an JavaScript XMLHttpRequest which is kind of like a direct HTTP call with no user interaction or window. Azure AD/Entra ID does not have CORS enabled while performing an interactive sign-in which means you cannot send a CORS request to Azure AD/Entra ID.

Depending on the root cause, there may be different solutions. It would be best to collect a Fiddler capture to try to determine your scenario and the cause.

Within the Fiddler capture, look for your XMLHttpRequest request (This is usually or AJAX or JQuery call) and notice it’s a 302 redirect to “https://login.microsoftonline.com.com/…

The HTTP Request and response might look something like this… 

REQUEST
GET https://login.microsoftonline.com.com/domain.onmicrosoft.com/oauth2/v2.0/authorize?... HTTP/1.1
Host: login.microsoftonline.com
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Origin: https://app.domain.com

RESPONSE

HTTP/1.1 200 OK
Cache-Control: no-store, no-cache
Pragma: no-cache
Content-Type: text/html; charset=utf-8
Expires: -1
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-DNS-Prefetch-Control: on
P3P: CP="DSP CUR OTPi IND OTRi ONL FIN"
Set-Cookie:  ...
Referrer-Policy: strict-origin-when-cross-origin
Date: Tue, 24 Nov 2020 19:08:05 GMT
Content-Length: 194559
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
<!DOCTYPE html>
<html dir="ltr" class="" lang="en">
<head>

Notice there is a Origin header in the request and no ‘Access-Control-Allow-Origin’ header in the response.

We often see this when you try to pass a access token (or Authentication cookie) to the XMLHttpRequest endpoint, the security token whether it’s an access token or authentication cookie gets invalidated and the API redirects to the Azure AD/Entra ID sign-in page instead of throwing a proper 401 HTTP status code. Because of this redirect, and we do not support CORS, browser will throw a CORS error.

So, how do you resolve or workaround this?

Preferably, the solution would be for you to implement your application architecture to follow the OAuth2 and OIDC model. That is, the front end app acquires a access token and passes it in a authorization header to the API (this is what your making your XMLHttpRequest to). Please follow our samples…

https://learn.microsoft.com/en-us/azure/active-directory/develop/sample-v2-code?tabs=apptype#single-page-applications

Solutions to consider based on scenario

Not every scenario will be listed here as every environment and app architecture is different, however, these are the most common.

Scenario: Web App + Web API using authentication cookie

Otherwise meaning your app purely uses cookie authentication and does not follow the proper OAuth2 and Open ID Connect implementation. Your web app or framework is making XMLHttpRequest calls to its API endpoint and using the Web Apps authentication cookie.

When reviewing the Fiddler capture and looking at the XMLHttpRequest request, the XMLHttpRequest might look something like this (Notice the cookie)…

GET https://app.domain.com/… HTTP/1.1

Host: login.microsoftonline.com
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Origin: https://app.domain.com
Cookie: .AspNet.Cookies=xyz…

If your using Asp.Net or Asp.Net Core. You will need to customize your Azure AD/Entra ID Configuration to not use the token lifetime as the session lifetime.

For more information see…

You can configure the API authentication to thrown an error instead of performing a redirect. This is just an example if your using Asp.Net Core…

services.Configure<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{       
  options.Events.OnRedirectToIdentityProvider = (context) =>
  {
    if (!context.Request.Headers["Origin"].IsNullOrEmpty())
    {
      context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
      context.HandleResponse();
    }
                    
    return Task.FromResult(true);
  };
}

Then, you can implement additional XMLHttpRequest logic to check if the request is done and whether it was a redirect or a 401. You will need to handle this to tell the client to have the user sign-in again. In most cases, simply refreshing the page will allow the user re-authenticate. For example…

client.onreadystatechange = () => {
  // API call failed (401) or there was a redirect
  if ((client.readyState === client.DONE && client.responseURL == "") || client.Status == 401) {
    // Handle error such as Refreshing page should allow user to re-authenticate
    window.location.reload(true)
  }
};

Scenario: Standalone API using access token

When reviewing the Fiddler capture and looking at the XMLHttpRequest request, it might look something like this (Notice the Authorization header)…

GET https://app.domain.com/… HTTP/1.1

Host: login.microsoftonline.com
Connection: keep-alive
Authorization: Bearer eyJ0…
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Origin: https://app.domain.com

Send a valid token

If you are passing a access token to your API resource, ensure the token is valid in the first place. Check to see if the token is expired and if it is, request for a new access token.

If your using MSAL.js, then use acquireTokenSilent everytime to acquire a new token before passing it to your API. Do not cache this token yourself. Always use acquireTokenSilent to get the cached token directly from MSAL.

https://learn.microsoft.com/en-us/azure/active-directory/develop/scenario-spa-acquire-token?tabs=javascript2

Here is just a example of how it looks when passing a token to an API…

Build single-page app calling a web API – Microsoft Entra | Microsoft Learn

Use JWT Bearer authentication

Ensure JWT Bearer authentication is used (Not Open ID Connect authentication). This implementation will vary depending on the authentication middleware you are using. Please review the docs for your authentication middleware as each middleware has its own implementation strategy. JWT Bearer Authentication should throw a 401 error to the client if the token is not valid. The client should handle the error and request for a new token as needed. If Open ID Connect Authentication scheme is used, the API will attempt to redirect the request to Azure AD/Entra ID or B2C, and this is what will cause the CORS error and it is much harder for the client to handle this scenario.

Here are just a couple examples on how to set up JWT Bearer authentication (Look at the ToDoService/API portion of the samples)

https://learn.microsoft.com/en-us/azure/active-directory/develop/sample-v2-code?tabs=apptype#web-api

Scenario: If the issue is occurring while using MSAL.js to acquire a token

Make sure you configure, authority, knownAuthorities and protocolMode correctly if your using B2C or third-party IdP.

//…
import { ProtocolMode } from '@azure/msal-common';
//…
function MSALInstanceFactory(): IPublicClientApplication {
  return new PublicClientApplication({
    auth: {
      authority: 'https://contoso.b2clogin.com/tfp/655e51e9-be5e-xxxx-xxxx-38aa6558xxxx/b2c_1_susi/v2.0/',
      clientId: 'fb2ad7b7-2032-4a66-8723-e993eb4b9004',
      redirectUri: 'http://localhost:4200',
      knownAuthorities: ['contoso.b2clogin.com'],
      protocolMode: ProtocolMode.OIDC
    },
  });
}

For more information see…

https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md

Scenario: App behind a Load balancer

If your application is behind a load balancer, please check your load balancer for its session lifetime settings. This could be called Session persistence or session affinity.

Scenario: CORS error on Token endpoint

The only supported flow for Single Page Applications is Authorization Code flow with PKCE and Refresh Token flow while having the redirect address configured as Single Page Application.

Based on OAuth2 specs and Security best practices, you should NOT be using Resource Owner Password Credential flow or any of the Confidential Client flows i.e. Client Credentials or On-behalf-of flows.

All other flows will not be supported in Single Page Applications. Azure AD/Entra ID and B2C will not add the CORS headers for the unsupported flows.

Scenario: You are using Azure AD Application Proxy

Please see…
https://learn.microsoft.com/en-us/azure/active-directory/app-proxy/application-proxy-configure-complex-application

How to bundle consent

You have a custom client and a custom API. There is an application registration in Azure AD for each of these apps, one for the custom client, and one for the custom API. You want your users to be able to bundle the consent for these apps.

You might see one of the following errors…

  • AADSTS70000: The request was denied because one or more scopes requested are unauthorized or expired. The user must first sign in and grant the client application access to the requested scope
  • AADSTS650052: The app needs access to a service (\”{name}\”) that your organization \”{organization}\” has not subscribed to or enabled. Contact your IT Admin to review the configuration of your service subscriptions.
    • This is the old error replaced by the new error below
  • AADSTS650052: The app is trying to access a service\”{app_id}\”(\”app_name\”) that your organization %\”{organization}\” lacks a service principal for. Contact your IT Admin to review the configuration of your service subscriptions or consent to the application in order to create the required service principal

Step 1: Configure knownClientApplications for the API app registration

First, you will need to add the custom client app ID to the custom APIs app registration knownClientApplications property…

https://learn.microsoft.com/en-us/azure/active-directory/develop/reference-app-manifest#knownclientapplications-attribute

Step 2: Configure API permissions

Second, make sure all API permissions are configured correctly on the custom client and custom API app registrations. Also make sure that all of the custom API app registration API permissions are also added to the custom client app registration.

Step 3: The sign-in request

Third, your authentication request must use the .default scope. For Microsoft accounts, the scope must be for the custom API. For example. This will also work for school and work accounts.

https://login.microsoftonline.com/common/oauth2/v2.0/authorize
?response_type=code
&Client_id=72333f42-5078-4212-abb2-e4f9521ec76a
&redirect_uri=https://localhost
&scope=openid profile offline_access app_uri_id1/.default
&prompt=consent

However, the client will not be listed as having a permission for the API. This is ok because the client will be listed as knownClientApplications.

If you’re not concerned about supporting Microsoft Accounts, and will only be supporting work and school accounts, then use the following recommended approach. For example…

https://login.microsoftonline.com/common/oauth2/v2.0/authorize
?response_type=code
&Client_id=72333f42-5078-4212-abb2-e4f9521ec76a
&redirect_uri=https://localhost
&scope=openid profile offline_access User.Read https://graph.microsoft.com/.default
&prompt=consent 

The Implementation

If using MSAL.Net for example…

String[] consentScope = { "api://ae5a0bbe-d6b3-4a20-867b-c8d9fd442160/.default" };
var loginResult = await clientApp.AcquireTokenInteractive(consentScope)
    .WithAccount(account)
	 .WithPrompt(Prompt.Consent)
      .ExecuteAsync();

Keep in mind that consent propagation of new servicePrincipals and permissions may take a little time. Your applications must be able to handle this.

Acquiring tokens for more than one resource

If your custom client will also acquire tokens for another resource like Microsoft Graph, you will need to build logic as attempting to acquire these tokens immediately after the consent may fail.

  • Use the default scope
  • Track the acquired scopes until the required scope returns
  • Add a delay if the result still does not have the required scope.

Note: Currently, if acquireTokenSilent fails, MSAL will force you to perform a successful Interaction before it will allow you to use AcquireTokenSilent again, even if you have a valid refresh token to use.

Here is some sample code to handle this …

        public static async Task<AuthenticationResult> GetTokenAfterConsentAsync(string[] resourceScopes)
        {
            AuthenticationResult result = null;
            int retryCount = 0;

            int index = resourceScopes[0].LastIndexOf("/");

            string resource = String.Empty;

            // Determine resource of scope
            if (index < 0)
            {
                resource = "https://graph.microsoft.com";
            }
            else
            {
                resource = resourceScopes[0].Substring(0, index);
            }

            string[] defaultScope = { $"{resource}/.default" };

            string[] acquiredScopes = { "" };
            string[] scopes = defaultScope;
            
            while (!acquiredScopes.Contains(resourceScopes[0]) && retryCount <= 15)
            {
                try
                {
                    result = await clientApp.AcquireTokenSilent(scopes, CurrentAccount).WithForceRefresh(true).ExecuteAsync();
                    acquiredScopes = result.Scopes.ToArray();
                    if (acquiredScopes.Contains(resourceScopes[0])) continue;
                }
                catch (Exception e)
                { }

                // Switch scopes to pass to MSAL on next loop. This tricks MSAL to force AcquireTokenSilent after failure. This also resolves intermittent cachine issue in ESTS
                scopes = scopes == resourceScopes ? defaultScope : resourceScopes;
                retryCount++;

                // Obvisouly something went wrong
                if(retryCount==15)
                {
                    throw new Exception();
                }

                // MSA tokens do not return scope in expected format when .default is used
                int i = 0;
                foreach(var acquiredScope in acquiredScopes)
                {
                    if(acquiredScope.IndexOf('/')==0) acquiredScopes[i].Replace("/", $"{resource}/");
                    i++;
                }

                Thread.Sleep(2000);
            }

            return result;
        }

On the custom API using the On-behalf-of flow

Similarity, the custom API when trying to acquire tokens for another resource might fail immediately after consent.

while (result == null && retryCount >= 6)
            {
                UserAssertion assertion = new UserAssertion(accessToken);
                try
                {
                    result = await apiMsalClient.AcquireTokenOnBehalfOf(scopes, assertion).ExecuteAsync();
                    
                }
                catch { }

                retryCount++;

                if (result == null)
                {
                    Thread.Sleep(1000 * retryCount * 2);
                }
            }

If (result==null) return new HttpStatusCodeResult(HttpStatusCode.Forbidden, "Need Consent");

If this continues to fail and you run out of retries, its probably a good time to throw an error to the client and have it perform a full consent.

Example of client code assuming your API throws a 403…

HttpResponseMessage apiResult = null;
apiResult = await MockApiCall(result.AccessToken);

if(apiResult.StatusCode==HttpStatusCode.Forbidden)
{
  var authResult = await clientApp.AcquireTokenInteractive(apiDefaultScope)
    .WithAccount(account)
    .WithPrompt(Prompt.Consent)
    .ExecuteAsync();
  CurrentAccount = authResult.Account;

  // Retry API call
  apiResult = await MockApiCall(result.AccessToken); 
}          

Recommendations and expected behavior

Building an app for handling bundled consent is not as straight forward. Preferably you have a separate process you can walk your users through to perform this bundled consent, provision your app and API within their tenant or on their Microsoft Account and only get the consent experience once. (Separate from actually signing into the app.) If you don’t have this process and trying to build it into your app and your sign in experience, it gets messy and your users will have multiple consent prompts. I would recommend that you build a experience within your app that warns users they may get prompted to consent (multiple times).

For Microsoft Accounts, I would expect minimum of two consent prompts. One for the application, and one for the API.

For work and school accounts, I would expect only one consent prompt. Azure AD handles bundled consent much better than Microsoft Accounts.

Here is a end to end example sample of code. This has a pretty good user experience considering trying to support all account types and only prompting consent if required. Its not perfect as perfect is virtually non-existent.

string[] msGraphScopes = { "User.Read", "Mail.Send", "Calendar.Read" }
String[] apiScopes = { "api://ae5a0bbe-d6b3-4a20-867b-c8d9fd442160/access_as_user" };
String[] msGraphDefaultScope = { "https://graph.microsoft.com/.default" };
String[] apiDefaultScope = { "api://ae5a0bbe-d6b3-4a20-867b-c8d9fd442160/.default" };

var accounts = await clientApp.GetAccountsAsync();
IAccount account = accounts.FirstOrDefault();

AuthenticationResult msGraphTokenResult = null;
AuthenticationResult apiTokenResult = null;

try
{
	msGraphTokenResult = await clientApp.AcquireTokenSilent(msGraphScopes, account).ExecuteAsync();
	apiTokenResult = await clientApp.AcquireTokenSilent(apiScopes, account).ExecuteAsync();
}
catch (Exception e1)
{
	
	string catch1Message = e1.Message;
	string catch2Message = String.Empty;

	try
	{
        // First possible consent experience
		var result = await clientApp.AcquireTokenInteractive(apiScopes)
		  .WithExtraScopesToConsent(msGraphScopes)
		  .WithAccount(account)
		  .ExecuteAsync();
		CurrentAccount = result.Account;
		msGraphTokenResult = await clientApp.AcquireTokenSilent(msGraphScopes, CurrentAccount).ExecuteAsync();
		apiTokenResult = await clientApp.AcquireTokenSilent(apiScopes, CurrentAccount).ExecuteAsync();
	}
	catch(Exception e2)
	{
		catch2Message = e2.Message;
	};

	if(catch1Message.Contains("AADSTS650052") || catch2Message.Contains("AADSTS650052") || catch1Message.Contains("AADSTS70000") || catch2Message.Contains("AADSTS70000"))
	{
        // Second possible consent experience
		var result = await clientApp.AcquireTokenInteractive(apiDefaultScope)
			.WithAccount(account)
			.WithPrompt(Prompt.Consent)
			.ExecuteAsync();
		CurrentAccount = result.Account;
		msGraphTokenResult = await GetTokenAfterConsentAsync(msGraphScopes);
		apiTokenResult = await GetTokenAfterConsentAsync(apiScopes);
	}
}

// Call API

apiResult = await MockApiCall(apiTokenResult.AccessToken);
var contentMessage = await apiResult.Content.ReadAsStringAsync();

if(apiResult.StatusCode==HttpStatusCode.Forbidden)
{
	var result = await clientApp.AcquireTokenInteractive(apiDefaultScope)
		.WithAccount(account)
		.WithPrompt(Prompt.Consent)
		.ExecuteAsync();
	CurrentAccount = result.Account;

	// Retry API call
	apiResult = await MockApiCall(result.AccessToken);
}

If anyone has better ideas or solutions, please comment on this post.

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.

Using MSAL for Python to perform interactive sign in from a local script

This blog shows how to use MSAL for Python to perform an interactive sign in to Azure AD from running a local python script. The sample also demonstrates how to enable MSAL logging along with how to capture Python SSL web traffic using Fiddler Classic

App Registration:

You will need to have an Azure AD App Registration with “http://localhost” reply URL configured in the ‘Mobile and desktop applications’ platform

The Code:

If you don’t have msal and requests module already installed you may need to run both “pip install msal” and “pip install requests” commands to get these installed first.

Logging:

logging is enabled with the following code to set logging level to DEBUG

logging.basicConfig(level=logging.DEBUG)  # Enable DEBUG log for entire script
logging.getLogger("msal").setLevel(logging.DEBUG)  # Optionally disable MSAL DEBUG logs

Fiddler HTTPS capture

Capturing HTTPS traffic from Python application using Fiddler can be very challenging. The application uses MSAL library to authenticate with Azure AD and then uses the requests module to send request to MS Graph endpoint. The following code is used to help with Fiddler capture (assuming Fiddler is listening on port 8888)

app = msal.PublicClientApplication(client_id = appId, authority = "https://login.microsoftonline.com/"+ tenantId,verify = False)
graph_response = requests.get(  # Use token to call downstream service
        graphurl,
        headers={'Authorization': 'Bearer ' + result['access_token']},
        proxies={"http": "http://127.0.0.1:8888","https":"http://127.0.0.1:8888"},verify=False)

Below is the complete python script you can run locally on the machine. Make sure to provide your tenant ID and Application ID

import sys
import json, logging, msal, requests

# comment out the following 2 lines if you dont't want to enable MSAL logging
logging.basicConfig(level=logging.DEBUG)  # Enable DEBUG log for entire script
logging.getLogger("msal").setLevel(logging.DEBUG)  # Optionally disable MSAL DEBUG logs

tenantId = "<Tenant ID>"
appId = "<Application ID>"
scope = ["User.Read"]
graphurl = "https://graph.microsoft.com/v1.0/me"

app = msal.PublicClientApplication(client_id = appId, authority = "https://login.microsoftonline.com/"+ tenantId)

# use the following instead for Fiddler capture
# the verify = False flag is to tell the program to ignore SSL certificate verification.  We will need this since we are using Fiddler SSL certificate for HTTPS capture
# app = msal.PublicClientApplication(client_id = appId, authority = "https://login.microsoftonline.com/"+ tenantId,verify = False)

result = None

# accounts = app.get_accounts(username = "bhadmin@bachoang99live.onmicrosoft.com")
accounts = app.get_accounts()
if accounts:
    logging.info("Account(s) exists in cache, probably with token too. Let's try.")
    print("Account(s) already signed in:")
    for a in accounts:
        print(a["username"])
    chosen = accounts[0]  # Assuming the end user chose this one to proceed
    print("Proceed with account: %s" % chosen["username"])
    # Now let's try to find a token in cache for this account
    result = app.acquire_token_silent(scope, account=chosen)

if not result:
    logging.info("No suitable token exists in cache. Let's get a new one from AAD.")
    print("A local browser window will be open for you to sign in. CTRL+C to cancel.")
    result = app.acquire_token_interactive(  # Only works if your app is registered with redirect_uri as http://localhost
        scopes = scope,
        #parent_window_handle=...,  # If broker is enabled, you will be guided to provide a window handle
        # login_hint=config.get("username"),  # Optional.
            # If you know the username ahead of time, this parameter can pre-fill
            # the username (or email address) field of the sign-in page for the user,
            # Often, apps use this parameter during reauthentication,
            # after already extracting the username from an earlier sign-in
            # by using the preferred_username claim from returned id_token_claims.

        # prompt=msal.Prompt.SELECT_ACCOUNT,  # Or simply "select_account". Optional. It forces to show account selector page
        #prompt=msal.Prompt.CREATE,  # Or simply "create". Optional. It brings user to a self-service sign-up flow.
            # Prerequisite: https://docs.microsoft.com/en-us/azure/active-directory/external-identities/self-service-sign-up-user-flow
        )

if "access_token" in result:
    # Calling graph using the access token

    # use the following code if you want to capture SSL traffic in Fiddler
    # graph_response = requests.get(  # Use token to call downstream service
    #    graphurl,
    #    headers={'Authorization': 'Bearer ' + result['access_token']},
    #    proxies={"http": # "http://127.0.0.1:8888","https":"http://127.0.0.1:8888"},verify=False)

    graph_response = requests.get(  # Use token to call downstream service
        graphurl,
        headers={'Authorization': 'Bearer ' + result['access_token']})
    print("Graph API call result: %s ..." % graph_response.text[:100])
else:
    print(result.get("error"))
    print(result.get("error_description"))
    print(result.get("correlation_id"))  # You may need this when reporting a bug 

References:

msal.application.ClientApplication class | Microsoft Learn

microsoft-authentication-library-for-python/interactive_sample.py at dev · AzureAD/microsoft-authentication-library-for-python · GitHub

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.

Microsoft.Identity.Client.MsalClientException: Failed to get user name

You might be using the following method to attempt Integrated Windows Auth while using Microsoft Authentication Library (MSAL)…

result = await app.AcquireTokenByIntegratedWindowsAuth(scopes)

and you are getting one of the following errors…

  • Microsoft.Identity.Client.MsalClientException: Failed to get user name —> System.ComponentModel.Win32Exception: No mapping between account names and security IDs was done
  • Microsoft.Identity.Client.MsalClientException: Failed to get user name —> System.ComponentModel.Win32Exception: Access Denied

Make sure you at least meet these minimum requirements:

  • You are running the app as a local Active Directory user and not a local computer user account.
  • Ensure that the device is joined to the domain

What is actually failing?

MSAL makes a call to GetUserNameEx function from secur32.dll…

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/01ecd12464007fc1988b6a127aa0b1b980bca1ed/src/client/Microsoft.Identity.Client/Platforms/Features/DesktopOS/WindowsNativeMethods.cs#L66

For more information about GetUserNameEx…

https://learn.microsoft.com/en-us/windows/win32/api/secext/nf-secext-getusernameexa

Windows is returning this error message. There is a number of reasons this can fail that is out of scope for this article.

Manually pass the username to AcquireTokenByIntegratedWindowsAuth

If you already know ahead of time what the username is, you can just pass it manually to MSAL… This may be by far the easiest and recommended solution.

result = await app.AcquireTokenByIntegratedWindowsAuth(scopes).WithUsername(“serviceaccount@contoso.com”)

You can try other ways to dynamically get the username….

Use System.Security.Principal.WindowsIdentity.GetCurrent()

Note: If returns a username with no domain, this will not work and return different errors. For proper Azure Active Directory integration, we need to pass username in the format of user principal name.

string username = System.Security.Principal.WindowsIdentity.GetCurrent().Name;
result = await app.AcquireTokenByIntegratedWindowsAuth(scopes).WithUsername(username)

Use PublicClientApplication.OperatingSystemAccount.Username

Note: This attempts to access the Windows Account Broker to get the user signed into the device. This is not going to work if running on IIS or Windows Servers

string username = PublicClientApplication.OperatingSystemAccount.Username;
result = await app.AcquireTokenByIntegratedWindowsAuth(scopes).WithUsername(username)

For more information

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Integrated-Windows-Authentication

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());

How to Resolve IDX10501 Errors in a B2C Microsoft.Identity.Web Application

Consider the situation where you are developing an ASP.NET Core application that needs to support Azure B2C. Following the official Microsoft Document, you implement the Microsoft.Identity.Web library and three built-in User Flows. All is working well, but you also need to implement a Custom Policy. You find the following documentation which shows how to make a request with the B2C policy specified:

https://learn.microsoft.com/en-us/azure/active-directory-b2c/enable-authentication-web-application-options#pass-the-azure-ad-b2c-policy-id

You try to implement this guidance. Everything compiles ok, but when you test the app, you encounter an IDX10501 error after the custom policy redirects back to the application. I might look like this:

IDX10501: Signature validation failed. Unable to match key: kid: '[PII of type 'System.String' is hidden. For more details, see https://aka.ms/IdentityModel/PII.]'. Number of keys in TokenValidationParameters: '0'. Number of keys in Configuration: '1'. Exceptions caught: '[PII of type 'System.Text.StringBuilder' is hidden. For more details, see https://aka.ms/IdentityModel/PII.]'. token: '[PII of type 'System.IdentityModel.Tokens.Jwt.JwtSecurityToken' is hidden. For more details, see https://aka.ms/IdentityModel/PII.]'.

This article is precisely about how to deal with this situation.

The Reason For The Error:

First, let’s understand why this error is being thrown when the custom policy redirects back to your app. In ASP.NET Core, whenever a user is authenticated and authorized, and there is a redirect back to the Web App containing an ID Token, the ASP.NET Core middleware will try to validate this ID Token to make sure that the redirect is genuine. In order to validate the ID Token, the Middleware needs the public key of the signing certificate which was used to sign the ID Token. The Middleware gets this public key by querying AAD B2C. Specifically, there is a “metadata” endpoint in AAD B2C used by the Middleware which provides authentication information including any public keys for signing certificates.

You may remember when creating your custom policy that you needed to create or upload a signing certificate. This signing certificate is different from that used for built-in user flows in AAD B2C. This means that the public keys accessible from the metadata endpoint for your AAD B2C will not contain the public key for your custom policy. The custom policy actually has it’s own metadata endpoint.

The endpoint which the Middleware uses is configured by Microsoft.Identity.Web and set at application startup. Since the metadata URL is already set, invoking a custom policy during runtime will result in a scenario where the Middleware is looking at the wrong metadata URL while validating the returning token.

The Solution:

In short, we need to configure the correct metadata endpoint for our additional Custom Policy.

We do this by creating a second authentication scheme to handle the custom policy. With this additional authentication scheme, we can set the correct metadata endpoint at startup. Below is a brief overview of the steps involved in actually carrying this out:

  1. Add an additional redirect URI to your App Registration
  2. Configure an additional B2C authentication scheme in your application
  3. Add an action to the desired controller
  4. Implement the created action in the Layout

Before continuing, please review the following prerequisites.

Note: an example application that demonstrates the results of the following steps can be found at the following link
https://github.com/mbukovich/ExtraB2CPolicyMVC

Prerequisites:

Add an Additional Redirect URI to your App Registration

Using the same App Registration referred to in the Prerequisites, we will need to add another Redirect URI for the custom policy. The reason we cannot use the existing redirect URI for this situation is that doing so will confuse the Web App. We will be setting up two different authentication schemes, but when the B2C policy redirects back to the Web App, the Middleware will not know which authentication scheme to use. Thus, we need a separate redirect URI to clearly distinguish redirects from the existing and new authentication schemes.

Go to the Azure Portal at portal.azure.com, and navigate to your app registration. Once there, click on the “Authentication” blade on the left side of the screen. Under the existing redirect URI, add a new one. Copy and past the existing URI into the new one, and add a ‘-‘ to the existing URL and then whatever string you would like to define the callback endpoint for your application. In my example, my new redirect URI is “https://localhost:44321/signin-oidc-editemail”. Don’t forget to click “Save”!

Important Note:

You will need a separate redirect URI for each Authentication Scheme. This means if you are adding 2 Custom Policies (or more), then you will need to add 2 (or more) Authentication Schemes (one for each Policy), which means 2 (or more) redirect URIs.

Configure an Additional B2C Authentication Scheme

This process hinges around adding an action to your controller that will issue a challenge to the user. However, before we create this action, we need to properly configure the app with an additional authentication scheme. This means configuring the appsettings.json file as well as the Startup.cs file.

appsettings.json

Add the following JSON to your appsettings.json file:

"<name-of-your-configuration>": {
    "Instance": "https://<B2C-tenant-name>.b2clogin.com",
    "ClientId": "<client-id-of-your-app-registration>",
    "CallbackPath": "/<endpoint-of-your-new-redirect-uri>",
    "SignedOutCallbackPath": "/signout/<built-in-sign-in-sign-up-policy>",
    "Domain": "<B2C-tenant-name>.onmicrosoft.com",
    "SignUpSignInPolicyId": "<built-in-sign-in-sign-up-policy>"
},

This is how the appsettings.json file looks in my example application:

{
  "AzureADB2C": {
    "Instance": "https://markstestorganization1.b2clogin.com",
    "ClientId": "09717d12-ca7f-4388-8393-dafe42c0c3a5",
    "CallbackPath": "/signin-oidc",
    "SignedOutCallbackPath": "/signout/B2C_1_signupsignin1",
    "Domain": "markstestorganization1.onmicrosoft.com",
    "SignUpSignInPolicyId": "B2C_1_signupsignin1",
    "ResetPasswordPolicyId": "B2C_1_PasswordReset1",
    "EditProfilePolicyId": "B2C_1_editProfileTest1"
  },
  "AzureADB2CEditEmail": {
    "Instance": "https://markstestorganization1.b2clogin.com",
    "ClientId": "09717d12-ca7f-4388-8393-dafe42c0c3a5",
    "CallbackPath": "/signin-oidc-editemail",
    "SignedOutCallbackPath": "/signout/B2C_1_signupsignin1",
    "Domain": "markstestorganization1.onmicrosoft.com",
    "SignUpSignInPolicyId": "B2C_1_signupsignin1"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

Important Notes:

  • The name of the second B2C configuration is arbitrary; you can name it whatever you want. We will using this extra configuration for a single custom policy, and if you want to add more custom policies, you will need to add an additional B2C configuration in the AppSettings.json file. For this reason, I recommend giving the JSON object a name associated with the Custom Policy.
  • CallbackPath takes the String from the end of your Redirect URI from the previous step. Since my redirect URI is “https://localhost:44321/signin-oidc-editemail”, then my CallbackPath is “/signin-oidc-editemail”.
  • You need to include your standard built-in sign-up-sign-in user flow in the Authentication Scheme in case the user tries to use your Custom Policy without being signed-in.

Startup.cs

Now we need to configure an additional authentication scheme in the startup.cs file. Within the “ConfigureServices” function, add the following code (added to line 52 in my example app):

// Create another authentication scheme to handle extra custom policy
services.AddAuthentication()
       .AddMicrosoftIdentityWebApp(Configuration.GetSection("<name-of-json-configuration>"), "<Arbitrary-name-for-Auth-Scheme>", "<Arbitrary-name-of-Cookie-Scheme>");

services.Configure<OpenIdConnectOptions>("<Arbitrary-name-for-Auth-Scheme>", options =>
    {
        options.MetadataAddress = "<Metadata-Address-for-Custom-Policy>";
    });

You will need to choose an arbitrary name for your authentication scheme, and an arbitrary name for the associated cookie scheme. Microsoft.Identity.Web will create the new authentication scheme and cookie scheme with the names you specified. You will also need to replace the text, “<name-of-json-configuration>,” with the name of the json configuration from the previous step. In my case, this would be “AzureADB2CEditEmail.”

It is also very important to get the meta data address. This will be used by the middleware to get the information necessary to validate ID Tokens returned by the Custom Policy. The meta data address can be found in the Azure B2C Portal. On the left side of the screen, under “Policies,” click on “Identity Experience Framework” as indicated in the image below.

You will see your custom policies listed at the bottom of the page; click on the custom policy you are using for this guide. In my case it is “B2C_1A_DEMO_CHANGESIGNINNAME.”

In the dialog that opened up on the right side of the screen, the meta data address is the URL listed under “OpenId Connect Discovery Endpoint.” Copy this URL and paste it in for the value of the MetadataAddress variable.

Add Action to the Controller

Now we add an action to the desired controller to challenge the user with the Custom B2C Policy. In my example, I add the action to my Home Controller for the sake of simplicity. Add the following code to your controller and adjust the values and action name to meet your scenario (this code snippet can be found on line 40 of the “HomeController.cs” file in the “Controllers” folder in my example project):

[Authorize]
public IActionResult EditEmail()
{
    var redirectUrl = Url.Content("~/");
    var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
    properties.Items["policy"] = "B2C_1A_DEMO_CHANGESIGNINNAME";
    return Challenge(properties, "B2CEditEmail");
}

Important Notes:

  • Please rename the function to something that describes the action in your scenario.
  • Change “B2C_1A_DEMO_CHANGESIGNINNAME” to the name of your custom policy.
  • Change “B2CEditEmail” to whatever you named your custom policy authentication scheme. This is how the middleware will know which scheme and meta data address to use.

Implement the Action in the Layout

Now we need to implement the action in the layout so that the user can actually invoke the custom policy. In my example, I added a button alongside existing B2C buttons based on the tutorial mentioned in the prerequisites section; however, you can handle this in whatever way suits your layout and business requirements best.

If you want to follow the same idea that I used, then you can use the following snippet as an example of another list item added to the partial layout created in Step 4 of the tutorial linked in the 5th Prerequisite for this guide. (link: https://learn.microsoft.com/en-us/azure/active-directory-b2c/enable-authentication-web-application?tabs=visual-studio#step-4-add-the-ui-elements)

<li class="navbar-btn">
    <form method="get" asp-area="" asp-controller="Home" asp-action="EditEmail">
        <button type="submit" class="btn btn-primary" style="margin-right:5px">Edit Email</button>
    </form>
</li>

I added the snippet above to line 13 of the “_LayoutPartial.cshtml” file in the “Views/Shared” folder of my example project. Notice that the “asp-controller” property is set to “Home” in order to reference the Home Controller, and the “asp-action” property is set to “EditEmail” to reference the action created in the Home Controller.

If you have an existing app that doesn’t utilize the partial layout, and you just want a quick link to test the custom policy, you can use the following tag which creates a basic link. Replace the indicated values being sure to reference the correct controller if you didn’t add your action to the Home Controller:

<a asp-area="" asp-controller="Home" asp-action="replace-with-your-controller-action">Replace with text that describes the action</a>

Congratulations! Now you can run the Web App and test the solution.