Modern enterprise applications frequently need to support dual client interfaces: server-rendered web views (MVC/Razor Pages) for administrative dashboards and RESTful APIs for mobile applications.

This article walks through designing and implementing a production-grade authentication pipeline featuring JWT Bearer tokens, Refresh Token Rotation, and custom user lookup via ErpNumber, all structured under Clean Architecture and the Repository Pattern within a single ASP.NET Core application.

1. Architecture Overview

To maintain strict separation of concerns, the solution is partitioned into distinct layers:

  • Domain Layer (.Domain): Houses enterprise entities (ApplicationUser, RefreshToken) and repository interfaces.

  • Application Layer (.Application): Contains business logic (AuthService), DTOs, and service contracts (ITokenService, IAuthService).

  • Infrastructure Layer (.Infrastructure): Implements database persistence (AppDbContext), repository patterns (RefreshTokenRepository), and security utilities (TokenService).

  • Presentation Layer (.Presentation): Manages the dual authentication pipeline (Cookies + JWT Bearer), MVC controllers, and mobile API endpoints (AuthApiController).

2. Domain Layer: Custom Identity & Refresh Token Entity

We extend ASP.NET Core Identity's IdentityUser to support custom attributes like ErpNumber, FirstName, and LastName. We also introduce a RefreshToken entity to track tokens, expiration windows, and usage state.

The Entities

C#

C#
using Microsoft.AspNetCore.Identity;

namespace NTL.HR.AMS.Domain.Entities
{
    public class ApplicationUser : IdentityUser
    {
        public string FirstName { get; set; } = string.Empty;
        public string LastName { get; set; } = string.Empty;
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
        public string? ReportingOfficerId { get; set; }
        public string? ErpNumber { get; set; }
        public DateTime? LeaveInboxSeenUtc { get; set; }
    }

    public class RefreshToken
    {
        public int Id { get; set; }
        public string UserId { get; set; } = string.Empty;
        public string Token { get; set; } = string.Empty;
        public DateTime ExpiresAt { get; set; }
        public bool IsRevoked { get; set; } = false;
        public bool IsUsed { get; set; } = false;
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    }
}

3. Infrastructure Layer: Repository & Persistence

Following the Repository Pattern, data access operations are fully abstracted away from application services.

Repository Interface & Implementation

C#

C#
namespace NTL.HR.AMS.Domain.Interfaces
{
    public interface IRefreshTokenRepository
    {
        Task<RefreshToken?> GetByTokenAsync(string token);
        Task AddAsync(RefreshToken refreshToken);
        Task UpdateAsync(RefreshToken refreshToken);
    }
}

C#

C#
using Microsoft.EntityFrameworkCore;
using NTL.HR.AMS.Domain.Entities;
using NTL.HR.AMS.Domain.Interfaces;
using NTL.HR.AMS.Infrastructure.Persistence;

namespace NTL.HR.AMS.Infrastructure.Repositories
{
    public class RefreshTokenRepository : IRefreshTokenRepository
    {
        private readonly AppDbContext _context;

        public RefreshTokenRepository(AppDbContext context) => _context = context;

        public async Task<RefreshToken?> GetByTokenAsync(string token) =>
            await _context.RefreshTokens.FirstOrDefaultAsync(rt => rt.Token == token);

        public async Task AddAsync(RefreshToken refreshToken)
        {
            await _context.RefreshTokens.AddAsync(refreshToken);
            await _context.SaveChangesAsync();
        }

        public async Task UpdateAsync(RefreshToken refreshToken)
        {
            _context.RefreshTokens.Update(refreshToken);
            await _context.SaveChangesAsync();
        }
    }
}

4. Application Layer: Services & Token Generation

The TokenService handles cryptographic JWT generation and cryptographically secure random strings for refresh tokens. The AuthService handles authentication workflows: custom user lookup by ErpNumber, password verification, token issuance, token rotation, and explicit logout.

C#

C#
namespace NTL.HR.AMS.Application.Services
{
    public class AuthService : IAuthService
    {
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly ITokenService _tokenService;
        private readonly IRefreshTokenRepository _refreshTokenRepository;

        public AuthService(
            UserManager<ApplicationUser> userManager,
            ITokenService tokenService,
            IRefreshTokenRepository refreshTokenRepository)
        {
            _userManager = userManager;
            _tokenService = tokenService;
            _refreshTokenRepository = refreshTokenRepository;
        }

        public async Task<(bool Succeeded, AuthResponseDto? Response, string? Error)> LoginAsync(LoginRequestDto request)
        {
            var user = await _userManager.Users.FirstOrDefaultAsync(u => u.ErpNumber == request.ErpNumber);
            if (user == null || !await _userManager.CheckPasswordAsync(user, request.Password))
            {
                return (false, null, "Invalid ERP Number or Password.");
            }

            var jwtToken = await _tokenService.GenerateJwtTokenAsync(user);
            var refreshTokenString = _tokenService.GenerateRefreshToken();

            var refreshToken = new RefreshToken
            {
                UserId = user.Id,
                Token = refreshTokenString,
                ExpiresAt = DateTime.UtcNow.AddDays(7),
                CreatedAt = DateTime.UtcNow
            };

            await _refreshTokenRepository.AddAsync(refreshToken);

            var response = new AuthResponseDto(
                Token: jwtToken,
                RefreshToken: refreshTokenString,
                ErpNumber: user.ErpNumber,
                Email: user.Email,
                FullName: $"{user.FirstName} {user.LastName}".Trim(),
                ExpiresAt: DateTime.UtcNow.AddHours(8)
            );

            return (true, response, null);
        }

        public async Task<(bool Succeeded, string? Error)> LogoutAsync(LogoutRequestDto request)
        {
            var existingToken = await _refreshTokenRepository.GetByTokenAsync(request.RefreshToken);
            if (existingToken == null || existingToken.IsRevoked)
            {
                return (false, "Invalid or already revoked refresh token.");
            }

            existingToken.IsRevoked = true;
            await _refreshTokenRepository.UpdateAsync(existingToken);

            return (true, null);
        }
    }
}

5. Presentation Layer: Dual Authentication & API Endpoints

In Program.cs, we register both Cookie Authentication (for the MVC web dashboard) and JWT Bearer Authentication (for mobile apps and Swagger testing).

C#

JavaScript
// Dual Authentication Setup
builder.Services.AddAuthentication()
    .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtSettings["Issuer"],
            ValidAudience = jwtSettings["Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey))
        };
    });

The Controller (AuthApiController)

C#

C#
namespace NTL.HR.AMS.Presentation.Controllers
{
    [ApiController]
    [Route("api/auth")]
    public class AuthApiController : ControllerBase
    {
        private readonly IAuthService _authService;

        public AuthApiController(IAuthService authService) => _authService = authService;

        [HttpPost("login")]
        [AllowAnonymous]
        public async Task<IActionResult> Login([FromBody] LoginRequestDto request)
        {
            var (succeeded, response, error) = await _authService.LoginAsync(request);
            if (!succeeded) return Unauthorized(new { message = error });
            return Ok(response);
        }

        [HttpPost("refresh-token")]
        [AllowAnonymous]
        public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequestDto request)
        {
            var (succeeded, response, error) = await _authService.RefreshTokenAsync(request);
            if (!succeeded) return Unauthorized(new { message = error });
            return Ok(response);
        }

        [HttpPost("logout")]
        [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
        public async Task<IActionResult> Logout([FromBody] LogoutRequestDto request)
        {
            var (succeeded, error) = await _authService.LogoutAsync(request);
            if (!succeeded) return BadRequest(new { message = error });
            return Ok(new { message = "Logged out successfully." });
        }
    }
}

Conclusion

By combining ASP.NET Core Identity with custom properties, a clean repository-driven token lifecycle, and dual-scheme middleware configuration, you achieve a scalable architecture capable of powering an MVC back-office dashboard and secure mobile app APIs concurrently.