DEV Community

Cover image for JWT Authentication in ASP.NET Core Web API: Access and Refresh Tokens
Mirnes
Mirnes

Posted on Originally published at optimalcoder.net

JWT Authentication in ASP.NET Core Web API: Access and Refresh Tokens

Most APIs need a way to identify their clients and protect resources from unauthorized access. JWT authentication provides a simple and widely used approach for handling this in stateless Web APIs. This article explains how JWT authentication works in ASP.NET Core and demonstrates its implementation step by step. For simplicity, we will assume that a user already exists in the database. User registration is outside the scope of this article.

Design

Then, let us introduce some design around our authentication component and show how these different classes are working together. Requests are being handled in AuthenticationController, then dataflow is being handed over to AuthenticationServicewhich contains the main logic. AuthenticationServicehas the communication with the database via UserDbContext. With this, we have segregated our responsibilities across different classes based on their abstraction layer. More detailed explanation of the different data flows and components included is shown in two diagrams below.

Login

login

Authenticated Request

authenticated request

Authentication Flow

From the functionality perspective, token authentication flow is as follows:

  • User types in username and password and if credentials are valid, two tokens are returned to the client: access and refresh token.

  • Access token is used for accessing other endpoints where authentication is required, whereas refresh token is used once the access token expires and needs refresh.

  • When the client later requests a token refresh, the supplied token is hashed and compared with the stored value. Each successful refresh also generates a new refresh token and replaces the stored hash. This means a refresh token is effectively single-use.

  • In the database, we are storing just refresh token hash, so raw tokens are available only to the real user.

Controller

Now, let us move on with AuthenticationControllerwhich acts like an interface to the outside world. For the scope of this article, we will implement login, refresh token and logout mechanism.

 [Route("api/[controller]")]
 [ApiController]
 public class AuthenticationController : ControllerBase
 {
    private readonly IAuthenticationService _service;

    public AuthenticationController(IAuthenticationService service)
    {
        _service = service;
    }

    [AllowAnonymous]
    [HttpPost("Login")]
    public IActionResult Login([FromBody] UserLoginModel user)
    {
        var tokenModel = _service.Login(user);
        return Ok(new { TokenModel = tokenModel });
    }

    [AllowAnonymous]
    [HttpPost("RefreshToken")]
    public IActionResult RefreshToken([FromBody] TokenRequest request)
    {
        var tokenResponse = _service.RefreshToken(request);

        return Ok(tokenResponse);
    }

    [Authorize]
    [HttpPost("Logout")]
    public IActionResult Logout([FromBody] TokenRequest tokenModel)
    {
        var success = _service.Logout(User.Identity?.Name!, tokenModel);

        return Ok(success);
    }
 }
Enter fullscreen mode Exit fullscreen mode

Service

Furthermore, the logic around authentication is going inside the AuthenticationService. Here, we are injecting JWT configuration from appsettings.json. We didn’t put Key, which you will need to put on your own if you want to try the code. Key is the secret used to sign the access token and it should not be committed to source control. In the prod environment it should be managed in some secret-management mechanism.

    "Jwt": {
    "Key": "",
    "Issuer": "optimalcoder.net",
    "Audiences": [
        "https://localhost:44322/"
    ],
    "TokenValidityInMinutes": 1,
    "RefreshTokenValidityInDays": 7
}

Enter fullscreen mode Exit fullscreen mode
public interface IAuthenticationService
{
    TokenResponse Login(UserLoginModel user);
    TokenResponse RefreshToken(TokenRequest request);
    bool Logout(string username, TokenRequest request);
}

public class AuthenticationService : IAuthenticationService
{
    private readonly UserDbContext _userDbContext;
    private readonly IPasswordService _passwordService;
    private readonly Jwt _jwtConfig;

    public AuthenticationService(UserDbContext userDbContext, IPasswordService passwordService, IOptions<AppSettings> appSettings)
    {
        _userDbContext = userDbContext;
        _passwordService = passwordService;
        _jwtConfig = appSettings.Value.Jwt;
    }


    public TokenResponse Login(UserLoginModel model)
    {
        var user = _userDbContext.User.FirstOrDefault(x => x.UserName == model.UserName);

        if (user == null)
        {
            throw new UnauthorizedException("INVALID_CREDENTIALS", "Invalid username or password.");
        }

        var passwordValid = _passwordService.Verify(user, model.Password, user.PasswordHash);

        if (!passwordValid)
        {
            throw new UnauthorizedException("INVALID_CREDENTIALS", "Invalid username or password.");
        }

        var authToken = GenerateAuthToken(user);
        var refreshToken = GenerateRefreshToken();

        user.RefreshTokenHash = HashRefreshToken(refreshToken);
        user.RefreshTokenExpiryTime =
            DateTime.UtcNow.AddDays(
                _jwtConfig.RefreshTokenValidityInDays);

        _userDbContext.SaveChanges();

        return new TokenResponse
        {
            AuthToken = authToken,
            RefreshToken = refreshToken
        };

    }

    public TokenResponse RefreshToken(TokenRequest request)
    {
        var refreshTokenHash = HashRefreshToken(request.RefreshToken);

        var user = _userDbContext.User.FirstOrDefault(x => x.RefreshTokenHash == refreshTokenHash);

        if (user == null || user.RefreshTokenExpiryTime <= DateTime.UtcNow)
        {
            throw new UnauthorizedException("REFRESH_TOKEN_FAILED", "Refresh token failed.");
        }

        var newAuthToken = GenerateAuthToken(user);
        var newRefreshToken = GenerateRefreshToken();

        user.RefreshTokenHash = HashRefreshToken(newRefreshToken);

        user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(_jwtConfig.RefreshTokenValidityInDays);

        _userDbContext.SaveChanges();

        return new TokenResponse
        {
            AuthToken = newAuthToken,
            RefreshToken = newRefreshToken
        };
    }

    public bool Logout(string username, TokenRequest request)
    {
        var refreshTokenHash = HashRefreshToken(request.RefreshToken);

        var user = _userDbContext.User.FirstOrDefault(x => x.UserName == username &&
            x.RefreshTokenHash == refreshTokenHash);

        if (user == null)
        {
            throw new UnauthorizedException("LOGOUT_FAILED", "Invalid refresh token.");
        }

        user.RefreshTokenHash = null;
        user.RefreshTokenExpiryTime = null;

        _userDbContext.SaveChanges();

        return true;
    }

    private string GenerateAuthToken(User user)
    {
        var claims = CreateClaims(user);

        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtConfig.Key));

        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);

        foreach (var audience in _jwtConfig.Audiences)
        {
            claims.Add(new Claim(JwtRegisteredClaimNames.Aud, audience));
        }

        var tokenDescriptor = new JwtSecurityToken(
            issuer: _jwtConfig.Issuer,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(_jwtConfig.TokenValidityInMinutes),
            signingCredentials: credentials);

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

    private static string GenerateRefreshToken()
    {
        var randomNumber = new byte[64];

        using var rng = RandomNumberGenerator.Create();

        rng.GetBytes(randomNumber);

        return Convert.ToBase64String(randomNumber);
    }

    private static string HashRefreshToken(string refreshToken)
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken));

        return Convert.ToBase64String(hash);
    }

    private List<Claim> CreateClaims(User user)
    {
        var claims = new List<Claim>();

        claims.Add(new Claim(ClaimTypes.Name, user.UserName));

        return claims;
    }
}

Enter fullscreen mode Exit fullscreen mode

Because access tokens are stateless, logging out just removes the stored refresh token, preventing the client from obtaining a new access token after the current access token expires. Next valid operation can only be new login. Other logic like token generation can be easily understood from the code itself, and therefore no need for additional explanations here.

Database

UserDbContextis simply the persistence layer used by this example. The same authentication service can work with a different persistence implementation, such as the repository approach described in DB repositories and services.

public class UserDbContext : DbContext
{
    public UserDbContext(): base()
    {

    }
    public UserDbContext(DbContextOptions<UserDbContext> options)
   : base(options)
    { 
    }

    public virtual DbSet<User> User { get; set; }

}
Enter fullscreen mode Exit fullscreen mode

Middleware

Validation of the username and password, together with other exceptions inside the AuthenticationServiceare done trough UnauthorizedException, so it can be easily preprocessed at the ExceptionMiddlewareand returned as an unified response inside the ProblemDetails to the client.

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext httpContext, IOptimalLogger logger)
    {
        try
        {
            await _next(httpContext);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(httpContext, logger, ex);
        }
    }

    private async Task HandleExceptionAsync(HttpContext context, IOptimalLogger logger, Exception ex)
    {
        ProblemDetails response;

        switch (ex)
        {
            ...
            case UnauthorizedException unauthorizedEx:
                response = CreateErrorResponse(StatusCodes.Status401Unauthorized, unauthorizedEx.Code, ex.Message);
                break;
            ...

        }

        context.Response.StatusCode = response.Status;
        context.Response.ContentType = "application/json";

        await context.Response.WriteAsJsonAsync(response);
    }


    private static ProblemDetails CreateErrorResponse(
    int status,
    string code,
    string message)
    {
        return new ProblemDetails
        {
            Status = status,
            Detail = message,
            Title = code
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Dependency Injection

In the end, let us take a look at the code snippets which are needed to wire everything up. We are defining which service will be injected inside the DI container and how Authentication is handled by the framework. The remaining configuration is omitted for clarity.

        ...
        services.AddScoped<IAuthenticationService, AuthenticationService>();
        services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
        services.AddScoped<IPasswordService, PasswordService>();
        ...
        ...
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(jwtOptions =>
                {
                    jwtOptions.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = true,
                        ValidateAudience = true,
                        ValidateLifetime = true,
                        ValidateIssuerSigningKey = true,
                        ValidAudiences = jwt.Audiences,
                        ValidIssuer = jwt.Issuer,
                        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key))
                    };
                });

        ...

        ...
        services.AddAuthorization();
        ...
        app.UseAuthentication();
        app.UseAuthorization();
        ...
Enter fullscreen mode Exit fullscreen mode

Want to check the complete implementation?
The article focuses on the parts of the implementation that are useful for understanding. The entire source code is available on my github repository.
Explore more →

Top comments (0)