Implement Azure AD Continuous Access (CA) standalone with Blazor ASP.NET Core

This post shows how to force an Azure AD policy using Azure AD Continuous Access (CA) in an ASP.NET Core Blazor application. An authentication context is used to require MFA. The “acrs” claim in the id_token is used to validate whether or not an Azure AD CAE policy has been fulfilled. If the claim is missing, an OpenID Connect challenge is sent to the Azure AD identity provider to request and require this. In this sample, MFA is required.

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 Blazor backend to handle the CA validation correctly
  4. Implement an authentication challenge using the claims challenge in the Blazor WASM

Setup overview

A Blazor WASM application is implemented and hosted in an ASP.NET Core application. This is one single application, or also know as a server rendered application. The single application is secured using a single confidential client and the security is implemented in the trusted backend with no sensitive token data stored in the browser. Cookies are used to store the sensitive data. Microsoft.Identity.Web is used to implement the security. The Microsoft.Identity.Web lib is an OpenID Connect client wrapper from Microsoft with some Microsoft Azure specifics.

Creating a conditional access authentication context

A continuous access evaluation (CAE) authentication 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 up.

Validate the CAE in the Blazor backend

The CaeClaimsChallengeService class is used to implement the CAE check in the application. The class checks for the acrs claim and returns a claims challenge requesting the claim if this is missing.

namespace BlazorBffAzureAD.Server;

/// <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.
/// 
/// This class is only required if using a standalone AuthContext check
/// </summary>
public class CaeClaimsChallengeService
{
    private readonly IConfiguration _configuration;

    public CaeClaimsChallengeService(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public string? CheckForRequiredAuthContextIdToken(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)
            {
                string clientId = _configuration.GetSection("AzureAd").GetSection("ClientId").Value;
                var cae = "{\"id_token\":{\"acrs\":{\"essential\":true,\"value\":\"" + authContextId + "\"}}}";

                return cae;
            }
        }

        return null;
    }
}

The AdminApiCallsController is used to provide data for the Blazor WASM UI. If the identity does not have the required authorization, an unauthorized response is returned to the UI with the claims challenge.

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace BlazorBffAzureAD.Server.Controllers;

[ValidateAntiForgeryToken]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
[ApiController]
[Route("api/[controller]")]
public class AdminApiCallsController : ControllerBase
{
    private readonly CaeClaimsChallengeService _caeClaimsChallengeService;

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

    [HttpGet]
    public IActionResult Get()
    {
        // if CAE claim missing in id token, the required claims challenge is returned
        var claimsChallenge = _caeClaimsChallengeService
            .CheckForRequiredAuthContextIdToken(AuthContextId.C1, HttpContext);

        if (claimsChallenge != null)
        {
            return Unauthorized(claimsChallenge);
        }

        return Ok(new List<string>()
        {
            "Admin data 1",
            "Admin data 2"
        });
    }
}

Handling the authentication challenge in the Blazor WASM client

The Blazor WASM client handles the unauthorized response by authenticating again using Azure AD. If the claims challenge is returned, a step up authentication is sent to Azure AD with the challenge. The CaeStepUp method is used to implement the UI part of this flow.

using System.Net;

namespace BlazorBffAzureAD.Client.Services;

// orig src https://github.com/berhir/BlazorWebAssemblyCookieAuth
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 navigates to the authorization URL of the backend application with the claims challenge passed as a parameter.

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 Login checks for claims challenge and starts an authentication process using Azure AD and the Microsoft.Identity.Web client.

[Route("api/[controller]")]
public class AccountController : ControllerBase
{
    [HttpGet("Login")]
    public ActionResult Login(string? returnUrl, string? claimsChallenge)
    {
        // var claims = "{\"access_token\":{\"acrs\":{\"essential\":true,\"value\":\"c1\"}}}";
        // var claims = "{\"id_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);
    }

Using CAE is a useful way in applications to force authorization or policies in an Azure applications. This can be implemented easily with an ASP.NET Core application.

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

One comment

  1. […] Implement Azure AD Continuous Access Evaluation (CAE) standalone with Blazor ASP.NET Core – Damien Bowden […]

Leave a Reply to The Morning Brew - Chris Alcock » The Morning Brew #3482 Cancel 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: