Role Based JWT Authentication in ASP.NET Core Web API

Role-Based JWT Authentication in ASP.NET Core Web API

In this article, I will discuss implementing Role-Based JWT Authentication in ASP.NET Core Web API Applications. Please read our previous article on Implementing Revoke Refresh Tokens in JWT-Based Token Authentication.

What is Role-Based Authentication?

Role-Based Authentication (often referred to as Role-Based Access Control or RBAC) is a security mechanism in which users are granted access to resources based on their roles within an organization. A role represents a group or category of users with the same permissions or access rights. For example, in an application, we might have roles such as “Admin,” “Seller,” “Customer,” etc., and each role has its own set of permissions for accessing specific parts of the application.

How Does It Work:

In role-based authentication, once a user logs in and their identity is verified, the system determines their roles. These roles decide which parts of the application the user can access and what actions they can perform. With RBA:

  • Define Roles: Establish roles that reflect different access levels within the application.
  • Assign Roles to Users: Each user is assigned one or more roles based on their responsibilities.
  • Enforce Access Control: The application checks the user’s roles to determine if they have permission to perform specific actions or access certain resources.
Example:

Each role has specific permissions, and users are assigned roles accordingly. Consider an e-commerce application with the following roles:

  • Admin: Can manage products, view all orders, and manage users.
  • Seller: Can add and manage their products and view their sales.
  • Customer: Can browse products, place orders, and view their order history.
Modifying Products Controller of our Resource Server Application:

The [Authorize] attribute in ASP.NET Core restricts access to controllers or actions based on authentication and authorization rules. By specifying roles, you can control which users can access specific endpoints based on their assigned roles. So, please modify the ProductsController of our Resource Server application as follows:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ResourceServer.Models;

namespace ResourceServer.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        // In-memory list to store products
        private static readonly List<Product> Products = new List<Product>
        {
            new Product { Id = 1, Name = "Product A", Price = 10.0M, Description = "Test Product A" },
            new Product { Id = 2, Name = "Product B", Price = 20.0M, Description = "Test Product B"  },
            new Product { Id = 3, Name = "Product C", Price = 30.0M, Description = "Test Product C"  }
        };

        private static int _nextId = 4; // To auto-increment product IDs

        // Only Authorize (No specific role needed)
        // User must be authenticated but can be in ANY role or have no role at all.
        [Authorize]
        [HttpGet("GetAll")]
        public ActionResult<List<Product>> GetAllProduct()
        {
            return Ok(Products);
        }

        // Authorize with Admin Role only
        // User must be authenticated AND must be in the "Admin" role.
        [Authorize(Roles = "Admin")]
        [HttpGet("GetById/{id}", Name = "GetProductById")]
        public ActionResult<Product> GetProductById(int id)
        {
            var product = Products.FirstOrDefault(p => p.Id == id);
            if (product == null)
            {
                return NotFound(new { message = $"Product with ID {id} not found." });
            }

            return Ok(product);
        }

        // Authorize with User Role only
        // User must be authenticated AND must be in the "User" role.
        [Authorize(Roles = "User")]
        [HttpPost("Add")]
        public IActionResult AddProduct([FromBody] Product product)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            product.Id = _nextId++;
            Products.Add(product);

            return CreatedAtRoute("GetProductById", new { id = product.Id }, product);
        }

        // Authorize with either "Admin" AND "User" Roles
        // Specifying multiple roles in [Authorize(Roles = "Admin,User")]
        // means the user must be in EITHER the "Admin" OR the "User" role (logical OR).
        [Authorize(Roles = "Admin,User")]
        [HttpPut("Update/{id}")]
        public IActionResult UpdateProduct(int id, [FromBody] Product updatedProduct)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var existingProduct = Products.FirstOrDefault(p => p.Id == id);
            if (existingProduct == null)
            {
                return NotFound(new { message = $"Product with ID {id} not found." });
            }

            existingProduct.Name = updatedProduct.Name;
            existingProduct.Description = updatedProduct.Description;
            existingProduct.Price = updatedProduct.Price;

            return NoContent();
        }

        // Authorize with both Admin or User Role
        // There are two separate [Authorize] attributes with "Admin" and "User" roles which means
        // that users having both roles can access this endpoint.
        [Authorize(Roles = "Admin")]
        [Authorize(Roles = "User")]
        [HttpDelete("Delete/{id}")]
        public IActionResult DeleteProduct(int id)
        {
            var product = Products.FirstOrDefault(p => p.Id == id);
            if (product == null)
            {
                return NotFound(new { message = $"Product with ID {id} not found." });
            }

            Products.Remove(product);
            return NoContent();
        }
    }
}
Code Explanations:
  • [Authorize]: This restricts access to the endpoint to any authenticated user, regardless of their roles. The user must be authenticated (i.e., present a valid JWT token), but they don’t need any specific role. Any user who has successfully authenticated can access the GetAllProduct endpoint. Roles are not considered in this scenario.
  • [Authorize(Roles = “Admin”)]: Only users who have the “Admin” role can access the endpoint. The GetProductById endpoint is restricted to users who possess the “Admin” role. Even if a user is authenticated, they cannot access this endpoint unless they have the “Admin” role.
  • [Authorize(Roles = “User”)]: Only users who have the “User” role can access the endpoint. The AddProduct endpoint is restricted to users who possess the “User” role. Authenticated users without the “User” role cannot access this endpoint.
  • [Authorize(Roles = “Admin,User”)]: Users having either “Admin” or “User” roles to access the endpoint. The UpdateProduct endpoint is restricted to authenticated users who possess either the “User” or “Admin” role.
  • [Authorize(Roles = “Admin”)], [Authorize(Roles = “User”)]: The user must have both “Admin” and “User” roles to access the endpoint. The DeleteProduct endpoint is restricted to authenticated users who possess both “User” or “Admin” roles.

