Implement Azure AD Continuous Access in an ASP.NET Core Razor Page app using a Web API

This article shows how Azure AD continuous access (CA) can be used in an ASP.NET Core UI application to force MFA when using an administrator API from a separate ASP.NET Core application. Both applications are secured using Microsoft.Identity.Web. An ASP.NET Core Razor Page application is used to implement the UI application. The API is implemented with swagger open API and ASP.NET Core. An Azure AD conditional access authentication context is used to implement the MFA requirement. An Azure AD CAE policy is setup which requires the defines MFA and uses the context.

Code https://github.com/damienbod/AspNetCoreAzureADCAE

History

2022-05-25 Update info about AAD license requirements.

Blogs in this series

CAE is available to everyone , including free tenants.
CA Auth Context requires AAD P1, as its dependent on the CA feature, which is only available to AAD P1 and above.

Requirements

  • Azure AD tenant with P1 license for CA Auth Context
  • Microsoft Graph

Create a Conditional access Authentication Context

A Continuous access evaluation (CAE) authentication context was created using Microsoft Graph and can be viewed in the portal. In this demo, like the Microsoft sample application, three authentication contexts are created using Microsoft Graph. The Policy.Read.ConditionalAccess Policy.ReadWrite.ConditionalAccess permissions are required to change the CAE authentication contexts.

This is only needed to create the CA authentication contexts. Once created, this can be used in the target applications.

public async Task CreateAuthContextViaGraph(string acrKey, string acrValue)
{
	await _graphAuthContextAdmin.CreateAuthContextClassReferenceAsync(
		acrKey, 
		acrValue, 
		$"A new Authentication Context Class Reference created at {DateTime.UtcNow}", 
		true);
}

public async Task<AuthenticationContextClassReference?> 
	CreateAuthContextClassReferenceAsync(
		string id, 
		string displayName, 
		string description, 
		bool IsAvailable)
{
	try
	{
		var acr = await _graphServiceClient
			.Identity
			.ConditionalAccess
			.AuthenticationContextClassReferences
			.Request()
			.AddAsync(new AuthenticationContextClassReference
			{
				Id = id,
				DisplayName = displayName,
				Description = description,
				IsAvailable = IsAvailable,
				ODataType = null
			});

		return acr;
	}
	catch (ServiceException e)
	{
		_logger.LogWarning(
		"We could not add a new ACR: {exception}",  e.Error.Message);

		return null;
	}
}

The created conditional access authentication context can be viewed in the portal in the Security blade of the Azure AD tenant.

If you open the context, you can see the id used. This is used in the applications to check the MFA requirement.

Create a CAE policy to use the context

Now that a authentication context exists, a CAE policy can be created to use this. I created a policy to require MFA.

Implement the API and use the CAE context

The API application needs to validate if the access token contains the acrs claim with the c1 value. If CAE is activated and the claim is included in the token, then any policies which use this CAE authentication context must be fulfilled or no events have been received which inform the client that this access token is invalid. A lot of things need to be implemented correctly for this to work. If configured correctly, a MFA step up authentication is required to use the API. The API returns an unauthorized response as specified in the OpenID Connect signals and events specification, if the claim is missing from the access token. This is handled by the calling UI application.

/// <summary>
/// Claims challenges, claims requests, and client capabilities
/// 
/// https://docs.microsoft.com/en-us/azure/active-directory/develop/claims-challenge
/// 
/// Applications that use enhanced security features like Continuous Access Evaluation (CAE) 
/// and Conditional Access authentication context must be prepared to handle claims challenges.
/// </summary>
public class CaeClaimsChallengeService
{
    private readonly IConfiguration _configuration;

    public CaeClaimsChallengeService(IConfiguration configuration)
    {
        _configuration = configuration;
    }
    /// <summary>
    /// Retrieves the acrsValue from database for the request method.
    /// Checks if the access token has acrs claim with acrsValue.
    /// If does not exists then adds WWW-Authenticate and throws UnauthorizedAccessException exception.
    /// </summary>
    public void CheckForRequiredAuthContext(string authContextId, HttpContext context)
    {
        if (!string.IsNullOrEmpty(authContextId))
        {
            string authenticationContextClassReferencesClaim = "acrs";

            if (context == null || context.User == null || context.User.Claims == null || !context.User.Claims.Any())
            {
                throw new ArgumentNullException(nameof(context), "No Usercontext is available to pick claims from");
            }

            var acrsClaim = context.User.FindAll(authenticationContextClassReferencesClaim).FirstOrDefault(x => x.Value == authContextId);

            if (acrsClaim?.Value != authContextId)
            {
                if (IsClientCapableofClaimsChallenge(context))
                {
                    string clientId = _configuration.GetSection("AzureAd").GetSection("ClientId").Value;
                    var base64str = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"access_token\":{\"acrs\":{\"essential\":true,\"value\":\"" + authContextId + "\"}}}"));

                    context.Response.Headers.Append("WWW-Authenticate", $"Bearer realm=\"\", authorization_uri=\"https://login.microsoftonline.com/common/oauth2/authorize\", client_id=\"" + clientId + "\", error=\"insufficient_claims\", claims=\"" + base64str + "\", cc_type=\"authcontext\"");
                    context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
                    string message = string.Format(CultureInfo.InvariantCulture, "The presented access tokens had insufficient claims. Please request for claims requested in the WWW-Authentication header and try again.");
                    context.Response.WriteAsync(message);
                    context.Response.CompleteAsync();
                    throw new UnauthorizedAccessException(message);
                }
                else
                {
                    throw new UnauthorizedAccessException("The caller does not meet the authentication  bar to carry our this operation. The service cannot allow this operation");
                }
            }
        }
    }

    /// <summary>
    /// Evaluates for the presence of the client capabilities claim (xms_cc) and accordingly returns a response if present.
    /// </summary>
    public bool IsClientCapableofClaimsChallenge(HttpContext context)
    {
        string clientCapabilitiesClaim = "xms_cc";

        if (context == null || context.User == null || context.User.Claims == null || !context.User.Claims.Any())
        {
            throw new ArgumentNullException(nameof(context), "No Usercontext is available to pick claims from");
        }

        var ccClaim = context.User.FindAll(clientCapabilitiesClaim).FirstOrDefault(x => x.Type == "xms_cc");

        if (ccClaim != null && ccClaim.Value == "cp1")
        {
            return true;
        }

        return false;
    }
}

The API uses the CAE scoped service to validate the CAE authentication context and either the data is returned or an unauthorized exception is returned. The Authorize attribute is also used to validate the JWT bearer token and validate that the authentication policy is supported. You could probably implement middleware to check the CAE authentication context as well.

[Authorize(Policy = "ValidateAccessTokenPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[ApiController]
[Route("[controller]")]
public class ApiForUserDataController : ControllerBase
{
    private readonly CaeClaimsChallengeService _caeClaimsChallengeService;

    public ApiForUserDataController(CaeClaimsChallengeService caeClaimsChallengeService)
    {
        _caeClaimsChallengeService = caeClaimsChallengeService;
    }

    [HttpGet]
    public IEnumerable<string> Get()
    {
        // returns unauthorized exception with WWW-Authenticate header if CAE claim missing in access token
        // handled in the caller client exception with challenge returned if not ok
        _caeClaimsChallengeService.CheckForRequiredAuthContext(AuthContextId.C1, HttpContext);
        return new List<string> { "admin API CAE protected data 1", "admin API CAE protected  data 2" };
    }
}

The program file adds the services and secure the API using Microsoft.Identity.Web. A policy is created to used on the controllers.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<CaeClaimsChallengeService>();

builder.Services.AddDistributedMemoryCache();
builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration)
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddMicrosoftGraph(builder.Configuration.GetSection("GraphBeta"))
    .AddDistributedTokenCaches();

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

builder.Services.AddControllers(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ValidateAccessTokenPolicy", validateAccessTokenPolicy =>
    {
        // Validate id of application for which the token was created
        // In this case the UI application 
        validateAccessTokenPolicy.RequireClaim("azp", builder.Configuration["AzpValidClientId"]);

        // only allow tokens which used "Private key JWT Client authentication"
        // // https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
        // Indicates how the client was authenticated. For a public client, the value is "0". 
        // If client ID and client secret are used, the value is "1". 
        // If a client certificate was used for authentication, the value is "2".
        validateAccessTokenPolicy.RequireClaim("azpacr", "1");
    });
});

The program file is used to setup the API ASP.NET Core API project like any Azure AD Microsoft.Identity.Web client.

  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]",
    "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]",
    "ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]",
    "ClientSecret": "[Copy the client secret added to the app from the Azure portal]",
    "ClientCertificates": [
    ],
    // the following is required to handle Continuous Access Evaluation challenges
    "ClientCapabilities": [ "cp1" ],
    "CallbackPath": "/signin-oidc"
  },
  "AzpValidClientId": "7c839e15-096b-4abb-a869-df9e6b34027c",
  "GraphBeta": {
    "BaseUrl": "https://graph.microsoft.com/beta",
    "Scopes": "Policy.Read.ConditionalAccess Policy.ReadWrite.ConditionalAccess"
  },

