Step Up Challenges for ASP.NET Core Client Apps with Duende IdentityServer

Khalid Abuhakmeh |

Modern software solutions involve hundreds of user actions. When building solutions, user interactions can have impacts ranging from mundane to critical, making it the application’s responsibility to ensure that the user acts intentionally. While a system can follow the best security practices, it may be beneficial to reaffirm a user’s identity before they perform a potentially irreversible action. For example, by requiring multi-factor authentication (MFA) as a Step Up challenge to confirm a user's actions.

In this post, we’ll define Step Up challenges, and highlight their role in a modern .NET solution. We'll also examine how to implement the feature in your ASP.NET Core apps powered by Duende IdentityServer to add a layer of security to mission-critical user decisions.

What is a Step Up Challenge?

Most web application users have likely received a Step Up challenge. These challenges are common in business applications that handle sensitive information or destructive actions.

Imagine you are about to transfer a large sum of money from one bank account to another. It is in everyone's best interest if you, the initiator, reenter your credentials to verify the action. Additionally, the application may ask for an additional layer of authentication by calling your phone number, sending a text message, emailing a secure link, or asking for a unique value provided by an authentication token provider. These additional steps ensure everyone is aware of the actions taking place and that the security context is reestablished for good measure.

With Duende IdentityServer, Step Up Challenges can be implemented any way you see fit for your particular use case, but some common Step Up Challenge approaches include:

  • Re-entering credentials during high-impact situations.
  • We require Multi-factor authentication during authentication.
  • Proving secret information at the time of the challenge.
  • Confirming identity with biometric information.

The following section will explain what you need to perform a Step Up challenge in an ASP.NET Core application and how to modify your Duende IdentityServer implementation to fulfill the challenge requirements. We will break down the tutorial into The Client ASP.NET Core Web Application and the Duende IdentityServer identity provider.

Implementing Step Up Challenges in ASP.NET Core

You can find the working sample of this solution in this GitHub Repository

Our ASP.NET Core application will rely heavily on the authorization policies infrastructure to determine whether a user meets the requirements to access a resource. Authorization policies allow us to inspect the ClaimsPrincipal and the attached user claims to determine the authentication state. By looking at a user's claims, we can understand metadata such as “When did the user log in?” and “Did they use multi-factor authentication?”

In a new ASP.NET Core application, let’s create three new policies: MaxAgeOneMinute, MfaRequired, and RecentMfa. The first policy of MaxAgeOneMinute checks the auth_time claim and determines if the user login occurred within the given period. The second policy of MfaRequired ensures that the user performs a multi-factor authentication when logging in. Again, this is confirmed by an amr claim with a value of mfa. Finally, the RecentMfa requires a recent login and MFA to occur during the current session.

We will use these authorization policies on several Razor Pages later in the example.

builder.Services.AddAuthorization(opt =>
{
    opt.AddPolicy("MaxAgeOneMinute", p =>
    {
        p.RequireAuthenticatedUser();
        p.AddRequirements(new MaxAgeRequirement(TimeSpan.FromMinutes(1)));
    });
    opt.AddPolicy("MfaRequired", p =>
    {
        p.RequireAuthenticatedUser();
        p.RequireClaim("amr", "mfa");
    });
    opt.AddPolicy("RecentMfa", p =>
    {
        p.RequireAuthenticatedUser();
        p.RequireClaim("amr", "mfa");
        p.AddRequirements(new MaxAgeRequirement(TimeSpan.FromSeconds(30)));
    });
});

We also mention a MaxAgeRequirement class, which allows us to define additional metadata on our authorization policies.

using Microsoft.AspNetCore.Authorization;

namespace Api.Authorization;

public class MaxAgeRequirement(TimeSpan maxAge) 
    : IAuthorizationRequirement
{
    public TimeSpan MaxAge { get; } = maxAge;
}

We also need a MaxAgeHandler to process the MaxAgeRequirement and determine if the current user passes the authorization policy.