Now, run the application and test the functionalities, and it should work as expected. We have already stored Role Claims while generating the Token. Please make sure to test the user having only the User Role and only the Admin Role, and the user having both User and Admin roles.

What is the Authorization Policy?

An Authorization Policy in ASP.NET Core is a powerful and flexible mechanism that defines a set of requirements and rules determining whether a user is authorized to access specific resources or perform certain actions within an application. Policies encapsulate complex authorization logic, allowing developers to enforce granular access controls beyond simple role-based checks.

An Authorization Policy is a set of requirements a user must satisfy to access a resource or perform an action within an ASP.NET Core application. Policies abstract the authorization logic, making it reusable and maintainable.

How It Works:
  • Define a Policy: Specify the requirements that users must meet.
  • Register the Policy: Configure the policy within the application’s service container.
  • Apply the Policy: Use the [Authorize(Policy = “PolicyName”)] attribute on controllers or actions to enforce the policy.
How to Implement Authorization Policies in ASP.NET Core:

We will create the following five policies:

  • Only Authorize No Role: Any authenticated user can access the endpoint, regardless of their role.
  • Authorize with Admin Role: Only users with the “Admin” role can access the endpoint.
  • Authorize with User Role: Only users with the “User” role can access the endpoint.
  • Authorize with Both Admin and User Roles: Only users who possess both “Admin” and “User” roles can access the endpoint.
  • Authorize with Either Admin or User Role: Users with either the “Admin” or “User” role can access the endpoint.
Implementing Authorization Policies in Program.cs

To define authorization policies, we need to configure them in our Program.cs. We need to add Authorization Policies within the AddAuthorization configuration. So, please modify the Program class as follows:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

namespace ResourceServer
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddControllers()
            .AddJsonOptions(options =>
            {
                // This will use the property names as defined in the C# model
                options.JsonSerializerOptions.PropertyNamingPolicy = null;
            });

            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = builder.Configuration["Jwt:Issuer"],
                    ValidateAudience = false,
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
                    {
                        var httpClient = new HttpClient();
                        var jwks = httpClient.GetStringAsync(builder.Configuration["Jwt:JWKS"]).Result;
                        var keys = new JsonWebKeySet(jwks).Keys;
                        return keys;
                    }
                };
            });

            // Define Authorization Policies
            builder.Services.AddAuthorization(options =>
            {
                // Scenario 1: Only authenticate (no specific role)
                // This policy simply requires the user to be authenticated
                options.AddPolicy("AuthenticatedUser", policy =>
                {
                    policy.RequireAuthenticatedUser();
                });

                // Scenario 2: Require Admin role
                options.AddPolicy("AdminOnly", policy =>
                {
                    policy.RequireRole("Admin");
                });

                // Scenario 3: Require User role
                options.AddPolicy("UserOnly", policy =>
                {
                    policy.RequireRole("User");
                });

                // Scenario 4: Require either Admin OR User role
                // With RequireRole, listing multiple roles is an OR condition
                options.AddPolicy("AdminOrUser", policy =>
                {
                    policy.RequireRole("Admin", "User");
                });

                // Scenario 5:  Authorize with Both Admin and User Roles
                // Multiple RequireRole calls within a policy are treated as AND conditions
                options.AddPolicy("AdminAndUser", policy =>
                    policy.RequireRole("Admin")
                          .RequireRole("User"));
            });

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthentication();

            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}
Updating the ProductsController to Use Policies

With the policies defined, we can now apply them to our ProductsController. This involves replacing the existing [Authorize] attributes with [Authorize(Policy = “PolicyName”)].

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ResourceServer.Models;

namespace ResourceServer.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        // In-memory list to store products
        private static readonly List<Product> Products = new List<Product>
        {
            new Product { Id = 1, Name = "Product A", Price = 10.0M, Description = "Test Product A" },
            new Product { Id = 2, Name = "Product B", Price = 20.0M, Description = "Test Product B"  },
            new Product { Id = 3, Name = "Product C", Price = 30.0M, Description = "Test Product C"  }
        };

        private static int _nextId = 4; // To auto-increment product IDs

        // Only Authorize (No specific role needed)
        // User must be authenticated but can be in ANY role or have no role at all.
        [Authorize(Policy = "AuthenticatedUser")]
        [HttpGet("GetAll")]
        public ActionResult<List<Product>> GetAllProduct()
        {
            return Ok(Products);
        }

        // Authorize with Admin Role only
        // User must be authenticated AND must be in the "Admin" role.
        [Authorize(Policy = "AdminOnly")]
        [HttpGet("GetById/{id}", Name = "GetProductById")]
        public ActionResult<Product> GetProductById(int id)
        {
            var product = Products.FirstOrDefault(p => p.Id == id);
            if (product == null)
            {
                return NotFound(new { message = $"Product with ID {id} not found." });
            }

            return Ok(product);
        }

        // Authorize with User Role only
        // User must be authenticated AND must be in the "User" role.
        [Authorize(Policy = "UserOnly")]
        [HttpPost("Add")]
        public IActionResult AddProduct([FromBody] Product product)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            product.Id = _nextId++;
            Products.Add(product);

            return CreatedAtRoute("GetProductById", new { id = product.Id }, product);
        }

        // Authorize with either "Admin" AND "User" Roles
        // Specifying multiple roles in [Authorize(Roles = "Admin,User")]
        // means the user must be in EITHER the "Admin" OR the "User" role (logical OR).
        [Authorize(Policy = "AdminOrUser")]
        [HttpPut("Update/{id}")]
        public IActionResult UpdateProduct(int id, [FromBody] Product updatedProduct)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var existingProduct = Products.FirstOrDefault(p => p.Id == id);
            if (existingProduct == null)
            {
                return NotFound(new { message = $"Product with ID {id} not found." });
            }

            existingProduct.Name = updatedProduct.Name;
            existingProduct.Description = updatedProduct.Description;
            existingProduct.Price = updatedProduct.Price;

            return NoContent();
        }

        // Authorize with both Admin or User Role
        // User must have BOTH "Admin" AND "User" roles.
        [Authorize(Policy = "AdminAndUser")]
        [HttpDelete("Delete/{id}")]
        public IActionResult DeleteProduct(int id)
        {
            var product = Products.FirstOrDefault(p => p.Id == id);
            if (product == null)
            {
                return NotFound(new { message = $"Product with ID {id} not found." });
            }

            Products.Remove(product);
            return NoContent();
        }
    }
}
Role-Based Hierarchical Access Control:

