Policy-Based Authorization in ASP.NET Core: Understanding the Building Blocks
Learn the core building blocks of the policy-based authorization framework in ASP.NET Core and how they work together to create flexible and powerful access control rules.
The policy-based authorization framework in ASP.NET Core lets you define richer and more flexible rules for controlling access than simple role checks.
In this post, we'll break down the building blocks of the policy-based authorization framework and put together a simple example to see how everything fits together.
I'll be using Amazon Cognito as the identity provider, but the same approach works with any identity provider.
Thanks to AWS for sponsoring it in my Cognito Series.
The Three Building Blocks
The policy-based authorization framework in ASP.NET Core is built on three fundamental components that work together to control access to your application.
Policy
A policy is a named set of rules that determines whether a user can access a resource or not. This is essentially the what you're trying to enforce.
For example, you might have an "AdminOnly" policy that ensures only administrators can access certain functionality.
Requirement
A requirement is a single rule or condition within a policy. It represents the criteria that must be satisfied for access to be allowed.
Requirements can be combined within a policy - you might require a user to be authenticated AND have a specific claim AND have a minimum age. Each of these would be a separate requirement.
Requirement Handler
A requirement handler is the logic that evaluates whether a requirement is met or not. This is the how - the handler implements the actual checking logic.
For instance, a handler might check if a user's claims contain a specific value, or verify that a user belongs to a certain group.
Translating to ASP.NET Core
In ASP.NET Core, these concepts are represented by specific interfaces and classes:
- IAuthorizationRequirement: A marker interface (an interface with no methods) used to identify authorization requirements
- IAuthorizationHandler: The interface that defines handlers for requirements
- Policy: A collection of requirements registered with a name
Let's see how this works with a simple example.
Building our First ASP NET Policy
Let's create a policy that restricts access to users who belong to an "Admin" group in Amazon Cognito.
Step 1: Define the Requirement
First, we'll create a requirement class. Since IAuthorizationRequirement is a marker interface with no methods, our requirement class is straightforward:
public class AdminOnlyRequirement : IAuthorizationRequirement{}
Step 2: Create the Requirement Handler
Next, we'll implement the handler that checks whether this requirement is satisfied:
public class AdminOnlyRequirementHandler : AuthorizationHandler<AdminOnlyRequirement>{protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,AdminOnlyRequirement requirement){if (context.User.HasClaim(c => c.Type == "cognito:groups" && c.Value == "Admin")){context.Succeed(requirement);}return Task.CompletedTask;}}
The handler inherits from AuthorizationHandler<T>, which is a base class that implements IAuthorizationHandler.
The generic type parameter specifies which requirement this handler is for.
In the HandleRequirementAsync method, we check if the user has a claim with the required value. If the claim exists, we call context.Succeed(requirement) to indicate this requirement is satisfied. If the claim doesn't exist, we simply return without calling Succeed, which means this handler doesn't mark the requirement as successful.
Not calling Succeed doesn't mean the requirement has completely failed. You
can have multiple handlers for the same requirement, and as long as one
handler succeeds, the requirement is satisfied.
Step 3: Register the Handler and Policy
The handler needs to be registered in the dependency injection container. Since there's no state in our handler, we can register it as a singleton:
builder.Services.AddSingleton<IAuthorizationHandler, AdminOnlyRequirementHandler>();
Now we can create a policy that uses our requirement:
builder.Services.AddAuthorization(options =>{options.AddPolicy("AdminOnly", policy =>{policy.Requirements.Add(new AdminOnlyRequirement());});});
This creates a policy named "AdminOnly" and adds our AdminOnlyRequirement to it.
Step 4: Apply the Policy
Finally, we can use the policy to protect our endpoints using the Authorize attribute and speicfying the Policy property.
[Authorize(Policy = "AdminOnly")][HttpPost]public IActionResult CreateUser(){// Only users in the Admin group can access thisreturn Ok("User created successfully");}
How It All Works Together
When a request comes in to an endpoint protected with [Authorize(Policy = "AdminOnly")], here's what happens:
- The ASP.NET Core authorization framework looks up the "AdminOnly" policy
- It finds the
AdminOnlyRequirementin that policy - It locates all registered handlers for
AdminOnlyRequirement(in our case,AdminOnlyRequirementHandler) - It calls each handler's
HandleRequirementAsyncmethod - If any handler calls
context.Succeed(requirement), the requirement is satisfied - If all requirements in the policy are satisfied, access is granted
- If any requirement is not satisfied, the user receives a 403 Forbidden response
What About RequireClaim and RequireAuthenticatedUser?
You might have seen code like this before:
builder.Services.AddAuthorization(options =>{options.AddPolicy("AdminOnly", policy =>{policy.RequireAuthenticatedUser();policy.RequireClaim("cognito:groups", "Admin");});});
Under the hood, these convenience methods are doing exactly what we just did manually. Let's look at RequireClaim:
If you navigate into the RequireClaim method, you'll see it creates a ClaimsAuthorizationRequirement and adds it to the policy's requirements.
public AuthorizationPolicyBuilder RequireClaim(string claimType, string claimValue){Requirements.Add(new ClaimsAuthorizationRequirement(claimType, claimValue));return this;}
The ClaimsAuthorizationRequirement class implements both IAuthorizationRequirement and IAuthorizationHandler in the same class:
public class ClaimsAuthorizationRequirement :IAuthorizationRequirement,IAuthorizationHandler{public string ClaimType { get; }public IEnumerable<string> AllowedValues { get; }public Task HandleRequirementAsync(AuthorizationHandlerContext context,ClaimsAuthorizationRequirement requirement){var claim = context.User.Claims.FirstOrDefault(c => c.Type == requirement.ClaimType &&requirement.AllowedValues.Contains(c.Value));if (claim != null){context.Succeed(requirement);}return Task.CompletedTask;}}
One advantage of combining the requirement and handler in the same class is that:
- The logic stays together
- You don't need to explicitly register the handler in the DI container
Similarly, RequireAuthenticatedUser creates a DenyAnonymousAuthorizationRequirement that checks if the user is authenticated.
The Default Authorization Service
When we call AddAuthorization, we're registering the default authorization services:
builder.Services.AddAuthorization();
This call internally registers DefaultAuthorizationService, which implements IAuthorizationService. This service is responsible for navigating through all policies, finding all requirements in each policy, locating all handlers for each requirement, executing the handlers, and determining whether authorization succeeds or fails.
You can override these default providers and write custom implementations for advanced use cases, but for most scenarios, the default implementation is sufficient.