Authentication & Authorization using .NET 8 Identity and JWT

Authentication & Authorization using .NET 8 Identity and JWT

Today I'm going to show you how you can customise ASP.NET Identity to change it's defaulty functionality and add JWT authentication.

Prerequisites

  • .NET SDK (version 8.0 or later)
  • A command-line interface (CLI) such as Command Prompt, PowerShell, or a terminal on macOS/Linux.
  • Visual Studio Code
First of all let's create a .NET Web API. Skip the project setup if you just need to see how to add Identity. Clone the repo if you want, then follow along.



Project Setup

Start by opening your CLI and creating a new Web API project. Run the following command to create a project: This command generates a new Web API project in a folder called DotnetIdentityDemo


dotnet new webapi -n DotnetIdentityDemo











Now I'm going to create a Model named 'Todo' and add some properties, then create a Controller to manage CRUD operations together with a sqlite database. 

First, create two Models in the Models folder for Todo and TodoCreateDto. [View commit on github]

namespace DotnetIdentityDemo.Models
{
    public class TodoCreateDto
    {
        public string Name { get; set; }
        public bool IsComplete { get; set; }
    }
}

namespace DotnetIdentityDemo.Models
{
    public class Todo
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public bool IsComplete { get; set; }
    }
}

Add following packages using .NET CLI. [Commit]

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Tools

Add connections string to the appsettings.json [Commit]

{
  "ConnectionStrings": {
    "TodoContext": "Data Source=todo.db"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

Create the Database context inside the Data folder. [Commit]

using DotnetIdentityDemo.Models;
using Microsoft.EntityFrameworkCore;

namespace DotnetIdentityDemo.Data
{
    public class DatabaseContext: DbContext
    {
        public DatabaseContext(DbContextOptions<DatabaseContext> options)
            : base(options)
        {
        }

        public DbSet<Todo> Todos { get; set; }
    }
}

Create TodoController with endpoints to Create, Update, Delete, Get by Id, Get all, Mark as complete. [Commit]

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DotnetIdentityDemo.Data;
using DotnetIdentityDemo.Models;

namespace DotnetIdentityDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TodosController : ControllerBase
    {
        private readonly DatabaseContext _context;

        public TodosController(DatabaseContext context)
        {
            _context = context;
        }

        // GET: api/todos
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Todo>>> GetTodos()
        {
            return await _context.Todos.ToListAsync();
        }

        // GET: api/todos/5
        [HttpGet("{id}")]
        public async Task<ActionResult<Todo>> GetTodoById(int id)
        {
            var todo = await _context.Todos.FindAsync(id);

            if (todo == null)
            {
                return NotFound();
            }

            return todo;
        }

        // POST: api/todos
        [HttpPost]
        public async Task<ActionResult<Todo>> CreateTodo(TodoCreateDto todoDto)
        {
            // Map the DTO to the Todo model
            var todo = new Todo
            {
                Name = todoDto.Name,
                IsComplete = todoDto.IsComplete
            };

            _context.Todos.Add(todo);
            await _context.SaveChangesAsync();

            return CreatedAtAction(nameof(GetTodoById), new { id = todo.Id }, todo);
        }

        // PUT: api/todos/5
        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateTodoName(int id, string name)
        {
            var todo = await _context.Todos.FindAsync(id);

            if (todo == null)
            {
                return NotFound();
            }

            todo.Name = name;
            await _context.SaveChangesAsync();

            return NoContent();
        }

        // PUT: api/todos/5/complete
        [HttpPut("{id}/complete")]
        public async Task<IActionResult> MarkTodoAsComplete(int id)
        {
            var todo = await _context.Todos.FindAsync(id);

            if (todo == null)
            {
                return NotFound();
            }

            todo.IsComplete = true;
            await _context.SaveChangesAsync();

            return NoContent();
        }

        // DELETE: api/todos/5
        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteTodoById(int id)
        {
            var todo = await _context.Todos.FindAsync(id);

            if (todo == null)
            {
                return NotFound();
            }

            _context.Todos.Remove(todo);
            await _context.SaveChangesAsync();

            return NoContent();
        }
    }
}


Update the Program.cs to register the controller and the database context to the services container. [Commit]

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

builder.Services.AddDbContext<DatabaseContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("TodoContext")));

...................................................


app.UseHttpsRedirection();
app.MapControllers();
app.Run();

Create migration for the initial database state and update the database using following CLI commands.

dotnet ef migrations add InitialCreate
dotnet ef database update

Adding Identity

First of all let's create the User model inside the Models folder, which will be our application user. [Commit]

using Microsoft.AspNetCore.Identity;

namespace DotnetIdentityDemo.Models
{
    public class User : IdentityUser
    {
        public string FirstName { get; set; }
        public string LastName { get; set; }
    }
}

You can see that we've inherited from IdentityUser provided by the Microsoft.AspNetCore.Identity namespace. If we look at the properties of the IdentityUser we can see that most of the properties an application needs to store it's users are provided there. You can either Ctrl+Left Click on the IdentityUser or visit this link.

/// <summary>
/// Gets or sets the primary key for this user.
/// </summary>
[PersonalData]
public virtual TKey Id { get; set; } = default!;

/// <summary>
/// Gets or sets the user name for this user.
/// </summary>
[ProtectedPersonalData]
public virtual string? UserName { get; set; }

/// <summary>
/// Gets or sets the normalized user name for this user.
/// </summary>
public virtual string? NormalizedUserName { get; set; }

/// <summary>
/// Gets or sets the email address for this user.
/// </summary>
[ProtectedPersonalData]
public virtual string? Email { get; set; }

It has properties like Id, UserName, Email etc. but what if we want more properties?, like FirstName and LastName. That's why I have created my own model User, and inherited IdentityUser, so that I get all properties mentioned here + other properties which I need for my application.

Also install package Microsoft.AspNetCore.Identity.EntityFrameworkCore, using,
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore

Now create another Database context for Identity related models, let's call it IdentityContext.

using DotnetIdentityDemo.Models;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace DotnetIdentityDemo.Data
{
    public class IdentityContext : IdentityDbContext<User>
    {
        public IdentityContext(DbContextOptions<IdentityContext> options)
        : base(options)
        {}
    }
}

And after that, register the IdentityContext just like the DatabaseContext.

// Register SQLite database for DatabaseContext
builder.Services.AddDbContext<DatabaseContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("TodoContext")));

// Register SQLite database for IdentityContext
builder.Services.AddDbContext<IdentityContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("TodoContext")));


Next add the Authorization and Authentication and bind our custom User and IdentityContext to it.

// Add Authorization and Authentication
builder.Services.AddAuthorization();
builder.Services.AddAuthentication()
    .AddBearerToken(IdentityConstants.BearerScheme);
builder.Services.AddIdentityCore<User>()
    .AddEntityFrameworkStores<IdentityContext>()
    .AddApiEndpoints();


AddEntityFrameworkStores: Adds an Entity Framework implementation of identity information stores.
AddApiEndpoints: Adds configuration and services needed to support IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi(IEndpointRouteBuilder) but does not configure authentication.
Therefore to configure authentication, we'have added AddAuthentication to configure authentication separately.

Next we need to enable authentication and authorization capabilities.

app.UseHttpsRedirection();

app.UseAuthentication(); <--- This
app.UseAuthorization(); <--- This

app.MapControllers();

app.MapIdentityApi<User>(); <--- And this.

app.Run();

UseAuthentication adds the Microsoft.AspNetCore.Authentication.AuthenticationMiddleware, while UseAuthorization adds the Microsoft.AspNetCore.Authorization.AuthorizationMiddleware to the specified IApplicationBuilder

Also, MapIdentityApi adds endpoints for registering, logging in, and logging out using ASP.NET Core Identity. [Commit]

Now create a migration, this time specifying the database context using --context.
dotnet ef migrations add IdentityUserModels --context IdentityContext and migrate using dotnet ef database update --context IdentityContext

Now if you run the application, you will see Identity endpoints together with Todo endpoints.



Let's try out Register endpoint. 

{
  "email": "someone@abc.com",
  "password": "pa$$word"
}


This request will result in 400 Error: Bad Request response with couple of validation errors.


Let's send a correct request and try to login from that user.


Now we're seeing an exception, 'NOT NULL constraint failed: AspNetUsers.FirstName'. Did you understand what happened? It's because we have updated the IdentityUser, with our own properties, FirstName and LastName. To send FirstName and LastName in the request, we need to customize the register endpoint. How can we do that? Well there are 2 ways.

01. Create your own implementation of MapIdentityApi in IdentityApiEndpointRouteBuilderExtensions

Remember this?