Implementing a Role Hierarchy in our ASP.NET Core application allows us to define a structured access control system where higher-level roles inherit the permissions of lower-level roles. For example:

  • Admin can access all endpoints.
  • Editor can access endpoints requiring Editor or User roles.
  • User can access endpoints requiring User role only.

We can implement a custom authorization policy that understands the role hierarchy to achieve this. Let us proceed and implement this role-based hierarchical access control in our ASP.NET Core Web API application.

Understanding Role Hierarchy

A Role Hierarchy allows roles to inherit permissions from other roles. For example:

  • Admin: Inherits permissions from both Editor and User.
  • Editor: Inherits permissions from the User.
  • User: Base role with the least permissions.

This hierarchy ensures that higher-level roles automatically possess the permissions of the roles beneath them, reducing redundancy and simplifying access control management.

Add authorization Policies for roles in the Program Class:

Define policies that encapsulate your hierarchical role requirements, enhancing maintainability and clarity. So, please modify the Program class as follows to define hierarchical role-based policies for Admin, Editor, and User.

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

namespace ResourceServer
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddControllers()
            .AddJsonOptions(options =>
            {
                // This will use the property names as defined in the C# model
                options.JsonSerializerOptions.PropertyNamingPolicy = null;
            });

            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = builder.Configuration["Jwt:Issuer"],
                    ValidateAudience = false,
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
                    {
                        var httpClient = new HttpClient();
                        var jwks = httpClient.GetStringAsync(builder.Configuration["Jwt:JWKS"]).Result;
                        var keys = new JsonWebKeySet(jwks).Keys;
                        return keys;
                    }
                };
            });

            // Define Authorization Policies
            builder.Services.AddAuthorization(options =>
            {
                // Policy for User role (accessible by User, Editor, Admin)
                options.AddPolicy("UserPolicy", policy =>
                    policy.RequireRole("User", "Editor", "Admin"));

                // Policy for Editor role (accessible by Editor, Admin)
                options.AddPolicy("EditorPolicy", policy =>
                    policy.RequireRole("Editor", "Admin"));

                // Policy for Admin role (accessible by Admin only)
                options.AddPolicy("AdminPolicy", policy =>
                    policy.RequireRole("Admin"));
            });

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthentication();

            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}
Code Explanation:
  • UserPolicy: Grants access to users with User, Editor, or Admin roles.
  • EditorPolicy: Grants access to users with Editor or Admin roles.
  • AdminPolicy: Grants access exclusively to users with the Admin role.

These policies streamline authorization checks and make your controllers/actions cleaner.

Apply Policies to Controller Actions

In the controller classes where you want to enforce role-based access, add [Authorize(Policy = “PolicyName”)] attributes to the actions. For a better understanding, please modify the Products Controller as follows:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ResourceServer.Models;