using Api.Authorization;
using Microsoft.AspNetCore.Authorization;

namespace ClientStepUp.Web.Authorization;

public class MaxAgeHandler : AuthorizationHandler<MaxAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext ctx,
        MaxAgeRequirement requirement)
    {
        var authTimeClaim = ctx.User.FindFirst("auth_time")?.Value;
        if (authTimeClaim == null) 
        {
            return Task.CompletedTask;
        }

        var authTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(authTimeClaim));

        var timeSinceAuth = DateTime.UtcNow - authTime;

        if(timeSinceAuth < requirement.MaxAge)
        {
            ctx.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

We also must implement an ASP.NET Core middleware to perform the Step Up Challenge. If one of our Step Up Challenge policies fails, we must issue a ChallengeAsync call redirecting to our Duende IdentityServer instance.

using System.Globalization;
using Api.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Authorization.Policy;

namespace ClientStepUp.Web.Authorization;

public class StepUpAuthorizationMiddlewareResultHandler 
    : IAuthorizationMiddlewareResultHandler
{
    private readonly AuthorizationMiddlewareResultHandler _default = new();
    
    public new async Task HandleAsync(
        RequestDelegate next,
        HttpContext context,
        AuthorizationPolicy policy,
        PolicyAuthorizationResult authResult)
    {
        // If the authorization was forbidden due to a step-up requirement
        // challenge the user again to log back in passing requirements to IdentityServer
        if (authResult.Forbidden)
        {
            var failures = authResult
                .AuthorizationFailure!
                .FailedRequirements
                .ToList();
            
            var maxAgeRequirement = failures.OfType<MaxAgeRequirement>().FirstOrDefault();
            var mfaRequirement = failures.OfType<ClaimsAuthorizationRequirement>()
                .FirstOrDefault(r => r.ClaimType == "amr" && r.AllowedValues!.Contains("mfa"));

            if (maxAgeRequirement is not null || mfaRequirement is not null)
            {
                AuthenticationProperties props = new();
                if (maxAgeRequirement is not null)
                {
                    var totalSeconds = maxAgeRequirement.MaxAge.TotalSeconds.ToString(CultureInfo.InvariantCulture);
                    props.Items.Add("max_age", totalSeconds);
                }
                if (mfaRequirement is not null)
                {
                    props.Items.Add("acr_values", "mfa");
                }
                
                await context.ChallengeAsync("oidc", props);
                // make sure to end here or else the default implementation will run
                return;
            }
        }
        
        // Fall back to the default implementation.
        await _default.HandleAsync(next, context, policy, authResult);
    }
}

When issuing a Step Up Challenge, we place information in the AuthenticationProperties instance, allowing us to pass information to IdentityServer. Before redirecting to IdentityServer, we must add this information to our OpenID connect protocol message.

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = "cookie";
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie")
.AddOpenIdConnect("oidc", opt =>
{
    opt.Authority = "https://localhost:7246";
    opt.ClientId = "step-up";
    opt.ClientSecret = "secret";
    opt.ResponseType = "code";
    opt.Scope.Add("scope1");

    opt.ClaimActions.Remove("acr");

    opt.SaveTokens = true;
    opt.GetClaimsFromUserInfoEndpoint = true;
    opt.MapInboundClaims = false;
    opt.TokenValidationParameters.NameClaimType = "name";
    opt.TokenValidationParameters.RoleClaimType = "role";

    opt.Events.OnRedirectToIdentityProvider = ctx =>
    {
        if (ctx.Properties.Items.TryGetValue("acr_values", out var acrValues))
        {
            ctx.ProtocolMessage.AcrValues = acrValues;
        }

        if (ctx.Properties.Items.TryGetValue("max_age", out var maxAge))
        {
            ctx.ProtocolMessage.MaxAge = maxAge;
        }

        return Task.CompletedTask;
    };

    opt.Events.OnRemoteFailure = ctx =>
    {
        if (ctx.Failure?.Data.Contains("error") ?? false)
        {
            var error = ctx.Failure.Data["error"] as string;
            switch (error)
            {
                case OidcConstants.AuthorizeErrors.UnmetAuthenticationRequirements:
                    ctx.HandleResponse();
                    ctx.Response.Redirect("/MfaDeclined");
                    break;
            }
        }

        return Task.CompletedTask;
    };
});

While we’re here, we’ll also handle the use case when the user fails or declines to complete the multi-factor authentication requirement.

To complete the wiring of this code, we must register our authorization handlers and middleware into the ASP.NET Core services collection.

builder.Services.AddSingleton<IAuthorizationHandler, MaxAgeHandler>();
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, StepUpAuthorizationMiddlewareResultHandler>();

Now, we can use these authorization policies in our ASP.NET Core Razor Pages, API Controllers, or Minimal APIs.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace Client;

[Authorize("MfaRequired")]
public class MfaRequiredModel(ILogger<MfaRequiredModel> logger) : PageModel
{
    public ILogger<MfaRequiredModel> Logger { get; } = logger;

    public void OnGet()
    {
    }
}

The ASP.NET Core client is ready to perform Step Up Challenges, but what about our Duende IdentityServer host? What does it need to implement?

Handling Step Up Challenges in the Duende IdentityServer Host

To change the behavior in our Duende IdentityServer host, we must implement the IAuthorizeInteractionResponseGenerator interface. This interface allows us to redirect user flows to a Multi-factor Authentication page when the request requires it. In the example, the ASP.NET Core client application requests it when the challenge is issued.

In our IdentityServer host application, we will implement a StepUpInteractionResponseGenerator class.

using System.Security.Claims;
using Duende.IdentityServer;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Validation;
using IdentityModel;

namespace ClientStepUp.Identity;

public class StepUpInteractionResponseGenerator(
    IdentityServerOptions options,
    IClock clock,
    ILogger<AuthorizeInteractionResponseGenerator> logger,
    IConsentService consent,
    IProfileService profile)
    : AuthorizeInteractionResponseGenerator(options, clock, logger, consent, profile)
{
    protected override async Task<InteractionResponse> ProcessLoginAsync(ValidatedAuthorizeRequest request)
    {
        var result = await base.ProcessLoginAsync(request);

        if (!result.IsLogin && !result.IsError)
        {
            ArgumentNullException.ThrowIfNull(request.Subject);
            
            if (MfaRequired(request) && !AuthenticatedWithMfa(request.Subject))
            {
                if(UserDeclinedMfa(request.Subject))
                {
                    result.Error = OidcConstants.AuthorizeErrors.UnmetAuthenticationRequirements;
                }
                else
                {
                    result.RedirectUrl = "/Account/Mfa";
                }
            }
        }
        return result;
    }

    private bool MfaRequired(ValidatedAuthorizeRequest request) => 
        MfaRequestedByClient(request) || 
        (request is {Subject.Identity.Name: {} name} && AlwaysUseMfaForUser(name));

    private bool MfaRequestedByClient(ValidatedAuthorizeRequest request)
    {
        return request.AuthenticationContextReferenceClasses?.Contains("mfa") == true;
    }

    // If you have the requirement that some users will always use MFA and
    // others will not, you could implement that here. This might be a user
    // controlled option, or set according to some business logic.
    private bool AlwaysUseMfaForUser(string sub)
    {
        return sub == "bob";
    }

    private bool AuthenticatedWithMfa(ClaimsPrincipal user) =>
        user.Claims.Any(c => c is { Type: "amr", Value: "mfa" });

    private bool UserDeclinedMfa(ClaimsPrincipal user) =>
        user.Claims.Any(c => c is { Type: "declined_mfa", Value: "true" });        
}

You can modify the logic in this implementation to meet your specific business needs. It can also call external resources such as a database or third-party service. Determining the current state of our authentication session is as straightforward as examining the user's claims. We must remember to register our new implementation in our IdentityServer setup code.

builder.Services.AddTransient<IAuthorizeInteractionResponseGenerator, StepUpInteractionResponseGenerator>();

Now, let’s implement a multi-factor authentication page to handle adding the mfa claim to our user.

We must add the following Razor code in our /Account/Mfa/Index.chstml page.

@page
@model ClientStepUp.Identity.Pages.Account.Mfa.Index

<div class="lead">
    <h1>MFA</h1>

    <p>
        This page fakes MFA. In a real implementation, there would be more user 
        interaction here. The intent of this page is to show how to get to custom 
        pages where you can perform additional actions. See 
        StepUpInteractionResponseGenerator.cs for more details.
    </p>

    @if(Model.View.MfaRequestedByClient)
    {
        <p>The @Model.View.ClientName application requires MFA.</p>
    }
</div>

<form asp-page="/Account/Mfa/Index">
    <input type="hidden" asp-for="Input.ReturnUrl" />

    <button class="btn btn-primary" name="Input.Button" value="fake">Fake MFA</button>
    <button class="btn btn-warning" name="Input.Button" value="decline">Decline MFA</button>
</form>

The page model for this page handles the button click and form submission events.

using System.Security.Claims;
using Duende.IdentityServer.Services;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace ClientStepUp.Identity.Pages.Account.Mfa;

public class Index : PageModel
{
    private readonly IIdentityServerInteractionService _interaction;

    public Index(IIdentityServerInteractionService interaction)
    {
        _interaction = interaction;
    }

    [BindProperty]
    public InputModel Input { get; set; }

    public ViewModel View { get; set; }

    public async Task OnGetAsync(string returnUrl)
    {
        await BuildModelAsync(returnUrl);
    }

public async Task<IActionResult> OnPostAsync()
{
    if (ModelState.IsValid)
    {
        var existingProps = (await HttpContext.AuthenticateAsync()).Properties;

        var claims = Input.Button == "fake" ?
            User.Claims
                .Append(new Claim(JwtClaimTypes.AuthenticationMethod, "mfa"))
                .Append(new Claim(JwtClaimTypes.AuthenticationContextClassReference, "1")) :
            User.Claims
                .Append(new Claim("declined_mfa", "true"));
        var newPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims, "mfa", "name", "role"));
        await HttpContext.SignInAsync(newPrincipal, existingProps);

        var authContext = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl);
        if (authContext != null)
        {
            // Safe to trust input, because authContext is non-null
            return Redirect(Input.ReturnUrl);
        }
    }
    // something went wrong, show form with error
    await BuildModelAsync(Input.ReturnUrl);
    return Page();
}

    public async Task BuildModelAsync(string returnUrl)
    {
        var context = await _interaction.GetAuthorizationContextAsync(returnUrl);

        if (context != null)
        {
            Input = new InputModel
            {
                ReturnUrl = returnUrl
            };

            View = new ViewModel
            {
                ClientName = context.Client.ClientName,
                MfaRequestedByClient = context.AcrValues.Contains("mfa")
            };
        }

    }
}

The critical part of this code is the additional calls to SignInAsync, which alter the state of the current ClaimsPrincipal with further claims. We need these claims to complete our Step Up Challenge or inform the client that the challenge has failed.

That’s it! We now have an example of an ASP.NET Core client application and Duende IdentityServer working together to handle Step Up Challenges.

Conclusion

Step Up Challenges are a great way to improve your solution’s security posture. Understanding when and where to change your Duende IdentityServer-powered solutions can help you improve your security with minimal additional effort. In the everyday use case of a client application connected to Duende IdentityServer, a few strategic class overrides can transform the user experience and provide more intent to user actions, with infinite possibilities for implementation.

In the next post, we’ll explore how your APIs can require a Step Up Challenge to be completed. There will be many similarities but also essential differences. You won’t want to miss this!

As always, thanks for reading. Let us know your comments below.