Implement Azure AD Continuous Access (CA) step up with ASP.NET Core Blazor using a Web API

This article shows how to implement Azure AD Continuous Access (CA) in a Blazor application which uses a Web API. The API requires an Azure AD conditional access authentication context. In the example code, MFA is required to use the external API. If a user requests data from the API using the required access token without the required acr claim, an unauthorized response is returned with the missing claims. The Blazor application returns the claims challenge to the WASM application and the application authenticates again with the step up claims challenge. If the user has authenticated using MFA, the authentication is successful and the data from the API can be retrieved.

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

Blogs in this series

Steps to implement

  1. Create an authentication context in Azure for the tenant (using Microsoft Graph).
  2. Add a CA policy which uses the authentication context.
  3. Implement the CA Azure AD authentication context authorization in the API.
  4. Implement the Blazor backend to handle the CA unauthorized responses correctly.
  5. Implement an authentication challenge using the claims challenge in the Blazor WASM.

Setup overview

Creating a Conditional access Authentication Context

A continuous access (CA) authentication context was created using Microsoft Graph and a policy was created to use this. See the previous blog for details on setting this up.

External API setup

The external API is setup to validate Azure AD JWT Bearer access tokens and to validate that the required continuous access evaluation (CAE) policy is fulfilled. The CAE policy uses the authentication context required for this API. If the user is authorized, the correct claims need to be presented in the access token.

[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 CaeClaimsChallengeService class implements the CAE requirement to use the API. If the user access token has insufficient claims, an unauthorized response is returned to the application requesting data from the API. The WWW-Authenticate header is set with the correct data as defined in the OpenID Connect signals and events specification.

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using System;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text;

namespace AdminCaeMfaRequiredApi;

/// <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 can be used by any application and user which presents the correct access token including the claims required by the CAE.

Using Continuous Access Evaluation (CAE) in an ASP.NET Core hosted Blazor application.

The Blazor application is authenticated using MSAL and the backend for frontend (BFF) architecture. The Blazor application administrator page uses data from the CAE protected API. The Blazor ASP.NET Core hosted WASM application is protected using a MSAL confidential client.

services.AddMicrosoftIdentityWebAppAuthentication(
	Configuration, 
	"AzureAd", 
	subscribeToOpenIdConnectMiddlewareDiagnosticsEvents: true)
	.EnableTokenAcquisitionToCallDownstreamApi(
		new[]
		{ 
			"api://7c839e15-096b-4abb-a869-df9e6b34027c/access_as_user" 
		})
	.AddMicrosoftGraph(
		Configuration.GetSection("GraphBeta"))
	.AddDistributedTokenCaches();

The AdminApiClientService class is used to request data from the external API. The http client uses an Azure AD user delegated access token. If the API returns an unauthorized response, a WebApiMsalUiRequiredException is created with the WWW-Authenticate header payload.

public class AdminApiClientService
{
    private readonly IHttpClientFactory _clientFactory;
    private readonly ITokenAcquisition _tokenAcquisition;

    public AdminApiClientService(
        ITokenAcquisition tokenAcquisition,
        IHttpClientFactory clientFactory)
    {
        _clientFactory = clientFactory;
        _tokenAcquisition = tokenAcquisition;
    }

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

        var scopes = new List<string> 
		{ 
			"api://7c839e15-096b-4abb-a869-df9e6b34027c/access_as_user" 
		};
		
        var accessToken = await _tokenAcquisition
			.GetAccessTokenForUserAsync(scopes);

        client.BaseAddress = new Uri("https://localhost:44395");
        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;
        }

        throw new WebApiMsalUiRequiredException(
			$"Unexpected status code in the HttpResponseMessage: {response.StatusCode}.", response);
    }
}

The AdminApiCallsController implements the API used by the Blazor WASM client. This is protected using cookies. The controller would return an unauthorized response with the claims challenge, if the WebApiMsalUiRequiredException is thrown.

public class AdminApiCallsController : ControllerBase
{
    private readonly AdminApiClientService _userApiClientService;
   
