Learnixo

.NET & C# Development · Lesson 190 of 229

OrderFlow Part 2: JWT Auth — Access Tokens & Rotating Refresh Tokens

OrderFlow: JWT Authentication & Refresh Tokens

This is part 2 of the OrderFlow project series. We left off with a running API skeleton. Now we secure it: JWT access tokens, rotating refresh tokens, role-based authorization, and a locked-down endpoint structure.

Starting point: OrderFlow setup complete — DI, logging, health checks running.


What We're Adding

Before this lesson:
  GET /api/orders → 200 (anyone can call it — no auth)

After this lesson:
  GET  /api/orders     → 401 unless Bearer token present
  POST /api/orders     → 401 unless Customer or Admin role
  DELETE /api/orders   → 401 unless Admin role

  POST /api/auth/login   → returns accessToken + refreshToken
  POST /api/auth/refresh → exchanges refreshToken for new pair
  POST /api/auth/logout  → revokes refreshToken

Step 1: Add the Auth Domain

C#
// src/OrderFlow.Core/Entities/User.cs
public class User
{
    public int      Id           { get; set; }
    public string   Email        { get; set; } = "";
    public string   PasswordHash { get; set; } = "";
    public string   Role         { get; set; } = "Customer";   // Customer | Admin
    public DateTime CreatedAt    { get; set; }
    public bool     IsActive     { get; set; } = true;
}

// src/OrderFlow.Core/Entities/RefreshToken.cs
public class RefreshToken
{
    public int      Id        { get; set; }
    public int      UserId    { get; set; }
    public User     User      { get; set; } = null!;
    public string   Token     { get; set; } = "";
    public DateTime ExpiresAt { get; set; }
    public bool     IsRevoked { get; set; }
    public string?  ReplacedByToken { get; set; }   // rotation audit trail
    public DateTime CreatedAt { get; set; }

    public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
    public bool IsActive  => !IsRevoked && !IsExpired;
}

Step 2: EF Core — Add to DbContext

C#
// src/OrderFlow.Infrastructure/Data/OrderFlowDbContext.cs
public class OrderFlowDbContext(DbContextOptions<OrderFlowDbContext> opts) : DbContext(opts)
{
    public DbSet<Order>        Orders        => Set<Order>();
    public DbSet<User>         Users         => Set<User>();
    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();

    protected override void OnModelCreating(ModelBuilder model)
    {
        model.Entity<User>(e =>
        {
            e.HasIndex(u => u.Email).IsUnique();
            e.Property(u => u.Role).HasMaxLength(32);
        });

        model.Entity<RefreshToken>(e =>
        {
            e.HasIndex(rt => rt.Token).IsUnique();
            e.HasOne(rt => rt.User)
             .WithMany()
             .HasForeignKey(rt => rt.UserId)
             .OnDelete(DeleteBehavior.Cascade);
        });
    }
}
Bash
dotnet ef migrations add AddAuth --project src/OrderFlow.Infrastructure --startup-project src/OrderFlow.Api
dotnet ef database update --project src/OrderFlow.Infrastructure --startup-project src/OrderFlow.Api

Step 3: Token Service

C#
// src/OrderFlow.Core/Services/ITokenService.cs
public interface ITokenService
{
    string          GenerateAccessToken(User user);
    RefreshToken    GenerateRefreshToken(int userId);
    ClaimsPrincipal ValidateAccessToken(string token);
}

// src/OrderFlow.Infrastructure/Auth/TokenService.cs
public class TokenService(IConfiguration config) : ITokenService
{
    public string GenerateAccessToken(User user)
    {
        var key   = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(config["Jwt:Secret"]!));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub,   user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(ClaimTypes.Role,               user.Role),
            new Claim(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString()),
        };

        var token = new JwtSecurityToken(
            issuer:             config["Jwt:Issuer"],
            audience:           config["Jwt:Audience"],
            claims:             claims,
            expires:            DateTime.UtcNow.AddMinutes(15),   // short-lived
            signingCredentials: creds);

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

    public RefreshToken GenerateRefreshToken(int userId) => new()
    {
        UserId    = userId,
        Token     = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)),
        ExpiresAt = DateTime.UtcNow.AddDays(7),
        CreatedAt = DateTime.UtcNow,
    };

    public ClaimsPrincipal ValidateAccessToken(string token)
    {
        var handler    = new JwtSecurityTokenHandler();
        var parameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey         = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)),
            ValidateIssuer   = true,
            ValidIssuer      = config["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience    = config["Jwt:Audience"],
            ValidateLifetime = false,   // we only call this during refresh — token may be expired
        };

        return handler.ValidateToken(token, parameters, out _);
    }
}

Step 4: Auth Endpoints

C#
// src/OrderFlow.Api/Endpoints/AuthEndpoints.cs
public static class AuthEndpoints
{
    public static void MapAuthEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/api/auth").WithTags("Auth");