app.MapControllers();

app.MapIdentityApi<User>(); <--- This

app.Run();

This extension method is in IdentityApiEndpointRouteBuilderExtensions class. You can see it here or Ctrl+Left Click on MapIdentityApiWe can use the same class, but provide our own implementation to the register (or any other) method. 

Download and keep this file in your project, I will keep it inside Entensions folder. Make sure to fix the namespace to match our project.


Find the MapIdentityApi method and rename it to MapTodoIdentityApi. Take a look at the commit to get a better understanding. Then in Program.cs instead of MapIdentityApi use MapTodoIdentityApi [Commit]


Done! now we can find the /register endpoint and alter it.

routeGroup.MapPost("/register", async Task<Results<Ok, ValidationProblem>>
    ([FromBody] RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) =>
{
    var userManager = sp.GetRequiredService<UserManager<TUser>>();

    if (!userManager.SupportsUserEmail)
    {
        throw new NotSupportedException($"{nameof(MapTodoIdentityApi)} requires a user store with email support.");
    }

    var userStore = sp.GetRequiredService<IUserStore<TUser>>();
    var emailStore = (IUserEmailStore<TUser>)userStore;
    var email = registration.Email;

    if (string.IsNullOrEmpty(email) || !_emailAddressAttribute.IsValid(email))
    {
        return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email)));
    }

    var user = new TUser();
    await userStore.SetUserNameAsync(user, email, CancellationToken.None);
    await emailStore.SetEmailAsync(user, email, CancellationToken.None);
    var result = await userManager.CreateAsync(user, registration.Password);

    if (!result.Succeeded)
    {
        return CreateValidationProblem(result);
    }

    await SendConfirmationEmailAsync(user, userManager, context, email);
    return TypedResults.Ok();
});

Now, let's add a DTO, RegisterDto with the all the new fields you want, in our case FirstName and LastName 

namespace DotnetIdentityDemo.Dtos
{
    public class RegisterDto
    {
        public required string FirstName { get; init; }
        public required string LastName { get; init; }
        public required string Email { get; init; }
        public required string Password { get; init; }
    }
}

Next, do the necessary changes in IdentityApiEndpointRouteBuilderExtensions class. 

routeGroup.MapPost("/register", async Task<Results<Ok, ValidationProblem>>
    ([FromBody] RegisterDto registration, HttpContext context, [FromServices] IServiceProvider sp) =>
{
    var userManager = sp.GetRequiredService<UserManager<User>>();

    if (!userManager.SupportsUserEmail)
    {
        throw new NotSupportedException($"{nameof(MapTodoIdentityApi)} requires a user store with email support.");
    }

    var userStore = sp.GetRequiredService<IUserStore<User>>();
    var emailStore = (IUserEmailStore<User>)userStore;
    var email = registration.Email;

    if (string.IsNullOrEmpty(email) || !_emailAddressAttribute.IsValid(email))
    {
        return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email)));
    }

    var user = new User{
        FirstName = registration.FirstName,
        LastName = registration.LastName
    };

    await userStore.SetUserNameAsync(user, email, CancellationToken.None);
    await emailStore.SetEmailAsync(user, email, CancellationToken.None);
    var result = await userManager.CreateAsync(user, registration.Password);

    if (!result.Succeeded)
    {
        return CreateValidationProblem(result);
    }

    await SendConfirmationEmailAsync(user, userManager, context, email);
    return TypedResults.Ok();
});


Here, we have replaced RegisterRequest with RegisterDto from DotnetIdentityDemo.Dtos, then TUser with User from DotnetIdentityDemo.Models, then initialized the new user like this. Take look at this commit for all the changes we need to do.

var user = new User{
    FirstName = registration.FirstName,
    LastName = registration.LastName
};

Now we can start the application and register a user.


02. Create your own Controller for Authentication.

Now let's look at another way to do this. Create AuthController, and add required endpoints, for this project I will only create login, refresh and registration, but you can implement your own logic for pretty much everythin using this method as well.

But first let me create the TokenService, for this we need Nuget package, Microsoft.AspNetCore.Authentication.JwtBearer

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Then create ITokenService and TokenSerive as follows.

using System.Security.Claims;
using DotnetIdentityDemo.Models;

namespace DotnetIdentityDemo.Services;

public interface ITokenService
{
    string GenerateAccessToken(User user);
    string GenerateRefreshToken(User user);
    ClaimsPrincipal? ValidateRefreshToken(string refreshToken);
}

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using DotnetIdentityDemo.Models;
using Microsoft.IdentityModel.Tokens;

namespace DotnetIdentityDemo.Services
{
    public class TokenService : ITokenService
    {
        private readonly IConfiguration _configuration;

        public TokenService(IConfiguration configuration)
        {
            _configuration = configuration;
        }

        public string GenerateAccessToken(User user)
        {
            var claims = new[]
            {
            new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new Claim(ClaimTypes.Name, user.UserName),
            new Claim(ClaimTypes.Email, user.Email)
        };

            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]));
            var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

            var token = new JwtSecurityToken(
                issuer: _configuration["Jwt:Issuer"],
                audience: _configuration["Jwt:Audience"],
                claims: claims,
                expires: DateTime.Now.AddMinutes(30),  // Short expiry for access token
                signingCredentials: creds);

            return new JwtSecurityTokenHandler().WriteToken(token);
        }

        public string GenerateRefreshToken(User user)
        {
            var claims = new[]
            {
            new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new Claim(ClaimTypes.Name, user.UserName),
            new Claim(ClaimTypes.Email, user.Email),
        };

            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]));
            var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

            var refreshToken = new JwtSecurityToken(
                issuer: _configuration["Jwt:Issuer"],
                audience: _configuration["Jwt:Audience"],
                claims: claims,
                expires: DateTime.Now.AddDays(7),  // Longer expiry for refresh token
                signingCredentials: creds);

            return new JwtSecurityTokenHandler().WriteToken(refreshToken);
        }

        public ClaimsPrincipal? ValidateRefreshToken(string refreshToken)
        {
            var tokenHandler = new JwtSecurityTokenHandler();
            try
            {
                var validationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    ValidateLifetime = true, // Allow token expiration check
                    ValidateIssuerSigningKey = true,
                    ValidIssuer = _configuration["Jwt:Issuer"],
                    ValidAudience = _configuration["Jwt:Audience"],
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]))
                };

                SecurityToken validatedToken;
                var principal = tokenHandler.ValidateToken(refreshToken, validationParameters, out validatedToken);

                // Ensure the token has not expired
                var jwtToken = validatedToken as JwtSecurityToken;
                if (jwtToken == null || !jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
                {
                    throw new SecurityTokenException("Invalid token");
                }

                return principal;
            }
            catch (Exception ex)
            {
                // Log or handle token validation failure as needed
                return null;
            }
        }
    }
}


Add JWT related configuration section to the appsettings.json

  "Jwt": {
    "Key": "c3VwZXJzZWNyZXRrZ687hjbjLkhv9iZW1vcmV0aGFuMjY1Yml0cw==",
    "Issuer": "https://localhost:7277",
    "Audience": "https://localhost:7277"
  },

Register the TokenService in Program.cs


builder.Services.AddScoped<ITokenService, TokenService>();


Alter AddAuthentication middleware to configure JWT.

builder.Services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
        };
    });

Create the Token model and LoginDto

namespace DotnetIdentityDemo.Dtos
{
    public class LoginDto
    {
        public string Email { get; set; }
        public string Password { get; set; }
    }
}

namespace DotnetIdentityDemo.Models;

public class Token
{
    public string AccessToken { get; set; }
    public string RefreshToken { get; set; }
}

And finally add the logic to the AuthController using the service methods.

using DotnetIdentityDemo.Dtos;
using DotnetIdentityDemo.Models;
using DotnetIdentityDemo.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

namespace DotnetIdentityDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AuthController : ControllerBase
    {
        private readonly UserManager<User> _userManager;
        private readonly SignInManager<User> _signInManager;
        private readonly ITokenService _tokenService;

        public AuthController(
            UserManager<User> userManager,
            SignInManager<User> signInManager,
            ITokenService tokenService)
        {
            _userManager = userManager;
            _signInManager = signInManager;
            _tokenService = tokenService;
        }

        [HttpPost("register")]
        public async Task<IActionResult> Register(RegisterDto model)
        {
            var existingUser = await _userManager.FindByEmailAsync(model.Email);
            if (existingUser is not null)
            {
                return BadRequest("User already registered.");

            }

            var user = new User
            {
                UserName = model.Email,
                Email = model.Email,
                FirstName = model.FirstName,
                LastName = model.LastName
            };

            var result = await _userManager.CreateAsync(user, model.Password);

            if (result.Succeeded)
            {
                return Ok(new { message = "User registered successfully." });
            }

            return BadRequest(result.Errors);
        }

        [HttpPost("login")]
        public async Task<IActionResult> Login(LoginDto model)
        {
            var user = await _userManager.FindByEmailAsync(model.Email);
            if (user == null)
            {
                return Unauthorized();
            }

            var result = await _signInManager.CheckPasswordSignInAsync(user, model.Password, false);
            if (!result.Succeeded)
            {
                return Unauthorized();
            }

            var accessToken = _tokenService.GenerateAccessToken(user);
            var refreshToken = _tokenService.GenerateRefreshToken(user);

            return Ok(new Token
            {
                AccessToken = accessToken,
                RefreshToken = refreshToken
            });
        }

        [HttpPost("refresh")]
        public IActionResult Refresh(string refreshToken)
        {
            var principal = _tokenService.ValidateRefreshToken(refreshToken);

            if (principal == null)
            {
                return Unauthorized(new { message = "Invalid refresh token." });
            }

            // Extract the user identity from the refresh token claims
            var userName = principal.Identity.Name;
            var user = _userManager.Users.SingleOrDefault(u => u.UserName == userName);

            if (user == null)
            {
                return Unauthorized(new { message = "User not found." });
            }

            // Generate new tokens
            var newAccessToken = _tokenService.GenerateAccessToken(user);
            var newRefreshToken = _tokenService.GenerateRefreshToken(user);  // Optionally rotate refresh tokens

            return Ok(new Token
            {
                AccessToken = newAccessToken,
                RefreshToken = newRefreshToken
            });
        }
    }
}


Take a look at this commit to better understand what we did.

Authorization 

Now I'm going to add 2 methods to MigrationExtensions to add 2 Roles (Administrator, RegisteredUser) and create the Admin user, then assign Admin to "Administrator" role.

public static async Task CreateRoles(IServiceProvider serviceProvider)
    {
        var roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();

        string[] roleNames = { "Administrator", "RegisteredUser" };
        foreach (var roleName in roleNames)
        {
            var roleExist = await roleManager.RoleExistsAsync(roleName);
            if (!roleExist)
            {
                await roleManager.CreateAsync(new IdentityRole(roleName));
            }
        }
    }

    public static async Task SeedAdminUser(IServiceProvider serviceProvider)
    {
        var userManager = serviceProvider.GetRequiredService<UserManager<User>>();

        var adminEmail = "admin@example.com";
        var adminPassword = "Admin@1234";  // Use a strong password and consider reading from configuration

        var adminUser = await userManager.FindByEmailAsync(adminEmail);
        if (adminUser == null)
        {
            adminUser = new User
            {
                UserName = adminEmail,
                Email = adminEmail,
                FirstName = "Admin",
                LastName = "User"
            };
            await userManager.CreateAsync(adminUser, adminPassword);
        }

        if (!await userManager.IsInRoleAsync(adminUser, "Administrator"))
        {
            await userManager.AddToRoleAsync(adminUser, "Administrator");
        }
    }

Alter AddIdentityCore middleware to include user roles. 

builder.Services.AddIdentityCore<User>()
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<IdentityContext>()
    .AddApiEndpoints();

Call these from Program.cs

using (var scope = app.Services.CreateScope())
{
    var serviceProvider = scope.ServiceProvider;
    MigrationExtensions.CreateRoles(serviceProvider).Wait();
    MigrationExtensions.SeedAdminUser(serviceProvider).Wait();
}

Also add all new users which get registered from our new register endpoint to the "RegisteredUser" role, 


    if (result.Succeeded)
    {
        // Assign "RegisteredUser" role to the newly registered user
        await _userManager.AddToRoleAsync(user, "RegisteredUser");

        return Ok(new { message = "User registered successfully." });
    }


Checkout this commit to better understand what we did.

Now, what remains to be done is add these roles to the token using claims, then authorize the controller endpoints. Let's change the TokenService a little bit to add the Roles of the user to the token response.

    var roles = _userManager.GetRolesAsync(user).Result;

    // Add user roles as claims
    foreach (var role in roles)
    {
        claims.Add(new Claim(ClaimTypes.Role, role));
    }

