ASP.NET Core has seen an overhaul to its authorization system in the form of authorization policies. As a developer, you can add several policies that can check claims, require an authenticated user, and even handle a custom requirement with any code you can write. It’s a powerful module system that allows for incredible flexibility when building your authorization system. However, with tremendous flexibility comes the inevitable complexity of finding and implementing the right part to change the system’s behavior. This post will explore how to change the behavior and the ultimate result of a failed authorization policy.
Adding a Custom Authorization Policy
If you want to use your policy across all the frameworks inside ASP.NET Core (Minimal APIs, MVC, and Razor Pages), you’ll want to define your policies within your Program
file. Policies are straightforward to implement, as there’s a list of requirements a user must meet before authorizing a request. First, let’s look at a specific policy.
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("woopsy", policy =>
{
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new FailRequirement());
});
});
builder.Services.AddTransient<IAuthorizationHandler, FailHandler>();
The woopsy
policy requires that the user be an authenticated user and that they meet the FaileRequirement
. Requirements have a matching AuthorizationHandler
which uses the requirement instance to identify an endpoint and potentially accept any additional metadata. So how are the pair of FailRequirement
and FailHandler
implemented?
public class FailHandler : AuthorizationHandler<FailRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
FailRequirement requirement)
{
context.Fail(new AuthorizationFailureReason(this, "Woopsy"));
return Task.CompletedTask;
}
}
public class FailRequirement : IAuthorizationRequirement
{
// add extra metadata if needed
// like flags, business info, etc.
}
In this case, the handler immediately fails the request by calling context.Fail
while setting an AuthorizationFailureReason
to be used later in the pipeline. As mentioned previously, the FailRequirement
class is a marker class, helping us identify the AuthorizationHandler
that ASP.NET Core should use to meet the requirement.
Let’s add the policy to a Minimal API endpoint (.NET 7+).
app.MapGet("/", () => "Hello, Khalid!")
.RequireAuthorization("woopsy");
Cool! Now we’re ready to fail. What about handling the authorization failure?
Handling the AuthorizationFailureReason
In most cases, ASP.NET Core will handle failed authorizations by either returning a 403 Forbidden
status code or trying to work with your authentication and authorization pipeline defaults. That’s perfectly fine, but sometimes you want to handle failed policies differently. Some examples include:
- Development-time diagnostic pages to help debug policy issues.
- Concealing administrative endpoints with
404 Not Found
for extra security rather than returning a403 Forbidden
status code. - Redirect users to support pages when failed policies have more to do with business policy than technical failure (payment overdue).
The list of possibilities is endless and really up to your imagination.
To handle failed authorizations, we need to register a new IAuthorizationMiddlewareResultHandler
instance. This new handler will accept several data points and allow you to change the response flow.
namespace Microsoft.AspNetCore.Authorization
{
/// <summary>
/// Allow custom handling of authorization and handling of the authorization response.
/// </summary>
public interface IAuthorizationMiddlewareResultHandler
{
/// <summary>
/// Evaluates the authorization requirement and processes the authorization result.
/// </summary>
/// <param name="next">
/// The next middleware in the application pipeline.
/// Implementations may not invoke this if the authorization did not succeed.
/// </param>
/// <param name="context">The <see cref="HttpContext"/>.</param>
/// <param name="policy">The <see cref="AuthorizationPolicy"/> for the resource.</param>
/// <param name="authorizeResult">The result of authorization.</param>
Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult);
}
}
For our implementation, we want to return an HTML page and stop the request processing when our FailRequirement
policy fails.
// namespaces
// Microsoft.AspNetCore.Http
// Microsoft.AspNetCore.Authorization
// Microsoft.Extensions.Logging
public class AuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler
{
private readonly ILogger<AuthorizationMiddlewareResultHandler> _logger;
private readonly Microsoft.AspNetCore.Authorization.Policy.AuthorizationMiddlewareResultHandler _defaultHandler = new();
public AuthorizationMiddlewareResultHandler(ILogger<AuthorizationMiddlewareResultHandler> logger)
{
_logger = logger;
}
public async Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult)
{
var authorizationFailureReason = authorizeResult.AuthorizationFailure?.FailureReasons.FirstOrDefault();
var message = authorizationFailureReason?.Message;
_logger.LogInformation("Authorization Result says {Message}",
message
);
if (authorizationFailureReason?.Handler is FailHandler)
{
var html = $"<html><h1>{message}</h1></html>";
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await Results.Content(html, "text/html").ExecuteAsync(context);
}
else
{
// Fall back to the default implementation.
await _defaultHandler.HandleAsync(next, context, policy, authorizeResult);
}
}
}
You have access to the HttpContext
, so you could potentially do anything with the current request/response. In our case, we only want to change the default behavior when ASP.NET Core handles the FailRequirment
. In all other cases, we want ASP.NET Core to use the default handler to complete the user’s request.
I’ve included the complete ASP.NET Core Minimal API example below to see it all in action.
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<IAuthorizationHandler, FailHandler>();
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationMiddlewareResultHandler>();
builder.Services.AddAuthentication(o =>
{
o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
o.RequireAuthenticatedSignIn = false;
})
.AddCookie();
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("woopsy", policy =>
{
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new FailRequirement());
});
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", () => "Hello, Khalid!")
.RequireAuthorization("woopsy");
app.MapGet("/Account/Login", async (HttpContext ctx, string returnUrl) =>
{
// just sign in to hit the policy
await ctx.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("name", "test@test.com") })));
ctx.Response.Redirect(returnUrl);
});
app.Run();
public class FailHandler : AuthorizationHandler<FailRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
FailRequirement requirement)
{
context.Fail(new AuthorizationFailureReason(this, "Woopsy"));
return Task.CompletedTask;
}
}
public class FailRequirement : IAuthorizationRequirement
{
// add extra metadata if needed
// like flags, business info, etc.
}
public class AuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler
{
private readonly ILogger<AuthorizationMiddlewareResultHandler> _logger;
private readonly Microsoft.AspNetCore.Authorization.Policy.AuthorizationMiddlewareResultHandler _defaultHandler = new();
public AuthorizationMiddlewareResultHandler(ILogger<AuthorizationMiddlewareResultHandler> logger)
{
_logger = logger;
}
public async Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult)
{
var authorizationFailureReason = authorizeResult.AuthorizationFailure?.FailureReasons.FirstOrDefault();
var message = authorizationFailureReason?.Message;
_logger.LogInformation("Authorization Result says {Message}",
message
);
if (authorizationFailureReason?.Handler is FailHandler)
{
var html = $"<html><h1>{message}</h1></html>";
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await Results.Content(html, "text/html").ExecuteAsync(context);
}
else
{
// Fall back to the default implementation.
await _defaultHandler.HandleAsync(next, context, policy, authorizeResult);
}
}
}
Conclusion
There you have it. I hope you’ve learned something new about the authorization pipeline in ASP.NET Core and how you may modify it to meet your use case. Let me know if you have any questions or thoughts by following me on Twitter at @buhakmeh. As always, thanks for reading.