        group.MapPost("/register", Register);
        group.MapPost("/login",    Login);
        group.MapPost("/refresh",  Refresh);
        group.MapPost("/logout",   Logout).RequireAuthorization();
    }

    static async Task<IResult> Register(
        RegisterRequest req,
        OrderFlowDbContext db,
        CancellationToken ct)
    {
        if (await db.Users.AnyAsync(u => u.Email == req.Email, ct))
            return Results.Conflict("Email already registered.");

        var user = new User
        {
            Email        = req.Email.ToLowerInvariant(),
            PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
            Role         = "Customer",
            CreatedAt    = DateTime.UtcNow,
        };

        db.Users.Add(user);
        await db.SaveChangesAsync(ct);
        return Results.Created($"/api/users/{user.Id}", new { user.Id, user.Email, user.Role });
    }

    static async Task<IResult> Login(
        LoginRequest req,
        OrderFlowDbContext db,
        ITokenService tokens,
        CancellationToken ct)
    {
        var user = await db.Users
            .FirstOrDefaultAsync(u => u.Email == req.Email.ToLowerInvariant(), ct);

        if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
            return Results.Unauthorized();   // don't reveal which field was wrong

        if (!user.IsActive)
            return Results.Forbid();

        var accessToken  = tokens.GenerateAccessToken(user);
        var refreshToken = tokens.GenerateRefreshToken(user.Id);

        db.RefreshTokens.Add(refreshToken);
        await db.SaveChangesAsync(ct);

        return Results.Ok(new AuthResponse(accessToken, refreshToken.Token, user.Role));
    }

    static async Task<IResult> Refresh(
        RefreshRequest req,
        OrderFlowDbContext db,
        ITokenService tokens,
        CancellationToken ct)
    {
        var stored = await db.RefreshTokens
            .Include(rt => rt.User)
            .FirstOrDefaultAsync(rt => rt.Token == req.RefreshToken, ct);

        if (stored is null || !stored.IsActive)
            return Results.Unauthorized();

        // Rotate: revoke old token, issue new pair
        stored.IsRevoked = true;
        var newRefresh   = tokens.GenerateRefreshToken(stored.UserId);
        stored.ReplacedByToken = newRefresh.Token;

        db.RefreshTokens.Add(newRefresh);
        await db.SaveChangesAsync(ct);

        var newAccess = tokens.GenerateAccessToken(stored.User);
        return Results.Ok(new AuthResponse(newAccess, newRefresh.Token, stored.User.Role));
    }

    static async Task<IResult> Logout(
        LogoutRequest req,
        OrderFlowDbContext db,
        CancellationToken ct)
    {
        var token = await db.RefreshTokens
            .FirstOrDefaultAsync(rt => rt.Token == req.RefreshToken, ct);

        if (token is { IsActive: true })
        {
            token.IsRevoked = true;
            await db.SaveChangesAsync(ct);
        }

        return Results.NoContent();
    }
}

public record RegisterRequest(string Email, string Password);
public record LoginRequest(string Email, string Password);
public record RefreshRequest(string RefreshToken);
public record LogoutRequest(string RefreshToken);
public record AuthResponse(string AccessToken, string RefreshToken, string Role);

Step 5: Wire Up JWT Validation

C#
// src/OrderFlow.Api/Program.cs additions
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opts =>
    {
        opts.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)),
            ValidateIssuer   = true,
            ValidIssuer      = builder.Configuration["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience    = builder.Configuration["Jwt:Audience"],
            ValidateLifetime = true,
            ClockSkew        = TimeSpan.Zero,   // no tolerance — 15 min means 15 min
        };
    });

builder.Services.AddAuthorization(opts =>
{
    opts.AddPolicy("AdminOnly",    p => p.RequireRole("Admin"));
    opts.AddPolicy("CustomerOrAdmin", p => p.RequireRole("Customer", "Admin"));
});

builder.Services.AddScoped<ITokenService, TokenService>();

// After building the app:
app.UseAuthentication();   // must come before UseAuthorization
app.UseAuthorization();
JSON
// appsettings.Development.json
{
  "Jwt": {
    "Secret":   "dev-only-secret-replace-in-prod-32chars!!",
    "Issuer":   "orderflow-api",
    "Audience": "orderflow-clients"
  }
}

Step 6: Secure the Order Endpoints

C#
// Apply auth to the orders group
var orders = app.MapGroup("/api/orders")
    .WithTags("Orders")
    .RequireAuthorization();   // all routes need a valid token

orders.MapGet("/",        GetOrders);              // any authenticated user
orders.MapGet("/{id}",   GetOrder);               // any authenticated user
orders.MapPost("/",      CreateOrder);             // Customer or Admin
orders.MapPut("/{id}",   UpdateOrder)
    .RequireAuthorization("AdminOnly");            // Admin only
orders.MapDelete("/{id}", DeleteOrder)
    .RequireAuthorization("AdminOnly");            // Admin only

// Read the user ID from the JWT claim
static async Task<IResult> GetOrders(
    ClaimsPrincipal user,   // injected from the token
    IOrderRepository orders,
    CancellationToken ct)
{
    var userId = int.Parse(user.FindFirst(ClaimTypes.NameIdentifier)!.Value);
    var role   = user.FindFirst(ClaimTypes.Role)!.Value;

    // Admins see all orders; customers see only their own
    var result = role == "Admin"
        ? await orders.GetAllAsync(ct)
        : await orders.GetByCustomerAsync(userId, ct);

    return Results.Ok(result);
}

Step 7: Test the Auth Flow

Bash
# 1. Register
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"P@ssw0rd!"}'

# 2. Login  get tokens
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"P@ssw0rd!"}'
#  {"accessToken":"eyJ...","refreshToken":"abc123...","role":"Customer"}

# 3. Call a protected endpoint
curl http://localhost:5000/api/orders \
  -H "Authorization: Bearer eyJ..."

# 4. Refresh when access token expires
curl -X POST http://localhost:5000/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"abc123..."}'

# 5. Logout
curl -X POST http://localhost:5000/api/auth/logout \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"abc123..."}'

What's Next

OrderFlow now has:

  • JWT access tokens (15-min lifetime)
  • Rotating refresh tokens (7-day lifetime, single-use)
  • Role-based authorization (Customer/Admin)
  • Revocation on logout

Next: OrderFlow CQRS — introduce MediatR commands and queries to replace the direct repository calls and prepare for domain events.