To do this you want to inject UserManager to the TokenService.

    private readonly IConfiguration _configuration;
    private readonly UserManager<User> _userManager;

    public TokenService(IConfiguration configuration, UserManager<User> userManager)
    {
        _configuration = configuration;
        _userManager = userManager;
    }

Check this commit.

If you want to secure your API endpoints using role-based attributes directly, you can use the [Authorize(Roles = "...")] attribute, which is simpler and checks the user's role directly in the attribute declaration. 

Before that I want to install Swashbuckle.AspNetCore.Filters package to make sure that we have support to send bearer tokens over swagger (Open API), then I am configuring couple of options in AddSwaggerGen middleware. [commit]

builder.Services.AddSwaggerGen(options =>
{
    options.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Name = "Authorization",
        Type = SecuritySchemeType.ApiKey
    });
    options.OperationFilter<SecurityRequirementsOperationFilter>();
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Version = "v1",
        Title = "ToDo API",
        Description = "An ASP.NET Core Web API for managing ToDo items"
    });
});

Then in the TodoController, add the Authorize attributes.

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DotnetIdentityDemo.Data;
using DotnetIdentityDemo.Models;
using DotnetIdentityDemo.Dtos;
using Microsoft.AspNetCore.Authorization;

namespace DotnetIdentityDemo.Controllers
{
    [Authorize] // Ensure all endpoints require authentication
    [Route("api/[controller]")]
    [ApiController]
    public class TodosController : ControllerBase
    {
        private readonly DatabaseContext _context;

        public TodosController(DatabaseContext context)
        {
            _context = context;
        }

        // GET: api/todos
        [HttpGet]
        [Authorize(Roles = "RegisteredUser,Administrator")]
        public async Task<ActionResult<IEnumerable<Todo>>> GetTodos()
        {
            return await _context.Todos.ToListAsync();
        }

        // GET: api/todos/5
        [HttpGet("{id}")]
        [Authorize(Roles = "RegisteredUser,Administrator")]
        public async Task<ActionResult<Todo>> GetTodoById(int id)
        {
            var todo = await _context.Todos.FindAsync(id);

            if (todo == null)
            {
                return NotFound();
            }

            return todo;
        }

        // POST: api/todos
        [HttpPost]
        [Authorize(Roles = "RegisteredUser,Administrator")]
        public async Task<ActionResult<Todo>> CreateTodo(TodoCreateDto todoDto)
        {
            // Map the DTO to the Todo model
            var todo = new Todo
            {
                Name = todoDto.Name,
                IsComplete = todoDto.IsComplete
            };

            _context.Todos.Add(todo);
            await _context.SaveChangesAsync();

            return CreatedAtAction(nameof(GetTodoById), new { id = todo.Id }, todo);
        }

        // PUT: api/todos/5
        [HttpPut("{id}")]
        [Authorize(Roles = "Administrator")]
        public async Task<IActionResult> UpdateTodoName(int id, string name)
        {
            var todo = await _context.Todos.FindAsync(id);

            if (todo == null)
            {
                return NotFound();
            }

            todo.Name = name;
            await _context.SaveChangesAsync();

            return NoContent();
        }

        // PUT: api/todos/5/complete
        [HttpPut("{id}/complete")]
        [Authorize(Roles = "Administrator")]
        public async Task<IActionResult> MarkTodoAsComplete(int id)
        {
            var todo = await _context.Todos.FindAsync(id);

            if (todo == null)
            {
                return NotFound();
            }

            todo.IsComplete = true;
            await _context.SaveChangesAsync();

            return NoContent();
        }

        // DELETE: api/todos/5
        [HttpDelete("{id}")]
        [Authorize(Roles = "Administrator")]
        public async Task<IActionResult> DeleteTodoById(int id)
        {
            var todo = await _context.Todos.FindAsync(id);

            if (todo == null)
            {
                return NotFound();
            }

            _context.Todos.Remove(todo);
            await _context.SaveChangesAsync();

            return NoContent();
        }
    }
}


Here, I have made sure only administrators can mark an item as complete, update name and delete ToDo [commit].

Let's try an unregistered user.


Now lets try to login using a registered user, and see.
First authorize our requests with swagger.


Now create a new ToDo item. It is a success. But when we try to delete an item, we get 403 forbidden response.


If we login using the Admin user, we should be able to delete as well.


Well that's it. I know that Role names can be moved to a constants file and refactored. I decided not to that keep it a bit more newbie friendly. Checkout these resources as well.