In a previous post, we discussed implementing a Step Up challenge in a typical ASP.NET Core client application powered by Duende IdentityServer. A Step Up challenge helps you build applications that ensure critical actions are performed by the user in question by attempting additional levels of scrutiny.
While client logic can handle most Step Up Challenge scenarios, modern ASP.NET Core solutions may depend on a distributed architecture of APIs, each with unique security requirements.
In this post, we’ll cover the OAuth RFC 9470 specification. This specification allows API developers to request that a client perform a Step Up challenge and issue access tokens that meet the security requirements of an API endpoint. We’ll also see how to implement challenges in your ASP.NET Core APIs and dependent clients.
What is a Step Up Challenge For APIs?
In September 2023, the IETF proposed a Step Up Authentication Challenge Protocol, which states:
“It is not uncommon for resource servers to require different authentication strengths or recentness according to the characteristics of a request. This document introduces a mechanism that resource servers can use to signal to a client that the authentication event associated with the access token of the current request does not meet its authentication requirements and, further, how to meet them.”
Step Up challenges allow APIs, such as your ASP.NET Core solution, to ask users to reaffirm their identity or escalate their privileges before they can act within the application. You’ve likely seen this behavior in applications you use every day, for example:
- A banking application may ask you to reenter your credentials before seeing your routing and account information.
- A banking application may challenge a large transfer amount from one account to another.
- GitHub may challenge you with multi-factor authentication before creating a new access token for a third-party app.
- An e-commerce application may force you to reauthenticate if you decide to make a large purchase.
Each scenario challenges the user to ensure their identity in the scenarios of impactful decisions. The challenge is left to the client's discretion and can take any form the developer decides. Here are some common challenges you’ll see in modern applications.
- Re-entering user credentials at the time of an action.
- Multi-factor authentication includes a phone call, text message, or MFA app.
- Biometrics such as a fingerprint scanner or facial recognition.
- Magic link emails are sent directly to the user’s email address.
While these are common challenges implemented in many applications, Duende IdentityServer allows you to execute any Step Up challenge mechanism that makes sense for your solutions. Let’s continue to review our sample to see what you must implement to get Step Up Challenges working in your existing deployments.
Implementing Step Up Challenges with Duende IdentityServer
Most ASP.NET Core solutions have two essential elements: the IdentityServer host and the clients. In a Step Up challenge, the client determines whether the user’s current authentication is adequate. We continue performing that operation if the user has a valid authentication state. If the authentication requires “more”, then we redirect the user to IdentityServer to perform additional actions to elevate their authentication state.
In the Duende provided sample, the “more” mixes time-based claims and multi-factor authentication (MFA) depending on the user’s action.
You can follow along with the solution breakdown by cloning the Duende samples repository and opening the StepUp.sln
solution file, which was imagined and implemented by our very own Dominick Baier.
Requirements on the ASP.NET Core Clients
The Duende sample has two clients: an ASP.NET Core API with business logic and an ASP.NET Core web application that calls authenticated endpoints defined on the API. Our sample setup follows the typical architecture we see with many customers.
In the sample, the API determines whether the user can call and execute an endpoint. The web application is responsible for challenging users and redirecting them to the IdentityServer instance if they fail to meet the authorization requirements.
Let’s start with the API endpoints and their authorization requirements. We register the authorization policies in the API’s setup code, and the authorization service will use these policies when we apply them to our endpoints.
Various API endpoints will use each policy to initiate potential Step Up actions an API can require: the user will need to re-authenticate after a specific time elapsed, perform MFA, or perform MFA after a particular time elapsed.
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)));
});
});
These policies are used on API endpoints using the AuthorizeAttribute
.
[HttpGet]
[Route("max-age")]
[Authorize("MaxAgeOneMinute")]
public IEnumerable<string> MaxAge()
{
yield return ShowAge();
}
[HttpGet]
[Route("mfa")]
[Authorize("MfaRequired")]
public IEnumerable<string> MfaRequired()
{
yield return ShowAmrValues();
}
[HttpGet]
[Route("both")]
[Authorize("RecentMfa")]
public IEnumerable<string> Both()
{
yield return ShowAge();
yield return ShowAmrValues();
}
So far, this is standard ASP.NET Core infrastructure code you’d see in most implementations.
Now, we need to handle the fail state of these authorization attempts, and we can do that by implementing the IAuthorizationMiddlewareResultHandler
interface found in the Microsoft.AspNetCore.Authorization
namespace. We’ll implement a StepUpAuthorizationMiddlewareResultHandler
to look for the failed authorization results and send that information to any API endpoint caller.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Authorization.Policy;
namespace Api.Authorization;
public class StepUpAuthorizationMiddlewareResultHandler
: IAuthorizationMiddlewareResultHandler
{
private readonly AuthorizationMiddlewareResultHandler defaultHandler = new();
public async Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authResult)
{
// If the authorization was forbidden due to a step-up requirement, set
// the status code and WWW-Authenticate header to indicate that step-up
// is required
if (authResult.Forbidden)
{
var maxAgeReq = authResult.AuthorizationFailure!.FailedRequirements
.OfType<MaxAgeRequirement>().FirstOrDefault();
var mfaReq = authResult.AuthorizationFailure!.FailedRequirements
.OfType<ClaimsAuthorizationRequirement>()
.FirstOrDefault(r => r.ClaimType == "amr" && r.AllowedValues!.Contains("mfa"));
if (maxAgeReq != null || mfaReq != null)
{
var header = new StepUpWWWAuthenticateHeader();
if (maxAgeReq != null)
{
header.MaxAge = (int)maxAgeReq.MaxAge.TotalSeconds;
}
if (mfaReq != null)
{
header.AcrValues = "mfa";
}
context.Response.Headers.WWWAuthenticate = header.ToString();
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
}
// Fall back to the default implementation.
await defaultHandler.HandleAsync(next, context, policy, authResult);
}
}
public class StepUpWWWAuthenticateHeader
{
private readonly string Error = "insufficient_user_authentication";
private string ErrorDescription
{
get
{
var ret = string.Empty;
if (MaxAge != null)
{
ret += "More recent authentication is required. ";
}
if (AcrValues != null)
{
ret += "MFA is required. ";
}
return ret;
}
}
public int? MaxAge { get; set; }
public string? AcrValues { get; set; }
public override string ToString()
{
var props = new List<string> {
$"Bearer error=\"{Error}\"",
$"error_description=\"{ErrorDescription}\""
};
if (MaxAge != null)
{
props.Add($"max_age={MaxAge}");
}
if (AcrValues != null)
{
props.Add($"acr_values={AcrValues}");
}
return string.Join(',', props);
}
}
That’s it for the API. Based on the user’s current claims, the API instance can determine whether the user has enough authorization to execute an endpoint.
In the web application, we must set up an HttpClient
that uses the current user’s authentication credentials to call and execute these API endpoints. Within .NET, we can intercept all requests and responses in an HttpClient
by implementing and registering a DelegatingHandler
. Since our API will respond with WWW-Authenticate
headers, we must check every response to determine if authorization has failed.
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Authentication;
public class StepUpHandler : DelegatingHandler
{
public StepUpHandler(IHttpContextAccessor accessor)
{
_contextAccessor = accessor;
}
private readonly IHttpContextAccessor _contextAccessor;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var response = await base.SendAsync(request, cancellationToken);
if (response.Headers.Contains("WWW-Authenticate"))
{
var authParam = response.Headers.WwwAuthenticate.First().Parameter;
if (string.IsNullOrEmpty(authParam))
{
return response;
}
var attributes = ParseWwwAuthenticateParameter(authParam);
var props = new AuthenticationProperties();
if (attributes.TryGetValue("max_age", out string? maxAge))
{
props.Items.Add("max_age", maxAge);
}
if (attributes.TryGetValue("acr_values", out string? acrValues))
{
props.Items.Add("acr_values", acrValues);
}
var httpContext = _contextAccessor.HttpContext;
if (props.Items.Any())
{
await httpContext!.ChallengeAsync("oidc", props);
}
}
return response;
}
private Dictionary<string, string> ParseWwwAuthenticateParameter(string parameter)
{
return parameter
.Split(',')
.Select(a => a.Trim())
.Select(a => a.Split('=').Select(x => x.Trim()).ToList())
.ToDictionary(a => a[0], a => a[1]);
}
}
As you can see, if our API's response fails, we begin building a challenge using AuthenticationProperties
with the necessary values. The code then calls ChallengeAsync
, redirecting the user to IdentityServer.
Now, let’s register the StepUpHandler
within our services collection in the web application. The registration will allow us to create an HttpClient
instance to call our API.
builder.Services.AddTransient<StepUpHandler>();
builder.Services.AddOpenIdConnectAccessTokenManagement();
builder.Services
.AddUserAccessTokenHttpClient(
"StepUp",
configureClient: client =>
{
client.BaseAddress = new Uri("https://localhost:7001/step-up/");
})
.AddHttpMessageHandler<StepUpHandler>();
We’ll also need to update our OpenID connect registration in the web application to handle OpenID events.
builder.Services.AddAuthentication(opt =>
{
opt.DefaultScheme = "cookie";
opt.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie")
.AddOpenIdConnect("oidc", opt =>
{
opt.Authority = "https://localhost:5001";
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.ContainsKey("acr_values"))
{
ctx.ProtocolMessage.AcrValues = ctx.Properties.Items["acr_values"];
}
if (ctx.Properties.Items.ContainsKey("max_age"))
{
ctx.ProtocolMessage.MaxAge = ctx.Properties.Items["max_age"];
}
return Task.CompletedTask;
};
opt.Events.OnRemoteFailure = ctx =>
{
if (ctx.Failure?.Data.Contains("error") ?? false)
{
var error = ctx.Failure.Data["error"] as string;
if (error == IdentityModel.OidcConstants.AuthorizeErrors.UnmetAuthenticationRequirements)
{
ctx.HandleResponse();
ctx.Response.Redirect("/MfaDeclined");
}
}
return Task.CompletedTask;
};
});
The OnRedirectToIdentityProvider
event adds information we added to the AuthenticationProperties
within our StepUpHandler
to the protocol message.
The OnRemoteFailure
event will allow our web application to handle failures issued by the IdentityServer instance. Let’s get to that now.
Requirements on the IdentityServer Host
We’re close to implementing a complete Step Up Challenge flow in an ASP.NET Core solution. To customize the login flow in Duende IdentityServer, we need to overwrite the existing IAuthorizeInteractionResponseGenerator
implementation with a new one.
Looking at the sample, we already have the StepUpInteractionResponseGenerator
.
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 IdentityServerHost;
public class StepUpInteractionResponseGenerator : AuthorizeInteractionResponseGenerator
{
public StepUpInteractionResponseGenerator(
IdentityServerOptions options,
IClock clock,
ILogger<AuthorizeInteractionResponseGenerator> logger,
IConsentService consent,
IProfileService profile) : base(options, clock, logger, consent, profile)
{
}
protected override async Task<InteractionResponse> ProcessLoginAsync(ValidatedAuthorizeRequest request)
{
var result = await base.ProcessLoginAsync(request);
if (!result.IsLogin && !result.IsError)
{
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) ||
AlwaysUseMfaForUser(request.Subject.Identity.Name);
private bool MfaRequestedByClient(ValidatedAuthorizeRequest request)
{
return request.AuthenticationContextReferenceClasses.Contains("mfa");
}
// 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.Type == "amr" && c.Value == "mfa");
private bool UserDeclinedMfa(ClaimsPrincipal user) =>
user.Claims.Any(c => c.Type == "declined_mfa" && c.Value == "true");
}
The logic in this class can be anything you want, but let’s discuss some rules we have implemented.
- Bob is a crucial user, and they must always use MFA.
- If the request contains the
acr
value ofmfa
, redirect the user to the MFA page. - If the user fails or declines the MFA process, we will return an error result.
We must now implement the logic in our /Account/Mfa
page. If the MFA challenge is successful, we need to reissue the user’s claims with updated information stating they have passed. The /Account/Mfa/Index.cshtml
page presents the user with two buttons: “Fake MFA” and “Decline MFA.”
<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>
Jumping to the /Account/Mfa/Index.cshtml.cs
page, we see the results of a user accepting the faked MFA challenge and the result of a decline.
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();
}
The StepUpInteractionResponseGenerator
then picks up the latest ClaimsPrincipal
, and the challenge continues. The IdentityServer host then returns the latest claims to our ASP.NET Core web application, allowing us to execute the API endpoint.
There you have it; you have now added the Step Up Challenge Protocol to your Duende IdentityServer-powered solution.
Conclusion
Implementing a Step Up Challenge flow in an ASP.NET Core web application provides a secure and efficient way to enhance authentication security by requiring users to provide additional factors when accessing sensitive resources. By using OpenID Connect and IdentityServer, we can seamlessly integrate this mechanism into existing authentication pipelines, ensuring that users have an authenticated and authorized status as the situation demands. This approach not only strengthens the security posture of web applications but also offers flexibility in accommodating various authentication requirements you only get when choosing Duende IdentityServer as your identity management provider.
We hope you enjoyed this blog post. For more helpful Duende IdentityServer information, check out our documentation and join our community discussions to interact with other Duende Software community members.