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#
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#
namespace NTL.HR.AMS.Domain.Interfaces
{
public interface IRefreshTokenRepository
{
Task<RefreshToken?> GetByTokenAsync(string token);
Task AddAsync(RefreshToken refreshToken);
Task UpdateAsync(RefreshToken refreshToken);
}
}
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#
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#
// 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#
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.
Join the conversation! Your thoughts help the community grow.