namespace ResourceServer.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        // In-memory list to store products
        private static readonly List<Product> Products = new List<Product>
        {
            new Product { Id = 1, Name = "Product A", Price = 10.0M, Description = "Test Product A" },
            new Product { Id = 2, Name = "Product B", Price = 20.0M, Description = "Test Product B"  },
            new Product { Id = 3, Name = "Product C", Price = 30.0M, Description = "Test Product C"  }
        };

        private static int _nextId = 4; // To auto-increment product IDs

        // GET: api/Products/GetAll
        // No authentication or authorization is required to access this endpoint.
        // Anyone (even unauthenticated users) can call this endpoint.
        [AllowAnonymous]
        [HttpGet("GetAll")]
        public ActionResult<List<Product>> GetAllProduct()
        {
            return Ok(Products);
        }

        // GET: api/Products/GetById/{id}
        // Requires the user to be authenticated but does not enforce any specific role.
        // Any authenticated user can call this endpoint regardless of their role.
        [Authorize]
        [HttpGet("GetById/{id}", Name = "GetProductById")]
        public ActionResult<Product> GetProductById(int id)
        {
            var product = Products.FirstOrDefault(p => p.Id == id);
            if (product == null)
            {
                return NotFound(new { message = $"Product with ID {id} not found." });
            }

            return Ok(product);
        }

        // POST: api/Products/Add
        // Requires the user to be authenticated and to have at least one of the roles in the "UserPolicy".
        // Accessible by Admin, Editor, or User roles.
        [Authorize(Policy = "UserPolicy")]
        [HttpPost("Add")]
        public IActionResult AddProduct([FromBody] Product product)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            product.Id = _nextId++;
            Products.Add(product);

            return CreatedAtRoute("GetProductById", new { id = product.Id }, product);
        }

        // PUT: api/Products/Update/{id}
        // Requires the user to be authenticated and to have at least one of the roles in the "EditorPolicy".
        // Accessible by Admin or Editor roles only.
        [Authorize(Policy = "EditorPolicy")]
        [HttpPut("Update/{id}")]
        public IActionResult UpdateProduct(int id, [FromBody] Product updatedProduct)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var existingProduct = Products.FirstOrDefault(p => p.Id == id);
            if (existingProduct == null)
            {
                return NotFound(new { message = $"Product with ID {id} not found." });
            }

            existingProduct.Name = updatedProduct.Name;
            existingProduct.Description = updatedProduct.Description;
            existingProduct.Price = updatedProduct.Price;

            return NoContent();
        }

        // DELETE: api/Products/Delete/{id}
        // Requires the user to be authenticated and to have the role defined in the "AdminPolicy".
        // Only Admins can access this endpoint.
        [Authorize(Policy = "AdminPolicy")]
        [HttpDelete("Delete/{id}")]
        public IActionResult DeleteProduct(int id)
        {
            var product = Products.FirstOrDefault(p => p.Id == id);
            if (product == null)
            {
                return NotFound(new { message = $"Product with ID {id} not found." });
            }

            Products.Remove(product);
            return NoContent();
        }
    }
}
Testing the Endpoints:

Test different endpoints using users with different roles. Ensure that admin users can access all endpoints, editor users can access editor policy and user policy endpoints, and user users can only access user policy endpoints.

Why Do We Need Role-Based Token Authentication?

Role-Based Token Authentication combines role-based access control with token-based authentication. This is commonly achieved using JSON Web Tokens (JWT). Role-based token authentication, mainly using JSON Web Tokens (JWT), provides several advantages:

  • Scalability: Since the roles and permissions are encoded in the token, there’s no need to query a database to continuously check the user’s role. This makes the system more scalable, especially in distributed or stateless environments where each request is independent.
  • Reduce Load on the Authentication Server: With JWT, the client holds the token. The server only needs to validate the token and its claims (which include roles). This simplifies the authentication logic and reduces the load on the central authentication server.
  • Security and Role Verification: Including roles in the token means that every service or component receiving the token can immediately know what the user can do without contacting a central authority. You can trust its claims as long as the token is signed and its signature is verified.
  • Flexibility: Role-based token authentication allows us to easily support complex scenarios, such as multi-role users, hierarchical roles, or even permissions-based access, all encoded into a compact token that can be securely passed around.
  • Stateless Architecture: Tokens allow stateless server architectures. JWTs are self-contained tokens that carry all necessary information, eliminating the need for server-side session storage.

So, Role-Based Authentication in ASP.NET Core Web API with JWT allows us to secure our endpoints and enforce different levels of access based on user roles. Using JWT, we can include role information in the token’s claims, which is then evaluated at runtime to determine what each user can access.

In the next article, I will discuss implementing SSO Authentication in ASP.NET Core Web API Application. In this article, I explain How to Implement Role-Based JWT Authentication in ASP.NET Core Web API Application with an Example. I hope you enjoy this Role-Based JWT Authentication in ASP.NET Core Web API article.

Leave a Reply

Your email address will not be published. Required fields are marked *