DEV Community

loading...

Refresh JWT with Refresh Tokens in Asp Net Core 5 Rest API Step by Step

moe23 profile image Mohamad Lawand Updated on ・7 min read

Hello friends, In this article I will be showing you today How to add refresh tokens to our JWT authentication to our Asp.Net Core REST API

Some of the topics we will cover are refresh tokens and New endpoints functionalities and utilising JWTs ("Json Web Tokens") and Bearer authentication.

You can also watch the full step by step video on YouTube:

As well download the source code:
https://github.com/mohamadlawand087/v8-refreshtokenswithJWT

This is Part 3 of API dev series you can check the different parts by following the links:

Part 1:https://dev.to/moe23/asp-net-core-5-rest-api-step-by-step-2mb6
Part 2: https://dev.to/moe23/asp-net-core-5-rest-api-authentication-with-jwt-step-by-step-140d

Alt Text

This is part 3 of our Rest API journey, and we will be basing our current work on our previous Todo REST API application that we have created in our last article, https://dev.to/moe23/asp-net-core-5-rest-api-authentication-with-jwt-step-by-step-140d. You can follow along by either going through the article and building the application with me as we go or you can get the source code from github.

Before we start implementing the Refresh Token functionality, let us examine how the refresh token logic will work.

By nature JWT tokens have an expiry time, the shorter the time the safer it is. there is 2 options to get new tokens after the JWT token has expired

  • Ask the user to login again, this is not a good user experience
  • Use refresh tokens to automatically re-authenticate the user and generate new JWT tokens.

So what is a refresh token, a refresh token can be anything from strings to Guids to any combination as long as its unique

Why is it important to have a short lived JWT token, if someone is stole our JWT token and started doing requests on the server, that token will only last for an amount of time before it expires and become useless. The only way to get a new token is using the refresh tokens or loging in.

Another main point is what happens to all of the tokens that were generated based on an user credentials if the user changes their password. we don't want to invalidate all of the sessions. We can just update the refresh tokens so a new JWT token based on the new credentials will be generated.

As well a good way to implement automatic refresh tokens is before every request the client makes we need to check the expiry of the token if its expired we request a new one else we use the token we have to perform the request.

So in out application instead of just generating just a JWT token with every authorisation we will add a refresh token as well.

So lets get started, we will first start by updating our startup class, by making TokenValidationParameters available across the application by adding them to our Dependency Injection Container

var key = Encoding.ASCII.GetBytes(Configuration["JwtConfig:Secret"]);

var tokenValidationParameters = new TokenValidationParameters {
    ValidateIssuerSigningKey = true,
    IssuerSigningKey = new SymmetricSecurityKey(key),
    ValidateIssuer = false,
    ValidateAudience = false,
    ValidateLifetime = true,
    RequireExpirationTime = false
};

services.AddSingleton(tokenValidationParameters);

services.AddAuthentication(options => {
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(jwt => {
    jwt.SaveToken = true;
    jwt.TokenValidationParameters = tokenValidationParameters;
});
Enter fullscreen mode Exit fullscreen mode

Once the JwtConfig class is updated now we need to update our GenerateJwtToken function in our AuthManagementController our TokenDescriptor Expire value from being fixed to the ExpiryTimeFrame, we need to make it shorter that we have specified

private string GenerateJwtToken(IdentityUser user)
{
    var jwtTokenHandler = new JwtSecurityTokenHandler();

    var key = Encoding.ASCII.GetBytes(_jwtConfig.Secret);

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new []
        {
            new Claim("Id", user.Id), 
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(JwtRegisteredClaimNames.Sub, user.Email),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        }),
        Expires = DateTime.UtcNow.AddSeconds(30),
        SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
    };

    var token = jwtTokenHandler.CreateToken(tokenDescriptor);
    var jwtToken = jwtTokenHandler.WriteToken(token);

    return jwtToken;
}
Enter fullscreen mode Exit fullscreen mode

The step will be to update our AuthResult in our configuration folder, we need to add a new property which will be catered for the refresh token

