JWT Authentication Flow with Refresh Tokens in ASP.NET Core Web API

At the start of this year, I put together a detailed guide on using JWT authentication with ASP.NET Core Web API and Angular. At 120+ comments, it is currently the busiest page on this tiny corner of the internet which is perhaps indicative of the challenges many developers face while hooking up authentication.

If I had to pick one important thing missing in that post, it would probably be refresh tokens and their subtle yet essential role within the JWT authentication and authorization workflow. I thought it would be worthwhile to update and restructure the Web API project in that post into a standalone JWT solution including refresh tokens - so grab a refill of your preferred beverage and let's get to it. ☕️🍺

Get notified on new posts Straight from me, no spam, no bullshit. Frequent, helpful, email-only content.

JWT Recap

Modern authentication and authorization protocols use tokens as a method of carrying just enough data to either authorize a user to execute an action or request data from a resource. In short, tokens are packets of information that allow some authorization process to be carried out. JWT tokens specifically provide a very convenient way to package up common properties about a user in the form of claims. The nice thing about claims is that they can be trusted and repeatedly validated because in most cases they are digitally signed using a private key with the HMAC algorithm. This signature ensures only a server possessing the key can decode and verify the contents of incoming tokens and grant or deny access to its resources.

Refresh Tokens

The diagram above is relatively straight-forward in illustrating how access tokens get issued by an authentication server and then exchanged in subsequent requests to access protected resources. But, if we look at it for long enough we should strike upon one crucial question: how long is the lifetime of an access token? Does it last for an hour or a day or a month?

This question is important because if some malicious party were to get a hold of a token, they could use it for its entire lifetime while posing as a genuine recipient. This scenario can occur because the server will always trust a JWT token with a valid signature.

At this point, the only way to invalidate the compromised token is by modifying the key used to sign it - but if we do that we invalidate every issued token for every user! Changing the key for this purpose is not an acceptable approach, and this exact problem is what refresh tokens are intended to solve.

Refresh tokens hold only the information required to obtain a new access token. They are mainly a one-time-use token to be exchanged for a new access token issued by the authentication server. The primary use case is trading in old, expired access tokens. In this scenario, a new JWT can be obtained by the client without re-authenticating, so there is no need to bug the user to enter their credentials every time their access token expires. Depending on the implementation and lifetime the token is valid for - minutes, hours, etc. this provides a seamless experience for the user while maintaining a higher degree of security. 🔒

Better yet, if a refresh token becomes compromised, it can be revoked or blacklisted so when any client app attempts to exchange it for a new access token the request will be denied forcing the user to re-enter their credentials and identify themselves appropriately with the authentication server.

Token Lifetime

The length of time your access and refresh tokens are valid for will largely depend on your unique application and security requirements. Generally, access tokens are said to be short-lived meaning they can expire anywhere from a few minutes to hours after being issued while refresh tokens are long-lived with a longer lifetime and are securely stored to protect them from potential attackers. 😈

We've covered off the theory on the role refresh tokens play in a JWT authentication flow. Now, let's take a look at an approach to implement them using ASP.NET Core Web API, Identity and Entity Framework Core.

Registering Users

The primary subject of any authentication system is, of course, the user. Our project is no different, so our first step is adding functionality within our data layer to create and persist new user accounts. Luckily for us, the ASP.NET Core Identity system has our back by supplying all of the APIs and integrations required to register a user and store their credentials, profile data, etc. in the database. In this tutorial, we'll be using Sql Server Express, but other supported persistence options include Azure Table Storage, MySql, PostgreSQL, etc.

Entity Framework Core and Identity

To begin, I nugetted the required packages for Entity Framework Core and Identity into the Infrastructure project. If you're interested in the exact list of packages in play, check out the Web.Api.Infrastructure.csproj file.

Next I created AppUser class inherits from IdentityUser - a built-in type used by the Identity framework to hold basic information about a user such as email, username, password etc. By default, this class maps to the AspNetUsers table which we'll see shortly. Subclassing IdentityUser in this manner gives us an extension point to add any custom properties to the identity model. I haven't added any in this sample project but thought it was worth mentioning.

public class AppUser : IdentityUser { // Add additional profile data for application users by adding properties to this class }

With the user model in place, there's a bit more boilerplate code to add to this step. First up is the AppIdentityDbContext which is just the context class Entity Framework Core uses for Identity. We'll add a second DbContext shortly for the main application. Next, I added AppIdentityDbContextFactory which allows the entity framework tools to generate migrations directly from our infrastructure class library. By default, it uses other conventions to gather up the necessary information from our entity types, DbContext, etc. to generate migrations but we'll bypass those and use the design-time factory instead.

public class AppIdentityDbContextFactory : DesignTimeDbContextFactoryBase<AppIdentityDbContext> { protected override AppIdentityDbContext CreateNewInstance(DbContextOptions <AppIdentityDbContext> options) { return new AppIdentityDbContext(options); } }

The next thing we need to do is wire up the Identity provider in the ASP.NET Core middleware. I added the necessary bits to the ConfigureServices() method in Startup.cs.

public IServiceProvider ConfigureServices(IServiceCollection services) { // Add framework services. services.AddDbContext<AppIdentityDbContext>(options => ptions.UseSqlServer(Configuration.GetConnectionString("Default"), b => b.MigrationsAssembly("Web.Api.Infrastructure"))); ... // add identity var identityBuilder = services.AddIdentityCore<AppUser>(o => { // configure identity options o.Password.RequireDigit = false; o.Password.RequireLowercase = false; o.Password.RequireUppercase = false; o.Password.RequireNonAlphanumeric = false; o.Password.RequiredLength = 6; }); ...

With that taken care of I added a UserRepository with a Create() method to persist new users in the database.

public async Task<CreateUserResponse> Create(string firstName, string lastName, string email, string userName, string password) { var appUser = new AppUser {Email = email, UserName = userName}; var identityResult = await _userManager.CreateAsync(appUser, password); if (!identityResult.Succeeded) return new CreateUserResponse(appUser.Id, false,identityResult.Errors.Select(e => new Error(e.Code, e.Description))); var user = new User(firstName, lastName, appUser.Id, appUser.UserName); _appDbContext.Users.Add(user); await _appDbContext.SaveChangesAsync(); return new CreateUserResponse(appUser.Id, identityResult.Succeeded, identityResult.Succeeded ? null : identityResult.Errors.Select(e => new Error(e.Code, e.Description))); }

In this method, we first use the framework provided _userManager to save the new user identity in the AspNetUsers table. Next, we save additional user information that will be used by the associated domain entity. You'll notice this uses a second context: _appDbContext that contains a User entity model.

Creating the Database with EF Core Migrations

With our initial user model defined, we're ready to create the migrations that will be used to generate the schema and database for us. Within the Infrastructure project folder, I ran the following commands to create and apply the app and identity context migrations. Note the use of the --context flag here to identify the target context. Because we have two defined in the same assembly this flag is required to let the EF Core tooling know which one to use when generating the target migration.

Web.Api.Infrastructure>dotnet ef migrations add initial --context AppIdentityDbContext

Web.Api.Infrastructure>dotnet ef migrations add initial --context AppDbContext

Web.Api.Infrastructure>dotnet ef database update --context AppIdentityDbContext

Web.Api.Infrastructure>dotnet ef database update --context AppDbContext

After running these commands, I found a new database within my localdb instance.

Register User Use Case

With the data layer in place, I moved up to the business layer and wrote the RegisterUserUseCase which basically just calls on the repository and pipes the results through an output port for our API (we'll get to that next) to use in its response. The concepts use case and output port are inspired by The Clean Architecture. If you'd like to learn more about Clean Architecture please check out my previous post or Uncle Bob's great introduction.

public async Task<bool> Handle(RegisterUserRequest message, IOutputPort<RegisterUserResponse> outputPort) { var response = await _userRepository.Create(message.FirstName, message.LastName,message.Email, message.UserName, message.Password); outputPort.Handle(response.Success ? new RegisterUserResponse(response.Id, true) : new RegisterUserResponse(response.Errors.Select(e => e.Description))); return response.Success; }

Accounts Controller

With the business and infrastructure layers in place, we're ready to set up a controller with an action for registering new user accounts. I added a new AccountsController with a single action method that will be triggered on a http POST request containing the user's details in the body. This message contains the data that is passed down through the use case and data layers to carry out the operation.

// POST api/accounts [HttpPost] public async Task<ActionResult> Post([FromBody] Models.Request.RegisterUserRequest request) { if (!ModelState.IsValid) { return BadRequest(ModelState); } await _registerUserUseCase.Handle(new RegisterUserRequest(request.FirstName,request.LastName,request.Email, request.UserName,request.Password), _registerUserPresenter); return _registerUserPresenter.ContentResult; }

Loose Coupling and IoC

To keep the layers and components in the project loosely-coupled everything is registered in Autofac. The Infrastructure and Core projects have their respective services registered in modules which get wired into the Web.Api project. We could use the built-in dependency injection container provided by the framework, but we'll replace it with Autofac and use its modules to bundle and organize the dependencies across the individual projects in the solution.

API Testing with Swagger

We're ready to run the project and test out the endpoint we just completed. To make testing easier I outfitted the Web.Api project with swagger by adding the Swashbuckle.AspNetCore package via nuget and then configuring the necessary bits in Startup.cs. Swagger greatly improves our API development experience by providing a documented spec of our API and a handy UI for testing and exploration - it's hard to imagine developing an API without it.

Now, I'm greeted with the Swagger UI when I run the project.

I filled in some user information in the Swagger UI and sent the request.

I get a successful response back. 😎

To test the failure path, I submit the same request again and get a 400 Bad Request error response telling me the user already exists. This message could easily be extracted and rendered in a friendlier fashion by mobile or clients consuming the API.

Authentication

We can create users with our API; now we'll add functionality to authenticate clients and issue them access and refresh tokens. In the following steps, we'll use the Identity APIs to validate user credentials and add the JWT middleware and other bits required to protect specific resources/APIs from unauthorized access.

Login Use Case

The LoginUseCase as the name advertises contains the important logic for authenticating users.

public async Task<bool>Handle(LoginRequest message, IOutputPort<LoginResponse> outputPort) { if (!string.IsNullOrEmpty(message.UserName) && !string.IsNullOrEmpty(message.Password)) { // ensure we have a user with the given user name var user = await _userRepository.FindByName(message.UserName); if (user != null) { // validate password if (await _userRepository.CheckPassword(user, message.Password)) { // generate refresh token var refreshToken = _tokenFactory.GenerateToken(); user.AddRereshToken(refreshToken, user.Id, message.RemoteIpAddress); await _userRepository.Update(user); // generate access token outputPort.Handle(new LoginResponse(await _jwtFactory.GenerateEncodedToken(user.IdentityId, user.UserName), refreshToken, true)); return true; } } } outputPort.Handle(new LoginResponse(new[] { new Error("login_failure", "Invalid username or password.") })); return false; }

Let's break it down a bit further.

The preliminary calls: _userRepository.FindByName() and _userRepository.CheckPassword() use Identity APIs in the underlying user repository to validate the received user credentials.

... public async Task<User>FindByName(string userName) { var appUser = await _userManager.FindByNameAsync(userName); return appUser == null ? null : _mapper.Map(appUser, await GetSingleBySpec(new UserSpecification(appUser.Id))); } public async Task<bool>CheckPassword(User user, string password) { return await _userManager.CheckPasswordAsync(_mapper.Map<AppUser>(user), password); } ...

If everything checks out, we generate a new refresh token via _tokenFactory.GenerateToken() .

internal sealed class TokenFactory : ITokenFactory { public string GenerateToken(int size=32) { var randomNumber = new byte[size]; using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(randomNumber); return Convert.ToBase64String(randomNumber); } } }

RandomNumberGenerator is from the System.Security.Cryptography namespace and creates a cryptographically fortified random value which we'll use for the refresh token.

With a new token value generated we issue it to the user with the AddRereshToken() method on the User domain entity.

public void AddRereshToken(string token,int userId,string remoteIpAddress,double daysToExpire=5) { _refreshTokens.Add(new RefreshToken(token, DateTime.UtcNow.AddDays(daysToExpire),userId, remoteIpAddress)); }

We're giving it a default lifetime of 5 days. Soon, we'll see where we check this value during validation of an exchanged refresh token.

Finally, we generate a new JWT token via _jwtFactory.GenerateEncodedToken() and pipe it through the output port to return it as part of the response from the Web API.

public async Task<AccessToken>GenerateEncodedToken(string id, string userName) { var identity = GenerateClaimsIdentity(id, userName); var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, userName), new Claim(JwtRegisteredClaimNames.Jti, await _jwtOptions.JtiGenerator()), new Claim(JwtRegisteredClaimNames.Iat, ToUnixEpochDate(_jwtOptions.IssuedAt).ToString(), ClaimValueTypes.Integer64), identity.FindFirst(Helpers.Constants.Strings.JwtClaimIdentifiers.Rol), identity.FindFirst(Helpers.Constants.Strings.JwtClaimIdentifiers.Id) }; // Create the JWT security token and encode it. var jwt = new JwtSecurityToken( _jwtOptions.Issuer, _jwtOptions.Audience, claims, _jwtOptions.NotBefore, _jwtOptions.Expiration, _jwtOptions.SigningCredentials); return new AccessToken(_jwtTokenHandler.WriteToken(jwt), (int)_jwtOptions.ValidFor.TotalSeconds); }

Here, we add the various Claims to the token. These are a combination of Registered ones which have reserved names in the JWT spec and Public which we create ourselves.

The registered claims in our token include:

iss : The JWT issuer.

: The JWT issuer. aud : The audience.

: The audience. sub : The subject of the JWT.

: The subject of the JWT. exp : The expiration of the JWT.

: The expiration of the JWT. jti : A unique identifier for the JWT.

: A unique identifier for the JWT. iat : Issued At time. Useful for checking the age of the token.

The public claims are:

rol : The role a user has in the context of our API. This attribute gets used in role-based authorization checks against a controller or action within a controller.

: The role a user has in the context of our API. This attribute gets used in role-based authorization checks against a controller or action within a controller. id : The user id. Useful in scenarios where we need to fetch the user entity.

The last step is to generate the serialized JWT to pass back to the client. For that, we use _jwtTokenHandler.WriteToken() . _jwtTokenHandler' is mainly a wrapper around JwtSecurityTokenHandler` from the System.IdentityModel.Tokens.Jwt namespace and contains the signing key and other bits of configuration provided by the JWT middleware. Next, we'll look at how the middleware is set up.

Check out the code in the infrastructure project's Auth folder to explore the classes responsible for generating and validating JWT and refresh tokens in more detail.

JWT Middleware

Before we can turn on JWTs in our API, we must wire up the JWT middleware in the ASP.NET Core pipeline. ASP.NET Core 2.1.0 includes all of the required APIs in the Microsoft.AspNetCore.App package. After that, all the required configuration is performed in the Startup.cs ConfigureServices() method. I've pulled out the relevant bits here.

public IServiceProvider ConfigureServices(IServiceCollection services) { ... // Register the ConfigurationBuilder instance of AuthSettings var authSettings = Configuration.GetSection(nameof(AuthSettings)); services.Configure<AuthSettings>(authSettings); var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(authSettings[nameof(AuthSettings.SecretKey)])); // jwt wire up // Get options from app settings var jwtAppSettingOptions = Configuration.GetSection(nameof(JwtIssuerOptions)); // Configure JwtIssuerOptions services.Configure<JwtIssuerOptions>(options => { options.Issuer = jwtAppSettingOptions[nameof(JwtIssuerOptions.Issuer)]; options.Audience = jwtAppSettingOptions[nameof(JwtIssuerOptions.Audience)]; options.SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); }); var tokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = jwtAppSettingOptions[nameof(JwtIssuerOptions.Issuer)], ValidateAudience = true, ValidAudience = jwtAppSettingOptions[nameof(JwtIssuerOptions.Audience)], ValidateIssuerSigningKey = true, IssuerSigningKey = signingKey, RequireExpirationTime = false, ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(configureOptions => { configureOptions.ClaimsIssuer = jwtAppSettingOptions[nameof(JwtIssuerOptions.Issuer)]; configureOptions.TokenValidationParameters = tokenValidationParameters; configureOptions.SaveToken = true; }); // api user claim policy services.AddAuthorization(options => { options.AddPolicy("ApiUser", policy => policy.RequireClaim(Constants.Strings.JwtClaimIdentifiers.Rol, Constants.Strings.JwtClaims.ApiAccess)); }); ... }

Auth Controller

Next, I added an AuthController with a Login() action for receiving the user credentials and calling the associated use case.

// POST api/auth/login [HttpPost("login")] public async Task<ActionResult>Login([FromBody] Models.Request.LoginRequest request) { if (!ModelState.IsValid) { return BadRequest(ModelState); } await _loginUseCase.Handle(new LoginRequest(request.UserName, request.Password, Request.HttpContext.Connection.RemoteIpAddress?.ToString()), _loginPresenter); return _loginPresenter.ContentResult; }

Testing the Login Endpoint

I reran the project and saw the new login endpoint in the Swagger UI - so far so good. 😎

In the request body, I entered the credentials of the user I created earlier and executed the request.

🔐 Note, in the real world this request must be made over HTTPS. For added security, you could also base64 encode the credentials payload.

I get a success response which includes the following:

An accessToken which is our JWT. We'll send this value back in subsequent requests as part of an Authorization header using the Bearer eyJhbGciOiJIUzI... format to access the protected resources of our API.

An expiresIn property which is the number of seconds the token is valid for. This is defined in JwtIssuerOptions. Our current setting is 7200 seconds (120 minutes).

A refreshToken that the client can exchange for a new access token.

Access a Protected Controller with Role-based Authorization

We can use the JWT received in the previous step to access the protected routes of our API.

I added a ProtectedController which doesn't do much but is decorated with an Authorize attribute that is enforcing the ApiUser policy.

[Authorize(Policy = "ApiUser")] [Route("api/[controller]")] [ApiController] public class ProtectedController : ControllerBase { // GET api/protected/home [HttpGet] public IActionResult Home() { return new OkObjectResult(new { result = true }); } }

This policy is set up in ConfigureServices() in Startup.cs and simply dictates that only users with the rol claim value of API access can access a protected controller or action.

public IServiceProvider ConfigureServices(IServiceCollection services) { ... // api user claim policy services.AddAuthorization(options => { options.AddPolicy("ApiUser", policy => policy.RequireClaim(Constants.Strings.JwtClaimIdentifiers.Rol, Constants.Strings.JwtClaims.ApiAccess)); }); ...

We can use the Swagger UI to see the policy in action by issuing a test request to the lone endpoint on ProtectedController .

This request results in a 401 Unauthorized response because I haven't included the appropriate authorization header containing my JWT. Let's fix that. Notice the www-authenticate header in the response. This a clue from the server letting us know what authentication scheme it is expecting us to use.

Testing protected APIs with Swagger is a breeze as it allows us to define the various authentication and authorization schemes our API requires.

... // Register the Swagger generator, defining 1 or more Swagger documents services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info { Title = "AspNetCoreApiStarter", Version = "v1" }); // Swagger 2.+ support c.AddSecurityDefinition("Bearer", new ApiKeyScheme { In = "header", Description = "Please insert JWT with Bearer into field", Name = "Authorization", Type = "apiKey" }); c.AddSecurityRequirement(new Dictionary<string, IEnumerable<string>> { { "Bearer", new string[] { } } }); }); ...

With the security configuration added to Swagger, we should see an Authorize button at the top of the Swagger UI page.

Clicking the button launches the Available Authorizations dialog where I entered the authorization header value using the Bearer {Token} format with the JWT token I received earlier during the login step.

With the auth header created, I'm now "logged in" as far as Swagger is concerned. I reran the test request to ProtectedController and got a 200 Success response - JWT authorization is working. 🤘

Exchanging Refresh Tokens

We've built out functionality in our API for creating new user accounts, issuing them access and refresh tokens and authorizing access to protected resources. Now, we'll add the ability to update an expired JWT token by exchanging it for a new one.

Exchange Refresh Token Use Case

Once again, we'll start with the use case and work out from there.

public async Task<bool>Handle(ExchangeRefreshTokenRequest message,IOutputPort<ExchangeRefreshTokenResponse> outputPort) { var cp = _jwtTokenValidator.GetPrincipalFromToken(message.AccessToken, message.SigningKey); // invalid token/signing key was passed and we can't extract user claims if (cp != null) { var id = cp.Claims.First(c => c.Type == "id"); var user = await _userRepository.GetSingleBySpec(new UserSpecification(id.Value)); if (user.HasValidRefreshToken(message.RefreshToken)) { var jwtToken = await _jwtFactory.GenerateEncodedToken(user.IdentityId, user.UserName); var refreshToken = _tokenFactory.GenerateToken(); user.RemoveRefreshToken(message.RefreshToken); // delete the token we've exchanged user.AddRereshToken(refreshToken, user.Id, ""); // add the new one await _userRepository.Update(user); outputPort.Handle(new ExchangeRefreshTokenResponse(jwtToken, refreshToken, true)); return true; } } outputPort.Handle(new ExchangeRefreshTokenResponse(false, "Invalid token.")); return false; }

In the first step, we use _jwtTokenValidator.GetPrincipalFromToken() to validate the received access token. If we have a valid JWT, we extract the user's id from the id claim and fetch the user from the database. We use a method on the User entity to check the validity of the refresh token by comparing the token values and Active flag - pretty simple.

public bool HasValidRefreshToken(string refreshToken) { return _refreshTokens.Any(rt => rt.Token == refreshToken && rt.Active); }

If the refresh token is valid we carry out the following steps to complete the exchange:

Create a new JWT via _jwtFactory.GenerateEncodedToken() .

. Create a new refresh token via _tokenFactory.GenerateToken() .

. Delete the user's old token via `user.RemoveRefreshToken()'. This one is important!

Add the user's new refresh token via _userRepository.Update() .

. Save the changes in the database and pass the new tokens through the output port.

Refresh Token Controller Action

With the use case in place, I moved back up to the Web API project and extended the AuthController with a new RefreshToken action which allows anonymous access and expects to receive access and refresh tokens as inputs.

// POST api/auth/refreshtoken [HttpPost("refreshtoken")] public async Task<ActionResult>RefreshToken([FromBody] Models.Request.ExchangeRefreshTokenRequest request) { if (!ModelState.IsValid) { return BadRequest(ModelState);} await _exchangeRefreshTokenUseCase.Handle(new ExchangeRefreshTokenRequest(request.AccessToken, request.RefreshToken, _authSettings.SecretKey), _exchangeRefreshTokenPresenter); return _exchangeRefreshTokenPresenter.ContentResult; }

Client-side Token Expiry Workflow

The most significant benefit refresh tokens offer from the perspective of the user is the seamless experience it creates by preventing the need for them to log in again. For this to happen, the client must realize when its access token is expired and act accordingly.

A typical client workflow for exchanging a refresh token might look like this:

The client makes a request to a protected resource with an expired token and receives a response containing a Token-Expired header.

header. Detecting the expired token, it issues a request to a refresh endpoint passing along the expired access token and its refresh token for validation.

If validation succeeds, the client receives new access and refresh tokens.

Client re-tries the original request with the new tokens and the cycle repeats.

The implementation of these steps will be different depending on what type of client you're building, eg. SPA, Mobile. A real world example would make an excellent topic for a future blog post, but for now, we can test this flow using Swagger.

Testing the Refresh Token Endpoint

The lifetime of a JWT in our demo project is currently hardcoded at 2 hours in JwtIssuerOptions. The token I received earlier is now expired so when I attempt to access the protected route I get 401 Unauthorized with a token-expired header in the response.

In a real-world client, the token-expired header is the signal our app needs to intercept to trigger the request to the refresh endpoint.

I simulated this step once again in Swagger testing the refresh token endpoint by pasting in the expired access and refresh tokens in the request body.

I submitted the request and voila - I get a successful response back containing new access and refresh tokens!

Wrapping Up

We've covered a lot here and hopefully, you've found some value in this guide. Disclaimer: The code examples are not production ready, there's config in code, keys stored insecurely, etc. so please be mindful of this and ensure whatever bits or concepts you implement in your project meet your project's security requirements.

If you have any comments, improvements or issues, please let me know in the comments!

Source code here