Now that the unauthorized exception is returned to the calling UI interactive client, this needs to be handled.

Implement the ASP.NET Core Razor Page with step up MFA check

The UI project implements a Web APP project. The Admin API scope is requested to access the admin API.

builder.Services.AddDistributedMemoryCache();
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration, 
       "AzureAd", 
       subscribeToOpenIdConnectMiddlewareDiagnosticsEvents: true)
    .EnableTokenAcquisitionToCallDownstreamApi(new[] { 
       builder.Configuration.GetSection("AdminApi")["Scope"] })
    .AddMicrosoftGraph(builder.Configuration.GetSection("GraphBeta"))
    .AddDistributedTokenCaches();

The app uses a scoped service to request data from the administrator API. Using the ITokenAcquisition interface, an access token is request for the API. If an unauthorized response is returned, then a WebApiMsalUiRequiredException exception is thrown with the response headers.

public async Task<IEnumerable<string>?> GetApiDataAsync()
{
	var client = _clientFactory.CreateClient();

	var scopes = new List<string> { _adminApiScope };
	var accessToken = await _tokenAcquisition
		.GetAccessTokenForUserAsync(scopes);

	client.BaseAddress = new Uri(_adminApiBaseUrl);
	client.DefaultRequestHeaders.Authorization = 
		new AuthenticationHeaderValue("Bearer", accessToken);
	client.DefaultRequestHeaders.Accept.Add(
		new MediaTypeWithQualityHeaderValue("application/json"));

	var response = await client.GetAsync("ApiForUserData");
	if (response.IsSuccessStatusCode)
	{
		var stream = await response.Content.ReadAsStreamAsync();
		var payload = await JsonSerializer
			.DeserializeAsync<List<string>>(stream);

		return payload;
	}

	// This exception can be used to handle a claims challenge
	throw new WebApiMsalUiRequiredException(
		$"Unexpected status code in the HttpResponseMessage: {response.StatusCode}.", 
		response);
}

The ASP.NET Core Razor page is used to handled the WebApiMsalUiRequiredException exception. If this is returned, a new ClaimChallenge is created with the request for the authentication context. This is returned to the UI. If this response is returned, the user is redirected to authenticate again for the new scope which must fulfil the CAE policy using this.

public async Task<IActionResult> OnGet()
{
	try
	{
		Data = await _userApiClientService.GetApiDataAsync();
		return Page();
	}
	catch (WebApiMsalUiRequiredException hex)
	{
		// Challenges the user if exception is thrown from Web API.
		try
		{
			var claimChallenge = WwwAuthenticateParameters
				.GetClaimChallengeFromResponseHeaders(hex.Headers);

			_consentHandler.ChallengeUser(
				new string[] { "user.read" }, claimChallenge);

			return Page();
		}
		catch (Exception ex)
		{
			_consentHandler.HandleException(ex);
		}

		_logger.LogInformation("{hexMessage}", hex.Message);
	}

	return Page();
}

MFA is configured in a policy using the CAE conditional access authentication context.

Notes

The application will only work with Azure AD and if the continuous access evaluation policies are implemented correctly by the Azure IT tenant admin. You cannot force this in the application, you can only use this.

Links

https://github.com/Azure-Samples/ms-identity-ca-auth-context

https://github.com/Azure-Samples/ms-identity-dotnetcore-ca-auth-context-app

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

https://github.com/Azure-Samples/ms-identity-dotnetcore-daemon-graph-cae

https://docs.microsoft.com/en-us/azure/active-directory/develop/developer-guide-conditional-access-authentication-context

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

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-conditional-access-dev-guide

https://techcommunity.microsoft.com/t5/itops-talk-blog/deep-dive-how-does-conditional-access-block-legacy/ba-p/3265345

6 comments

  1. […] Implement Azure AD Continuous Access Evaluation in an ASP.NET Core Razor Page app using a Web API (Damien Bowden) […]

  2. […] Implement Azure AD Continuous Access Evaluation in an ASP.NET Core Razor Page app using a Web API – Damien Bowden […]

  3. […] Implement Azure AD Continuous Access Evaluation in an ASP.NET Core Razor Page app using a Web API […]

  4. […] Implement Azure AD Continuous Access Evaluation in an ASP.NET Core Razor Page app using a Web A… […]

  5. […] context was created using Microsoft Graph and a policy was created to use this. See the first blog in this series for details on setting this […]

  6. […] context was created using Microsoft Graph and a policy was created to use this. See the first blog in this series for details on setting this […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.