    public AdminApiCallsController(
		AdminApiClientService userApiClientService)
    {
        _userApiClientService = userApiClientService;
    }

    [HttpGet]
    public async Task<IActionResult> Get()
    {
        try
        {
            return Ok(await _userApiClientService.GetApiDataAsync());
        }
        catch (WebApiMsalUiRequiredException hex)
        {
            var claimChallenge = WwwAuthenticateParameters
				.GetClaimChallengeFromResponseHeaders(hex.Headers);
            return Unauthorized(claimChallenge);
        }
    }
}

In the Blazor WASM client, an AuthorizedHandler is implemented to handle the unauthorized response from the API. If the “acr” claim is returned, the CAE step method is called.

public class AuthorizedHandler : DelegatingHandler
{
    private readonly HostAuthenticationStateProvider _authenticationStateProvider;

    public AuthorizedHandler(
		HostAuthenticationStateProvider authenticationStateProvider)
    {
        _authenticationStateProvider = authenticationStateProvider;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var authState = await _authenticationStateProvider
			.GetAuthenticationStateAsync();
        HttpResponseMessage responseMessage;
        if (authState.User.Identity!= null && 
			!authState.User.Identity.IsAuthenticated)
        {
            // if user is not authenticated, 
			// immediately set response status to 401 Unauthorized
            responseMessage = new HttpResponseMessage(
				HttpStatusCode.Unauthorized);
        }
        else
        {
            responseMessage = await base.SendAsync(
				request, cancellationToken);
        }

        if (responseMessage.StatusCode == HttpStatusCode.Unauthorized)
        {
            var content = await responseMessage.Content.ReadAsStringAsync();

            // if server returned 401 Unauthorized, redirect to login page
            if (content != null && content.Contains("acr")) // CAE
            {
                _authenticationStateProvider.CaeStepUp(content);
            }
            else // standard
            {
                _authenticationStateProvider.SignIn();
            }
        }

        return responseMessage;
    }
}

The CaeStepUp method is implemented in the Blazor WASM client and creates a claims challenge with the defined claims challenge and the URL of the WASM client page for the redirect.

public void CaeStepUp(string claimsChallenge, string? customReturnUrl = null)
{
	var returnUrl = customReturnUrl != null 
		? _navigation.ToAbsoluteUri(customReturnUrl).ToString() : null;
		
	var encodedReturnUrl = 
		Uri.EscapeDataString(returnUrl ?? _navigation.Uri);
	var logInUrl = _navigation.ToAbsoluteUri(
		$"{LogInPath}?claimsChallenge={claimsChallenge}&returnUrl={encodedReturnUrl}");
		
	_navigation.NavigateTo(logInUrl.ToString(), true);
}

The account login sends a challenge to Azure AD to request the claims for the CAE.

[HttpGet("Login")]
public ActionResult Login(string? returnUrl, string? claimsChallenge)
{
	//var claims = "{\"access_token\":{\"acrs\":{\"essential\":true,\"value\":\"c1\"}}}";
	var redirectUri = !string.IsNullOrEmpty(returnUrl) ? returnUrl : "/";

	var properties = new AuthenticationProperties { RedirectUri = redirectUri };

	if(claimsChallenge != null)
	{
		string jsonString = claimsChallenge.Replace("\\", "")
			.Trim(new char[1] { '"' });

		properties.Items["claims"] = jsonString;
	}

	return Challenge(properties);
}

The Microsoft.Identity.Web client package requires the cp1 ClientCapabilities.

"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"
},

To test both the Blazor server application and the API can be started and the CAE claims are required to use the API.

Links

https://github.com/damienbod/Blazor.BFF.AzureAD.Template

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

2 comments

  1. […] Implement Azure AD Continuous Access Evaluation (CAE) step up with ASP.NET Core Blazor using a Web A… (Damien Bowden) […]

  2. […] Implement Azure AD Continuous Access Evaluation (CAE) step up with ASP.NET Core Blazor using a Web A… – Damien Bowden […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

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

%d bloggers like this: