Let's Understand How It Works
Before we start implementing authentication, let's first understand the basic flow.
When a user logs in, they send their credentials (such as email and password) to the server through a login API. The server validates those credentials and, if they are correct, generates a JWT access token and returns it to the client.
The JWT contains claims that provide information about the authenticated user, such as their user ID, name, email, or role.
Whenever the client wants to access a protected API, it sends the JWT in the Authorization header:
Authorization: Bearer <access-token>
The server then validates the token before allowing the request to access the protected resource.
This gives us the basic authentication flow:
User → Login API → Validate Credentials → Generate JWT → Client → Protected API
Basic authentication flow using JWT
Refresh Token
Access tokens should generally be short-lived. A short lifetime reduces the amount of time an attacker can use a stolen access token.
However, this introduces another problem. If the access token expires after a short period, the user would have to log in again to obtain a new one. Obviously, asking users to enter their credentials repeatedly would not provide a good user experience.
This is where refresh tokens come into the picture.
During login, after validating the user's credentials, the server generates two tokens:
- Access Token — short-lived and used to access protected APIs.
- Refresh Token — long-lived and used to obtain a new access token.
The client stores both tokens. When the access token expires, instead of asking the user to log in again, the client sends the refresh token to the refresh endpoint.
The server validates the refresh token and, if it is still valid, generates a new access token and a new refresh token.
The process looks like this:
Login → Access Token + Refresh Token
Then, when the access token expires:
Refresh Token → Validate → New Access Token + New Refresh Token
Authentication flow using refresh tokens
This allows us to keep access tokens short-lived while still providing a smooth experience for the user.
Tools and Technologies Used
For this project, I'll be using the following technologies:
- Visual Studio / VS Code — Development environment
- .NET 10 — Backend framework
- ASP.NET Core Web API — Building the REST API
- SQL Server — Database
- Entity Framework Core — ORM for database operations
- ASP.NET Core Identity — User and role management
- Swagger — Testing the APIs
Connection String
We'll use SQL Server as the database for this project.
Add the SQL Server connection string to appsettings.json:
"ConnectionStrings":{"DefaultConnection":"Server=localhost;Database=JwtAuthDemo;Trusted_Connection=True;TrustServerCertificate=True;"}
We'll use this connection string later when configuring Entity Framework Core and registering our DbContext.
Models
Let's start by creating the model that represents our application user.
Create a new folder named Models. Inside the Models folder, create a class named ApplicationUser.
We'll inherit from IdentityUser<Guid> instead of the default IdentityUser. This allows ASP.NET Core Identity to use a Guid as the primary key for our users.
public class ApplicationUser : IdentityUser<Guid>
{
public string Name { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public DateTime RefreshTokenExpiryTime { get; set; }
}
Here, ApplicationUser extends the built-in IdentityUser class and adds the properties that are specific to our application.
-
Name— stores the user's name. -
RefreshToken— stores the currently issued refresh token. -
RefreshTokenExpiryTime— stores the expiration time of the refresh token.
ASP.NET Core Identity already provides several properties such as Id, Email, UserName, PasswordHash, and other user-management fields. By extending IdentityUser<Guid>, we can use all of that functionality while adding our own application-specific properties.
ApplicationDbContext
Next, we need to create our Entity Framework Core DbContext.
Create a new class named AppDbContext :
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
}
Our AppDbContext inherits from:
IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
The three generic parameters specify:
-
ApplicationUser— our custom user entity. -
IdentityRole<Guid>— the Identity role entity, usingGuidas its primary key. -
Guid— the type of the primary key used by Identity.
By inheriting from IdentityDbContext, Entity Framework Core will create and manage the required ASP.NET Core Identity tables, such as users, roles, claims, and user-role relationships.
We can also add our own DbSet properties to this context later if the application contains additional entities.
Registering AppDbContext and Identity
Now, we need to register our AppDbContext and ASP.NET Core Identity in Program.cs.
First, retrieve the connection string from appsettings.json and register the AppDbContext with Entity Framework Core:
string connectionString = builder.Configuration
.GetConnectionString("DefaultConnection")!;
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
Since we are using ASP.NET Core Identity, we also need to register Identity and tell it to use our AppDbContext for storing Identity data:
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>()
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
Here:
-
ApplicationUseris our custom Identity user. -
IdentityRole<Guid>represents roles and usesGuidas the primary key. -
AddEntityFrameworkStores<AppDbContext>()tells Identity to store users, roles, claims, and other Identity-related data using Entity Framework Core. -
AddDefaultTokenProviders()registers the default token providers provided by ASP.NET Core Identity.
Finally, we need to add the authentication and authorization middleware to the request pipeline.
Make sure they are added before MapControllers():
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
UseAuthentication() is responsible for identifying the user from the provided authentication credentials, such as a JWT.
UseAuthorization() then determines whether the authenticated user has permission to access the requested resource.
The order is important:
Request
↓
UseAuthentication()
↓
UseAuthorization()
↓
MapControllers()
↓
Controller
We'll configure JWT authentication shortly. Once that configuration is in place, UseAuthentication() will be responsible for validating the JWT sent with requests to protected endpoints.
JWT Configuration
Now that we have configured Entity Framework Core and ASP.NET Core Identity, the next step is to configure JWT authentication.
We need a few JWT-related settings such as the issuer, audience, and secret key.
Add the following configuration to appsettings.json:
"JWT":{"ValidAudience":"https://localhost:7001","ValidIssuer":"https://localhost:7001"}
Replace
https://localhost:7001with the URL of your API if your application is running on a different port.
ValidAudience
ValidAudience specifies who the JWT is intended for.
This value is used to validate the aud claim in the JWT. When the server receives a token, it can check whether the token was issued for the expected audience.
For example:
{"aud":"https://localhost:7001"}
If the audience in the token doesn't match the configured audience, the token validation will fail.
ValidIssuer
ValidIssuer identifies the application or authentication server that issued the JWT.
This value is used to validate the iss claim in the token.
For example:
{"iss":"https://localhost:7001"}
When validating a token, the application checks whether the issuer matches the configured value.
JWT Secret Key
The JWT also needs a secret key to sign and validate tokens.
For local development, instead of putting the secret directly inside appsettings.json, we'll store it using .NET User Secrets.
First, initialize User Secrets for the project:
dotnet user-secrets init
Then store the JWT secret:
dotnet user-secrets set "JWT:Secret" "your-32-characters-long-super-strong-jwt-secret-key"
The secret should be long, random, and kept private. Never commit your JWT secret to source control.
For production environments, use a proper secret-management solution such as a cloud key vault or another secure secrets manager.
Configuring JWT Authentication
Now let's configure JWT Bearer authentication in Program.cs.
Add the following configuration:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme =
JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme =
JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme =
JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = builder.Configuration["JWT:ValidAudience"],
ValidIssuer = builder.Configuration["JWT:ValidIssuer"],
ClockSkew = TimeSpan.Zero,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(
builder.Configuration["JWT:Secret"]!
)
)
};
});
Let's understand some of the important settings.
ValidateIssuer
ValidateIssuer = true
This tells ASP.NET Core to validate the iss claim of the JWT against the configured ValidIssuer.
ValidateAudience
ValidateAudience = true
This validates the aud claim against the configured ValidAudience.
ClockSkew
ClockSkew = TimeSpan.Zero
ClockSkew allows some tolerance when validating token expiration times. Setting it to TimeSpan.Zero means no additional time is added as a tolerance.
IssuerSigningKey
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(
builder.Configuration["JWT:Secret"]!
)
)
The secret key is converted into a byte array and used to create the symmetric signing key.
This key is important because the same secret is used to validate that the JWT was signed by our application and has not been modified.
At this point, our application knows how to validate JWTs. In the next sections, we'll create the service responsible for generating the access and refresh tokens.
Creating the Database with EF Core Migrations
Now that our AppDbContext, Identity, and JWT configuration are in place, we can create the database using Entity Framework Core migrations.
Open Package Manager Console in Visual Studio:
Tools → NuGet Package Manager → Package Manager Console
Run the following command to create the initial migration:
Add-MigrationInitialCreate
This creates a migration containing the changes required to create our database schema, including the tables required by ASP.NET Core Identity.
Next, apply the migration to the SQL Server database:
Update-Database
After running these commands successfully, Entity Framework Core will create the database and the required Identity tables in SQL Server.
You can now open SQL Server Management Studio (SSMS) or SQL Server Object Explorer and verify that the database and Identity tables have been created.
Role Constants
Instead of using role names such as "Admin" and "User" throughout the application, we'll keep them in one place.
Create a folder named Constants. Inside it, create a class named Roles.
// Domain/Constants/Roles.cs
namespace Practice_Backend.Domain.Constants;
public class Roles
{
public const string Admin = "Admin";
public const string User = "User";
}
Now, whenever we need to check or assign a role, we can use Roles.Admin or Roles.User instead of hardcoding the role name.
For example:
[Authorize(Roles = Roles.Admin)]
This also makes it easier to change role names later without having to search through the entire application.
Seeding Roles
Our application needs two roles:
AdminUser
Rather than manually creating these roles in the database, we can seed them when the application starts.
Create a folder named Seeder inside the Infrastructure project and create a class named RoleSeeder.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Practice_Backend.Domain.Constants;
namespace Practice_Backend.Infrastructure.Seeder;
public class RoleSeeder
{
public static async Task SeedData(IApplicationBuilder app)
{
using var scope = app.ApplicationServices.CreateScope();
var roleManager = scope.ServiceProvider
.GetRequiredService<RoleManager<IdentityRole<Guid>>>();
var logger = scope.ServiceProvider
.GetRequiredService<ILogger<RoleSeeder>>();
var roles = new[]
{
Roles.Admin,
Roles.User
};
foreach (var role in roles)
{
if (await roleManager.RoleExistsAsync(role))
{
continue;
}
var result = await roleManager.CreateAsync(
new IdentityRole<Guid>(role)
);
if (result.Succeeded)
{
logger.LogInformation(
"Role {Role} created successfully",
role
);
}
else
{
var errors = result.Errors
.Select(e => e.Description);
logger.LogError(
"Failed to create role {Role}. Errors: {Errors}",
role,
string.Join(", ", errors)
);
}
}
}
}
The important part here is that we first check whether a role already exists:
if (await roleManager.RoleExistsAsync(role))
{
continue;
}
This prevents the seeder from trying to create the same role every time the application starts.
Seeding the Admin User
Creating the Admin role is not enough. We also need an administrator account that can be assigned this role.
We'll create a separate AdminSeeder for this purpose.
Create another class named AdminSeeder inside the Seeder folder:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Practice_Backend.Domain.Constants;
using Practice_Backend.Domain.Entities;
namespace Practice_Backend.Infrastructure.Seeder;
public class AdminSeeder
{
public static async Task SeedData(IApplicationBuilder app)
{
using var scope = app.ApplicationServices.CreateScope();
var userManager = scope.ServiceProvider
.GetRequiredService<UserManager<ApplicationUser>>();
var logger = scope.ServiceProvider
.GetRequiredService<ILogger<AdminSeeder>>();
try
{
var admin = await userManager
.FindByEmailAsync("admin@gmail.com");
if (admin == null)
{
admin = new ApplicationUser
{
Name = "Admin",
UserName = "admin@gmail.com",
Email = "admin@gmail.com",
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString()
};
var result = await userManager.CreateAsync(
admin,
"Admin@123"
);
if (!result.Succeeded)
{
var errors = result.Errors
.Select(e => e.Description);
logger.LogError(
"Failed to create admin. Errors: {Errors}",
string.Join(", ", errors)
);
return;
}
logger.LogInformation("Admin user created");
}
if (!await userManager.IsInRoleAsync(admin, Roles.Admin))
{
var result = await userManager.AddToRoleAsync(
admin,
Roles.Admin
);
if (!result.Succeeded)
{
var errors = result.Errors
.Select(e => e.Description);
logger.LogError(
"Failed to assign Admin role. Errors: {Errors}",
string.Join(", ", errors)
);
return;
}
logger.LogInformation(
"Admin role assigned successfully"
);
}
}
catch (Exception ex)
{
logger.LogCritical(
ex,
"Error occurred while seeding admin"
);
}
}
}
There are two important checks in this seeder.
First, we check whether the admin user already exists:
var admin = await userManager
.FindByEmailAsync("admin@gmail.com");
If the user doesn't exist, we create the account.
After that, we check whether the user already has the Admin role:
if (!await userManager.IsInRoleAsync(admin, Roles.Admin))
If the role hasn't been assigned, we assign it using:
await userManager.AddToRoleAsync(
admin,
Roles.Admin
);
This makes the seeder idempotent—we can run it every time the application starts without continuously creating duplicate users or roles.
Note: In a real production application, don't hardcode an administrator password such as
Admin@123in source code. Store it in a secure configuration mechanism such as User Secrets for development and a secret manager in production.
Registration and Login
Now that Identity and JWT authentication are configured, let's implement the actual registration and login flow.
Registration
The Register method is responsible for creating a new user and issuing tokens after successful registration.
First, we check whether an account with the provided email already exists:
var existingUser = await userManager.FindByEmailAsync(command.Email);
if (existingUser != null)
{
throw new Exception(
"User with this email already exists"
);
}
If an account already exists, we throw a Exception instead of creating another account.
We also perform a validation before creating the user:
if (command.Password != command.ConfirmPassword)
{
throw new Exception(
"password and confirm password doesn't match"
);
}
The validation makes sure that the password and confirmation password match.
Once the validation succeeds, we create our ApplicationUser:
ApplicationUser user = new()
{
Email = command.Email,
SecurityStamp = Guid.NewGuid().ToString(),
UserName = command.Email,
Name= command.FullName,
EmailConfirmed = true,
};
We then let ASP.NET Core Identity create the user:
var createUserResult = await userManager
.CreateAsync(user, command.Password);
Identity takes care of processing the password and storing the user securely.
If user creation fails, we collect the Identity errors and throw a Exception:
if (createUserResult.Succeeded == false)
{
var errors = createUserResult.Errors
.Select(e => e.Description);
throw new Exception(
$"Failed to create user. Errors: {string.Join(", ", errors)}"
);
}
Assigning the User Role
After creating the user, we assign the User role:
var addUserToRoleResult = await userManager
.AddToRoleAsync(user, Roles.User);
This is important because the user's role will later be included as a claim in the JWT.
If the role assignment fails, we return an error:
if (addUserToRoleResult.Succeeded == false)
{
var errors = addUserToRoleResult.Errors
.Select(e => e.Description);
throw new BadRequestException(
$"Failed to assign user role. Errors: {string.Join(", ", errors)}"
);
}
Creating Claims
Once the user has been created and assigned a role, we create the claims that will be included in the JWT:
List<Claim> authClaims =
[
new Claim(
ClaimTypes.NameIdentifier,
user.Id.ToString()
),
new Claim(
ClaimTypes.Email,
user.Email
),
new Claim(
ClaimTypes.Name,
user.UserName
),
new Claim(
JwtRegisteredClaimNames.Jti,
Guid.NewGuid().ToString()
)
];
We then retrieve the roles assigned to the user:
var userRoles = await userManager.GetRolesAsync(user);
Each role is added as a ClaimTypes.Role claim:
foreach (var userRole in userRoles)
{
authClaims.Add(
new Claim(ClaimTypes.Role, userRole)
);
}
This allows ASP.NET Core's authorization system to determine the user's role when the JWT is later presented to a protected endpoint.
Generating the Tokens
Now we can generate the access token and refresh token:
var accessToken =
tokenService.GenerateAccessToken(authClaims);
string refreshToken =
tokenService.GenerateRefreshToken();
The refresh token and its expiration time are then stored on the user:
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime =
DateTime.UtcNow.AddDays(7);
await userManager.UpdateAsync(user);
public async Task<RegisterCommandResponse> Register(RegisterCommand command)
{
var existingUser = await userManager.FindByEmailAsync(command.Email);
if (existingUser != null)
{
throw new Exception("User with this email already exists");
}
if(command.Password != command.ConfirmPassword)
{
throw new Exception("password and confirm password doesn't match");
}
ApplicationUser user = new()
{
Email = command.Email,
SecurityStamp = Guid.NewGuid().ToString(),
UserName = command.Email,
Name= command.Name,
EmailConfirmed = true,
};
var createUserResult = await userManager.CreateAsync(user, command.Password);
if (createUserResult.Succeeded == false)
{
var errors = createUserResult.Errors.Select(e => e.Description);
throw new BadRequestException ($"Failed to create user. Errors: {string.Join(", ", errors)}");
}
var addUserToRoleResult = await userManager.AddToRoleAsync(user: user, role: Roles.User);
if (addUserToRoleResult.Succeeded == false)
{
var errors = addUserToRoleResult.Errors.Select(e => e.Description);
throw new BadRequestException( $"Failed to assign user role. Errors: {string.Join(", ", errors)}");
}
List<Claim> authClaims = [
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.UserName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
];
var userRoles = await userManager.GetRolesAsync(user);
foreach (var userRole in userRoles)
{
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
}
var accessToken = tokenService.GenerateAccessToken(authClaims);
string refreshToken = tokenService.GenerateRefreshToken();
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);
await userManager.UpdateAsync(user);
return new RegisterCommandResponse
{
accessToken = accessToken,
refreshToken = refreshToken
};
}
```
{% endraw %}
# Login
The login process is very similar, except that instead of creating a new user, we first validate the existing user's credentials.
We find the user using the email address:
{% raw %}
```csharp
var user = await userManager
.FindByEmailAsync(command.Email);
if (user == null)
{
throw new UnAuthorizedException(
"User with this Email is not registered with us."
);
}
Next, we validate the password using Identity's UserManager:
bool isValidPassword =
await userManager.CheckPasswordAsync(
user,
command.Password
);
if (isValidPassword == false)
{
throw new UnAuthorizedException(
"invalid credentials"
);
}
If the credentials are valid, we create the claims in the same way as we did during registration:
List<Claim> authClaims =
[
new Claim(
ClaimTypes.NameIdentifier,
user.Id.ToString()
),
new Claim(
ClaimTypes.Email,
user.Email
),
new Claim(
ClaimTypes.Name,
user.UserName
),
new Claim(
JwtRegisteredClaimNames.Jti,
Guid.NewGuid().ToString()
)
];
Then we retrieve the user's roles and add them to the claims:
var userRoles =
await userManager.GetRolesAsync(user);
foreach (var userRole in userRoles)
{
authClaims.Add(
new Claim(ClaimTypes.Role, userRole)
);
}
Finally, we generate a new access token and refresh token:
var accessToken =
tokenService.GenerateAccessToken(authClaims);
string refreshToken =
tokenService.GenerateRefreshToken();
The refresh token is stored against the user and given a seven-day expiration:
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime =
DateTime.UtcNow.AddDays(7);
await userManager.UpdateAsync(user);
The refresh token will become especially important when the access token expires. Instead of forcing the user to log in again, we can use the refresh token to obtain a new pair of tokens.
public async Task<LoginCommandResponse> Login(LoginCommand command)
{
var user = await userManager.FindByEmailAsync(command.Email);
if (user == null)
{
throw new UnAuthorizedException("User with this Email is not registered with us.");
}
bool isValidPassword = await userManager.CheckPasswordAsync(user, command.Password);
if (isValidPassword == false)
{
throw new UnAuthorizedException("invalid credentials");
}
List<Claim> authClaims = [
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.UserName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
];
var userRoles = await userManager.GetRolesAsync(user);
foreach (var userRole in userRoles)
{
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
}
var accessToken = tokenService.GenerateAccessToken(authClaims);
string refreshToken = tokenService.GenerateRefreshToken();
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);
await userManager.UpdateAsync(user);
return new LoginCommandResponse
{
accessToken = accessToken,
refreshToken = refreshToken
};
}
Refresh Token
The access token we generate during login is short-lived. Once it expires, the client can no longer use it to access protected endpoints.
Instead of asking the user to log in again, we can use the refresh token to generate a new access token.
Let's implement this flow in the Refresh method of our AuthService.
Getting the User from the Expired Token
The first step is to extract the user's claims from the expired access token:
var principal =
tokenService.GetPrincipalFromExpiredToken(
command.accessToken
);
Normally, an expired JWT would fail lifetime validation. However, our token service has a separate method that validates the token while allowing us to retrieve the claims from an expired access token.
From the principal, we can retrieve the username:
var username = principal.Identity.Name;
We can then use this username to find the corresponding user:
var user =
await userManager.FindByNameAsync(username);
if (user == null)
{
throw new UnAuthorizedException(
"user is null."
);
}
Validating the Refresh Token
Finding the user is not enough. We also need to make sure that the refresh token provided by the client is the same refresh token that we have stored for that user.
if (user.RefreshToken != command.refreshToken)
{
throw new UnAuthorizedException(
"refresh token doesn't match."
);
}
We also check whether the refresh token has expired:
if (user.RefreshTokenExpiryTime <= DateTime.UtcNow)
{
throw new UnAuthorizedException(
"refresh token time expired."
);
}
So, a refresh request is accepted only when:
- The access token can be validated and its claims extracted.
- The corresponding user exists.
- The provided refresh token matches the stored refresh token.
- The refresh token has not expired.
Generating New Tokens
Once all the validations pass, we generate a new access token using the claims from the existing token:
var newAccessToken =
tokenService.GenerateAccessToken(
principal.Claims
);
We also generate a completely new refresh token:
var newRefreshToken =
tokenService.GenerateRefreshToken();
We then replace the old refresh token with the new one:
user.RefreshToken = newRefreshToken;
user.RefreshTokenExpiryTime =
DateTime.UtcNow.AddDays(7);
await userManager.UpdateAsync(user);
This means the old refresh token is no longer valid.
This approach is known as refresh token rotation. Every successful refresh generates a new refresh token instead of continuing to use the same one.
The client can now replace its old tokens with these newly generated tokens and continue making authenticated requests.
public async Task<RefreshCommandResponse> Refresh(RefreshCommand command)
{
var principal = tokenService.GetPrincipalFromExpiredToken(command.accessToken);
var username = principal.Identity.Name;
var user = await userManager.FindByNameAsync(username);
if (user == null)
{
throw new UnAuthorizedException("user is null.");
}
if (user == null || user.RefreshToken != command.refreshToken)
{
throw new UnAuthorizedException("refresh token doesn't match.");
}
if (user == null || user.RefreshTokenExpiryTime <= DateTime.UtcNow)
{
throw new UnAuthorizedException("refresh token time expired.");
}
var newAccessToken = tokenService.GenerateAccessToken(principal.Claims);
var newRefreshToken = tokenService.GenerateRefreshToken();
user.RefreshToken = newRefreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);
await userManager.UpdateAsync(user);
return new RefreshCommandResponse
{
accessToken = newAccessToken,
refreshToken = newRefreshToken
};
}
Logout
Now that we have registration, login, and refresh token functionality, we also need to implement logout.
Logging out should invalidate the refresh token so that it can no longer be used to obtain new access tokens.
In our implementation, the logout operation is handled by the Logout method.
Getting the Current User
We first check whether we have an authenticated user:
if (currentUser.UserId == null)
{
throw new UnAuthorizedException(
"unauthorized access"
);
}
Our ICurrentUser abstraction provides the ID of the currently authenticated user from the JWT claims.
Once we have the user ID, we retrieve the user through UserManager:
var user =
await userManager.FindByIdAsync(
currentUser.UserId
);
if (user == null)
{
throw new UnAuthorizedException(
"unAuthorized access"
);
}
Invalidating the Refresh Token
Once we have the user, we remove the stored refresh token:
user.RefreshToken = null;
user.RefreshTokenExpiryTime = null;
await userManager.UpdateAsync(user);
This is important because simply removing the token from the client is not enough.
If someone has obtained a valid refresh token before logout, they could potentially continue using it.
By removing the refresh token from the database, the server can no longer validate that token.
After logout, the previously stored refresh token can no longer be used to obtain a new access token.
public async Task<LogoutCommandResponse> Logout(LogoutCommand command)
{
if (currentUser.UserId == null)
{
throw new UnAuthorizedException("unauthorized access");
}
var user = await userManager.FindByIdAsync(currentUser.UserId);
if(user == null)
{
throw new UnAuthorizedException("unAuthorized access");
}
user.RefreshToken = null;
user.RefreshTokenExpiryTime = null;
await userManager.UpdateAsync(user);
return new LogoutCommandResponse { };
}```