public class AuthResult
{
    public string Token { get; set; }
    public string RefreshToken { get; set; }
    public bool Success { get; set; }
    public List<string> Errors { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

We will add a new class called TokenRequest inside our Models/DTOs/Requests which will be responsible on accepting new request for the new endpoint that we will create later on to manage the refresh token

public class TokenRequest
{
    [Required]
    public string Token { get; set; }
    [Required]
    public string RefreshToken { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The next step is to create a new model called RefreshToken, in our Models folder.

public class RefreshToken
{
    public int Id { get; set; }
    public string UserId { get; set; } // Linked to the AspNet Identity User Id
    public string Token { get; set; }
    public string JwtId { get; set; } // Map the token with jwtId
    public bool IsUsed { get; set; } // if its used we dont want generate a new Jwt token with the same refresh token
    public bool IsRevoked { get; set; } // if it has been revoke for security reasons
    public DateTime AddedDate { get; set; }
    public DateTime ExpiryDate { get; set; } // Refresh token is long lived it could last for months.

    [ForeignKey(nameof(UserId))]
    public IdentityUser User {get;set;}
}
Enter fullscreen mode Exit fullscreen mode

Once the model is added we need to update our ApiDbContext

public virtual DbSet<RefreshToken> RefreshTokens {get;set;}
Enter fullscreen mode Exit fullscreen mode

Now lets create the migrations for our ApiDbContext so we can reflect the changes in your database

dotnet ef migrations add "Added refresh tokens table"
dotnet ef database update
Enter fullscreen mode Exit fullscreen mode

Our next step will be to create our new Endpoind "RefreshToken" in our AuthManagementController. The first thing we need to do is to inject the TokenValidationParameters

private readonly TokenValidationParameters _tokenValidationParameters;
private readonly ApiDbContext _apiDbContext;

public AuthManagementController(
    UserManager<IdentityUser> userManager,
    IOptionsMonitor<JwtConfig> optionsMonitor,
    TokenValidationParameters tokenValidationParameters,
    ApiDbContext apiDbContext)
{
    _userManager = userManager;
    _jwtConfig = optionsMonitor.CurrentValue;
    _tokenValidationParameters = tokenValidationParameters;
    _apiDbContext = apiDbContext;
}
Enter fullscreen mode Exit fullscreen mode

Once we inject the required parameters we need to update the GenerateToken function to include the refresh token

private async Task<AuthResult> GenerateJwtToken(IdentityUser user)
{
    var jwtTokenHandler = new JwtSecurityTokenHandler();

    var key = Encoding.ASCII.GetBytes(_jwtConfig.Secret);

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new []
        {
            new Claim("Id", user.Id), 
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(JwtRegisteredClaimNames.Sub, user.Email),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        }),
        Expires = DateTime.UtcNow.Add(_jwtConfig.ExpiryTimeFrame),
        SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
    };

    var token = jwtTokenHandler.CreateToken(tokenDescriptor);
    var jwtToken = jwtTokenHandler.WriteToken(token);

    var refreshToken = new RefreshToken(){
        JwtId = token.Id,
        IsUsed = false,
        UserId = user.Id,
        AddedDate = DateTime.UtcNow,
        ExpiryDate = DateTime.Now.AddYears(1),
        IsRevoked = false,
        Token = RandomString(25) + Guid.NewGuid()
    };

    await _apiDbContext.RefreshTokens.AddAsync(refreshToken);
    await _apiDbContext.SaveChangesAsync();

    return new AuthResult() {
        Token = jwtToken,
        Success = true,
        RefreshToken = refreshToken.Token
    };
}

public  string RandomString(int length)
{
    var random = new Random();
    var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    return new string(Enumerable.Repeat(chars, length)
    .Select(s => s[random.Next(s.Length)]).ToArray());
}
Enter fullscreen mode Exit fullscreen mode

Now lets update the return to both existing actions as we have changed the return type for GenerateJwtToken

For Login Action:

return Ok(await GenerateJwtToken(existingUser));
Enter fullscreen mode Exit fullscreen mode

For Register Action:

return Ok(await GenerateJwtToken(existingUser));
Enter fullscreen mode Exit fullscreen mode

Now we can start building our RefreshToken Action

[HttpPost]
[Route("RefreshToken")]
public async Task<IActionResult> RefreshToken([FromBody] TokenRequest tokenRequest)
{
    if(ModelState.IsValid)
    {
        var res = await VerifyToken(tokenRequest);

        if(res == null) {
                return BadRequest(new RegistrationResponse(){
                Errors = new List<string>() {
                    "Invalid tokens"
                },
                Success = false
            });
        }

        return Ok(res);
    }

    return BadRequest(new RegistrationResponse(){
            Errors = new List<string>() {
                "Invalid payload"
            },
            Success = false
    });
}
Enter fullscreen mode Exit fullscreen mode
private async Task<AuthResult> VerifyToken(TokenRequest tokenRequest)
{
    var jwtTokenHandler = new JwtSecurityTokenHandler();

    try
    {
        // This validation function will make sure that the token meets the validation parameters
        // and its an actual jwt token not just a random string
        var principal = jwtTokenHandler.ValidateToken(tokenRequest.Token, _tokenValidationParameters, out var validatedToken);

        // Now we need to check if the token has a valid security algorithm
        if(validatedToken is JwtSecurityToken jwtSecurityToken)
        {
            var result = jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase);

            if(result == false) {
                 return null;
            }
        }

                // Will get the time stamp in unix time
        var utcExpiryDate = long.Parse(principal.Claims.FirstOrDefaultAsync(x => x.Type == JwtRegisteredClaimNames.Exp).Value);

        // we convert the expiry date from seconds to the date
        var expDate = UnixTimeStampToDateTime(utcExpiryDate);

        if(expDate > DateTime.UtcNow)
        {
            return new AuthResult(){
                Errors = new List<string>() {"We cannot refresh this since the token has not expired"},
                Success = false
            };
        }

        // Check the token we got if its saved in the db
        var storedRefreshToken = await _apiDbContext.RefreshTokens.FirstOrDefaultAsync(x => x.Token == tokenRequest.RefreshToken); 

        if(storedRefreshToken == null)
        {
            return new AuthResult(){
                Errors = new List<string>() {"refresh token doesnt exist"},
                Success = false
            };
        }

        // Check the date of the saved token if it has expired
        if(DateTime.UtcNow > storedRefreshToken.ExpiryDate)
        {
            return new AuthResult(){
                Errors = new List<string>() {"token has expired, user needs to relogin"},
                Success = false
            };
        }

        // check if the refresh token has been used
        if(storedRefreshToken.IsUsed)
        {
            return new AuthResult(){
                Errors = new List<string>() {"token has been used"},
                Success = false
            };
        }

        // Check if the token is revoked
        if(storedRefreshToken.IsRevoked)
        {
            return new AuthResult(){
                Errors = new List<string>() {"token has been revoked"},
                Success = false
            };
        }

         // we are getting here the jwt token id
        var jti = principal.Claims.SingleOrDefault(x => x.Type == JwtRegisteredClaimNames.Jti).Value;

        // check the id that the recieved token has against the id saved in the db
        if(storedRefreshToken.JwtId != jti)
        {
           return new AuthResult(){
                Errors = new List<string>() {"the token doenst mateched the saved token"},
                Success = false
            };
        }

        storedRefreshToken.IsUsed = true;
        _apiDbContext.RefreshTokens.Update(storedRefreshToken);
        await _apiDbContext.SaveChangesAsync();

                var dbUser = await _userManager.FindByIdAsync(storedRefreshToken.UserId);
        return await GenerateJwtToken(dbUser);
    }
    catch(Exception ex)
    {
        return null;
    }
}

private DateTime UnixTimeStampToDateTime( double unixTimeStamp )
{
    // Unix timestamp is seconds past epoch
    System.DateTime dtDateTime = new DateTime(1970,1,1,0,0,0,0,System.DateTimeKind.Utc);
    dtDateTime = dtDateTime.AddSeconds( unixTimeStamp ).ToLocalTime();
    return dtDateTime;
}
Enter fullscreen mode Exit fullscreen mode

Finally we need to make sure everything still builds and run

dotnet build
dotnet run
Enter fullscreen mode Exit fullscreen mode

Once we make sure everything is as it should be we will test the app using postman, the testing scenarios will be as follow:

  • login in generating a JWT token with a refresh token ⇒ fail
  • directly try to refresh the token without waiting for it to expire ⇒ fail
  • waiting for the JWT token to expire and request a refresh token ⇒ Success
  • re-using the same refresh token ⇒ fail

Thank you for taking the time and reading the article

This is Part 3 of API dev series you can check the different parts by following the links:

Part 1:https://dev.to/moe23/asp-net-core-5-rest-api-step-by-step-2mb6
Part 2: https://dev.to/moe23/asp-net-core-5-rest-api-authentication-with-jwt-step-by-step-140d

Discussion (14)

pic
Editor guide
Collapse
grandsilence profile image
Grand Silence

Some fixes for the article:

  1. If you need lifetime of token less than 5 mins, add ClockSkew property in Startup.cs:

    var tokenValidationParameters = new TokenValidationParameters {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(key),
        ValidateIssuer = false,
        ValidateAudience = false,
        ValidateLifetime = true,
        RequireExpirationTime = false,
    
        // Allow to use seconds for expiration of token
        // Required only when token lifetime less than 5 minutes
        // THIS ONE
        ClockSkew = TimeSpan.Zero
    };
    
  2. Don't forget to use UTC instead of local time. You will need to fix method GenerateJwtToken:

      var refreshToken = new RefreshToken(){
            JwtId = token.Id,
            IsUsed = false,
            UserId = user.Id,
            AddedDate = DateTime.UtcNow,
            // INVALID DATE, USE UTC
            // ExpiryDate = DateTime.Now.AddYears(1),
    
            // Now it's correct
            ExpiryDate = DateTime.UtcNow.AddYears(1),      
            IsRevoked = false,
            Token = RandomString(25) + Guid.NewGuid()
        };
    
  3. Fix UnixTimeStampToDateTime() method for returning UTC format:

    private DateTime UnixTimeStampToDateTime( double unixTimeStamp )
    {
        // Unix timestamp is seconds past epoch
        System.DateTime dtDateTime = new DateTime(1970,1,1,0,0,0,0,System.DateTimeKind.Utc);
    
        // CHANGE HERE, from .ToLocalTime to .ToUniverstalTime
        dtDateTime = dtDateTime.AddSeconds( unixTimeStamp ).ToUniversalTime();
        return dtDateTime;
    }
    
Collapse
moe23 profile image
Mohamad Lawand Author

Thanks a lot Grand for these fixes, I am planning to add them this week.
If you want you can add a PR on the GitHub repo and I will merge them t

Collapse
rcpokorny profile image
Bob Pokorny, MBA

Hi Mohamad!

I have been following the currently 3-part series and continue to enjoy every video. I am currently experiencing a problem with the token not expiring and I think it is happening on Validation #3.

I'm finding that the expiryDate and DateTime.UtcNow values are too far apart to even expire. For example, at the time of running, my expiryDate value = '03/09/21 8:56:38 am' and my DateTime.UtcNow = '03/09/21 2:57:06 pm'.

I did end up using you exact code in GIT to make sure I'm getting the same results. Still no resolution. Then I started thinking, I am using zScaler to log into our network and wondering if that is having problems with date/time. However my expiryDate value is my local and accurate time.

Any ideas to why I cannot get my token to expire?
Thanks,
Bob

Collapse
moe23 profile image
Mohamad Lawand Author

Hi Bob,

Thank you for your feedback and comment, I will push an update to git repo to fix.

I apologise for the delay.

Collapse
shawld2 profile image
Lee Shaw

Hi Mohamad, love the article!
Should the accessToken expire after 30 seconds? And when it does how is this handled? Using Swagger the token doesn't seem to be 401ing when i'm accessing the api/todo.
I have downloaded v8.
Thanks in advance.
Lee

Collapse
moe23 profile image
Mohamad Lawand Author

Hi Lee, I think you can configure the token to for 5 min and then it can expire and use the refresh token to get a new one. There is a bug in the code in V8. I will be pushing a fix this week for it

Collapse
walvarez00 profile image
Walter Alvarez

Hi Mohamad, Im using v8-refreshtokenswithJWT and when I trY to RefreshToken I have always Token has expired please re-login. This behavior succeds in line:

            var tokenInVerification = jwtTokenHandler.ValidateToken(tokenRequest.Token, _tokenValidationParams, out var validatedToken);
Enter fullscreen mode Exit fullscreen mode

Then the software dont continue

Can you help me please. What can I do for fix it?

Thanks

Collapse
walvarez00 profile image
Walter Alvarez

The project works if I change ValidateLifetime to false

This is a good solution?

Thanks

Collapse
moe23 profile image
Mohamad Lawand Author • Edited

Hi Walter, there is a small bug related to regeneration of the refresh token. I will push a fix for this within this week. We should always keep it to true

Thread Thread
walvarez00 profile image
Walter Alvarez

Thanks Mohamad

Collapse
wangzhe66369 profile image
wangzhe66369

Hi Mohamad!
I have a question, why use RefreshToken, it seems that only using Token can also refresh the token.
Thanks in advance.
Zhe

Collapse
annguyen209 profile image
An T. NGUYEN

Refresh token is similar to a backup key to get back new token in case it is expired or lost. You are sending the token over the internet many times so it "maybe" stolen.
That why we keep token expiration time is shorter a lot to the refresh token.

Collapse
wangzhe66369 profile image
wangzhe66369

Thank you for your answer.
I understand that the purpose of JWT is to not store data on the server side. Now that the RefreshToken must be stored on the server side, does it violate the purpose of JWT? I feel that this approach is very similar to Session

Collapse
muhammadshahidkhanafridi profile image
Muhammad Shahid Khan Afridi

Hi Mohamad Lawand,
I did not find any Logout functionality here. can you please implement it or can you help via a comment for logout functionality in the same "AuthManagementController" controller?