Files
ROLAC/docs/superpowers/plans/2026-05-25-login-api.md
T

55 KiB
Raw Blame History

Login API (JWT + ASP.NET Identity) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement three auth endpoints (POST /api/auth/login, /api/auth/refresh, /api/auth/logout) using ASP.NET Core Identity for user management and custom JWT + DB-stored refresh tokens for stateless authentication.

Architecture: ASP.NET Core Identity manages users, roles, and password hashing. A TokenService generates HS256 JWTs (15-minute lifetime) and cryptographically-random refresh tokens stored as SHA-256 hashes in a PostgreSQL RefreshTokens table. The refresh token travels in an HttpOnly; Secure; SameSite=Strict cookie (rolac_rt); the access token is returned in the JSON response body. Every refresh rotates the token (old revoked, new issued) to limit replay windows.

Tech Stack: .NET 8, ASP.NET Core Identity, EF Core 8, Npgsql.EntityFrameworkCore.PostgreSQL, Microsoft.IdentityModel.Tokens (JWT), xUnit, Moq, EF InMemory (tests)


File Map

New files:

Path Responsibility
API/ROLAC.API/Entities/AppUser.cs IdentityUser subclass — adds MemberId, IsActive, LastLoginAt, LanguagePreference
API/ROLAC.API/Entities/AppRole.cs IdentityRole subclass — adds Description
API/ROLAC.API/Entities/RefreshToken.cs DB record per issued refresh token (hashed, revocable, rotatable)
API/ROLAC.API/Data/AppDbContext.cs IdentityDbContext<AppUser, AppRole, string> + RefreshTokens DbSet
API/ROLAC.API/Data/DbSeeder.cs Seeds 13 RBAC roles + a dev super_admin user on startup
API/ROLAC.API/DTOs/Auth/LoginRequest.cs { Email, Password } with validation attributes
API/ROLAC.API/DTOs/Auth/LoginResponse.cs { AccessToken, ExpiresIn, User }
API/ROLAC.API/Services/ITokenService.cs Interface: GenerateAccessToken / GenerateRefreshToken / HashToken
API/ROLAC.API/Services/TokenService.cs Implementation
API/ROLAC.API/Services/IAuthService.cs Interface: LoginAsync / RefreshAsync / LogoutAsync
API/ROLAC.API/Services/AuthService.cs Implementation
API/ROLAC.API/Controllers/AuthController.cs 3 endpoints wired to AuthService
API/ROLAC.API.Tests/ROLAC.API.Tests.csproj xUnit + Moq test project
API/ROLAC.API.Tests/Services/TokenServiceTests.cs 7 unit tests for TokenService
API/ROLAC.API.Tests/Services/AuthServiceTests.cs 9 unit tests for AuthService

Modified files:

Path What changes
API/ROLAC.API/ROLAC.API.csproj Add 5 NuGet packages
API/ROLAC.API/Program.cs Wire Identity, EF Core, JWT auth, CORS, DI, startup seeding
API/ROLAC.API/appsettings.json Add Jwt section (non-secret values only)
API/ROLAC.API/appsettings.Development.json Add ConnectionStrings + Jwt:SecretKey (gitignored)
API/ROLAC.API/ROLAC.API.sln Add test project reference

Task 1: Install NuGet packages

Files: Modify API/ROLAC.API/ROLAC.API.csproj

  • Step 1: Add the 5 required packages

Run from E:\VSProject\ROLAC:

dotnet add API/ROLAC.API/ROLAC.API.csproj package Microsoft.AspNetCore.Identity.EntityFrameworkCore --version 8.0.11
dotnet add API/ROLAC.API/ROLAC.API.csproj package Microsoft.EntityFrameworkCore.Design --version 8.0.11
dotnet add API/ROLAC.API/ROLAC.API.csproj package Npgsql.EntityFrameworkCore.PostgreSQL --version 8.0.11
dotnet add API/ROLAC.API/ROLAC.API.csproj package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.11
dotnet add API/ROLAC.API/ROLAC.API.csproj package Microsoft.IdentityModel.Tokens --version 7.5.2
  • Step 2: Restore and verify
dotnet restore API/ROLAC.API/ROLAC.API.csproj
dotnet build API/ROLAC.API/ROLAC.API.csproj

Expected: Build succeeded. 0 Error(s)

  • Step 3: Commit
git add API/ROLAC.API/ROLAC.API.csproj
git commit -m "chore: add Identity, EF Core PostgreSQL, JWT Bearer packages"

Task 2: Create entities

Files:

  • Create API/ROLAC.API/Entities/AppUser.cs

  • Create API/ROLAC.API/Entities/AppRole.cs

  • Create API/ROLAC.API/Entities/RefreshToken.cs

  • Step 1: Create Entities/AppUser.cs

using Microsoft.AspNetCore.Identity;

namespace ROLAC.API.Entities;

public class AppUser : IdentityUser
{
    /// <summary>Links this login account to a church member record. Null for admin-only accounts.</summary>
    public int? MemberId { get; set; }

    /// <summary>UI language preference: 'en' or 'zh-TW'.</summary>
    public string LanguagePreference { get; set; } = "en";

    /// <summary>False = account suspended (returns 403 even with correct password).</summary>
    public bool IsActive { get; set; } = true;

    public DateTime? LastLoginAt { get; set; }

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
  • Step 2: Create Entities/AppRole.cs
using Microsoft.AspNetCore.Identity;

namespace ROLAC.API.Entities;

public class AppRole : IdentityRole
{
    public string? Description { get; set; }
}
  • Step 3: Create Entities/RefreshToken.cs
namespace ROLAC.API.Entities;

public class RefreshToken
{
    public int Id { get; set; }

    public string UserId { get; set; } = null!;
    public AppUser User { get; set; } = null!;

    /// <summary>SHA-256 hex of the raw token sent to the client. Never store raw tokens.</summary>
    public string TokenHash { get; set; } = null!;

    public DateTime ExpiresAt { get; set; }
    public DateTime CreatedAt { get; set; }

    /// <summary>Set when this token is revoked (logout or rotation).</summary>
    public DateTime? RevokedAt { get; set; }

    /// <summary>Points to the hash of the token that replaced this one during rotation.</summary>
    public string? ReplacedByHash { get; set; }

    public string? DeviceInfo { get; set; }
    public string? IpAddress { get; set; }

    // Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
    public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
    public bool IsRevoked => RevokedAt.HasValue;
    public bool IsActive  => !IsRevoked && !IsExpired;
}
  • Step 4: Build to verify
dotnet build API/ROLAC.API/ROLAC.API.csproj

Expected: Build succeeded. 0 Error(s)

  • Step 5: Commit
git add API/ROLAC.API/Entities/
git commit -m "feat: add AppUser, AppRole, RefreshToken entities"

Task 3: Create AppDbContext and DbSeeder

Files:

  • Create API/ROLAC.API/Data/AppDbContext.cs

  • Create API/ROLAC.API/Data/DbSeeder.cs

  • Step 1: Create Data/AppDbContext.cs

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities;

namespace ROLAC.API.Data;

public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        builder.Entity<RefreshToken>(entity =>
        {
            entity.HasKey(e => e.Id);

            // Unique index on hash — enables fast lookup and prevents duplicate tokens
            entity.HasIndex(e => e.TokenHash).IsUnique();

            entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
            entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
            entity.Property(e => e.DeviceInfo).HasMaxLength(200);
            entity.Property(e => e.IpAddress).HasMaxLength(45);
            entity.Property(e => e.ReplacedByHash).HasMaxLength(64);

            entity.HasOne(e => e.User)
                  .WithMany(u => u.RefreshTokens)
                  .HasForeignKey(e => e.UserId)
                  .OnDelete(DeleteBehavior.Cascade);

            // Computed properties are not DB columns
            entity.Ignore(e => e.IsExpired);
            entity.Ignore(e => e.IsRevoked);
            entity.Ignore(e => e.IsActive);
        });

        builder.Entity<AppUser>(entity =>
        {
            entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
        });

        builder.Entity<AppRole>(entity =>
        {
            entity.Property(e => e.Description).HasMaxLength(500);
        });
    }
}
  • Step 2: Create Data/DbSeeder.cs
using Microsoft.AspNetCore.Identity;
using ROLAC.API.Entities;

namespace ROLAC.API.Data;

public static class DbSeeder
{
    private static readonly (string Name, string Description)[] Roles =
    [
        ("super_admin",     "System administrator — full access"),
        ("pastor",          "Pastor — full member and financial overview"),
        ("board_member",    "Board member — church governance"),
        ("coworker_chair",  "Coworker chair — coordinates ministry leaders"),
        ("ministry_leader", "Ministry leader — scoped to own ministry"),
        ("district_leader", "District leader — manages multiple cell groups"),
        ("cell_leader",     "Cell leader — scoped to own cell group"),
        ("coworker",        "Coworker — general worker in assigned ministry"),
        ("finance",         "Finance — manages giving and expense reports"),
        ("secretary",       "Secretary — manages member data and scheduling"),
        ("worship_leader",  "Worship leader — manages song library and setlists (Phase deferred)"),
        ("member",          "Member — views own profile and service roster"),
        ("visitor",         "Visitor — public pages only"),
    ];

    public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
    {
        foreach (var (name, description) in Roles)
        {
            if (!await roleManager.RoleExistsAsync(name))
            {
                await roleManager.CreateAsync(new AppRole
                {
                    Name        = name,
                    Description = description,
                });
            }
        }
    }

    /// <summary>
    /// Creates a super_admin test account for local development.
    /// DO NOT call this in production — remove or guard with IsDevelopment().
    /// Credentials: admin@rolac.org / Admin1234!
    /// </summary>
    public static async Task SeedAdminUserAsync(UserManager<AppUser> userManager)
    {
        const string adminEmail    = "admin@rolac.org";
        const string adminPassword = "Admin1234!";

        if (await userManager.FindByEmailAsync(adminEmail) is null)
        {
            var admin = new AppUser
            {
                UserName           = adminEmail,
                Email              = adminEmail,
                EmailConfirmed     = true,
                IsActive           = true,
                LanguagePreference = "en",
                CreatedAt          = DateTime.UtcNow,
            };

            var result = await userManager.CreateAsync(admin, adminPassword);
            if (result.Succeeded)
                await userManager.AddToRoleAsync(admin, "super_admin");
        }
    }
}
  • Step 3: Build to verify
dotnet build API/ROLAC.API/ROLAC.API.csproj

Expected: Build succeeded. 0 Error(s)

  • Step 4: Commit
git add API/ROLAC.API/Data/
git commit -m "feat: add AppDbContext (Identity + RefreshTokens) and DbSeeder (13 roles + dev admin)"

Task 4: Create DTOs

Files:

  • Create API/ROLAC.API/DTOs/Auth/LoginRequest.cs

  • Create API/ROLAC.API/DTOs/Auth/LoginResponse.cs

  • Step 1: Create DTOs/Auth/LoginRequest.cs

using System.ComponentModel.DataAnnotations;

namespace ROLAC.API.DTOs.Auth;

public class LoginRequest
{
    [Required]
    [EmailAddress]
    [MaxLength(256)]
    public string Email { get; set; } = null!;

    [Required]
    [MinLength(8)]
    [MaxLength(128)]
    public string Password { get; set; } = null!;
}
  • Step 2: Create DTOs/Auth/LoginResponse.cs
namespace ROLAC.API.DTOs.Auth;

public class LoginResponse
{
    /// <summary>Short-lived JWT (15 min). Store in memory — never in localStorage.</summary>
    public string AccessToken { get; set; } = null!;

    /// <summary>Seconds until the access token expires. Always 900 (15 × 60).</summary>
    public int ExpiresIn { get; set; }

    public UserInfo User { get; set; } = null!;
}

public class UserInfo
{
    public string Id                 { get; set; } = null!;
    public string Email              { get; set; } = null!;
    public IList<string> Roles       { get; set; } = [];
    public string LanguagePreference { get; set; } = "en";
}
  • Step 3: Commit
git add API/ROLAC.API/DTOs/
git commit -m "feat: add LoginRequest and LoginResponse DTOs"

Task 5: Create test project + TokenService (TDD)

Files:

  • Create API/ROLAC.API.Tests/ROLAC.API.Tests.csproj

  • Create API/ROLAC.API/Services/ITokenService.cs

  • Create API/ROLAC.API/Services/TokenService.cs

  • Create API/ROLAC.API.Tests/Services/TokenServiceTests.cs

  • Step 1: Create API/ROLAC.API.Tests/ROLAC.API.Tests.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="6.0.2">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Moq" Version="4.20.72" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Memory" Version="8.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\ROLAC.API\ROLAC.API.csproj" />
  </ItemGroup>

</Project>
  • Step 2: Add test project to solution
dotnet sln API/ROLAC.API/ROLAC.API.sln add API/ROLAC.API.Tests/ROLAC.API.Tests.csproj

Expected: Project 'API/ROLAC.API.Tests/ROLAC.API.Tests.csproj' added to the solution.

  • Step 3: Write failing tests for TokenService

Create API/ROLAC.API.Tests/Services/TokenServiceTests.cs:

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.Extensions.Configuration;
using ROLAC.API.Entities;
using ROLAC.API.Services;

namespace ROLAC.API.Tests.Services;

public class TokenServiceTests
{
    private readonly TokenService _sut;

    public TokenServiceTests()
    {
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                { "Jwt:SecretKey",                "test-secret-key-that-is-at-least-32-characters-long!!" },
                { "Jwt:Issuer",                   "rolac-api-test" },
                { "Jwt:Audience",                 "rolac-client-test" },
                { "Jwt:AccessTokenExpiryMinutes", "15" },
                { "Jwt:RefreshTokenExpiryDays",   "30" },
            })
            .Build();

        _sut = new TokenService(config);
    }

    [Fact]
    public void GenerateAccessToken_ReturnsValidJwt_WithUserIdAndRoles()
    {
        var user  = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
        var roles = new List<string> { "member", "finance" };

        var token = _sut.GenerateAccessToken(user, roles);

        Assert.NotEmpty(token);
        var handler = new JwtSecurityTokenHandler();
        var jwt     = handler.ReadJwtToken(token);

        Assert.Equal("user-123", jwt.Subject);
        Assert.Equal("test@rolac.org", jwt.Claims.First(c => c.Type == JwtRegisteredClaimNames.Email).Value);

        var roleClaims = jwt.Claims
            .Where(c => c.Type == ClaimTypes.Role)
            .Select(c => c.Value)
            .ToList();
        Assert.Contains("member",  roleClaims);
        Assert.Contains("finance", roleClaims);
    }

    [Fact]
    public void GenerateAccessToken_ExpiresIn15Minutes()
    {
        var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };

        var token = _sut.GenerateAccessToken(user, new List<string>());

        var handler       = new JwtSecurityTokenHandler();
        var jwt           = handler.ReadJwtToken(token);
        var expectedExpiry = DateTime.UtcNow.AddMinutes(15);

        Assert.True((jwt.ValidTo - expectedExpiry).Duration() < TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void GenerateRefreshToken_Returns64ByteBase64String()
    {
        var token = _sut.GenerateRefreshToken();

        Assert.NotEmpty(token);
        var bytes = Convert.FromBase64String(token);
        Assert.Equal(64, bytes.Length);
    }

    [Fact]
    public void GenerateRefreshToken_ProducesUniqueTokensEachCall()
    {
        var token1 = _sut.GenerateRefreshToken();
        var token2 = _sut.GenerateRefreshToken();

        Assert.NotEqual(token1, token2);
    }

    [Fact]
    public void HashToken_SameInputProducesSameHash()
    {
        const string raw = "some-raw-token-value";

        var hash1 = _sut.HashToken(raw);
        var hash2 = _sut.HashToken(raw);

        Assert.Equal(hash1, hash2);
    }

    [Fact]
    public void HashToken_DifferentInputsProduceDifferentHashes()
    {
        var hash1 = _sut.HashToken("token-a");
        var hash2 = _sut.HashToken("token-b");

        Assert.NotEqual(hash1, hash2);
    }

    [Fact]
    public void HashToken_Returns64CharLowercaseHexString()
    {
        var hash = _sut.HashToken("any-token");

        Assert.Equal(64, hash.Length);
        Assert.True(hash.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')));
    }
}
  • Step 4: Run build — expect failure (TokenService doesn't exist yet)
dotnet build API/ROLAC.API.Tests/ROLAC.API.Tests.csproj

Expected: Build failure — The type or namespace name 'TokenService' could not be found

  • Step 5: Create Services/ITokenService.cs
using ROLAC.API.Entities;

namespace ROLAC.API.Services;

public interface ITokenService
{
    /// <summary>Generates a signed HS256 JWT containing userId, email, and roles claims.</summary>
    string GenerateAccessToken(AppUser user, IList<string> roles);

    /// <summary>Generates a cryptographically-random 64-byte base64 string (the raw token value).</summary>
    string GenerateRefreshToken();

    /// <summary>Returns the SHA-256 hex hash of the raw token. Always hash before storing to DB.</summary>
    string HashToken(string rawToken);
}
  • Step 6: Create Services/TokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using ROLAC.API.Entities;

namespace ROLAC.API.Services;

public class TokenService : ITokenService
{
    private readonly IConfiguration _config;

    public TokenService(IConfiguration config)
    {
        _config = config;
    }

    public string GenerateAccessToken(AppUser user, IList<string> roles)
    {
        var secretKey = _config["Jwt:SecretKey"]!;
        var issuer    = _config["Jwt:Issuer"]!;
        var audience  = _config["Jwt:Audience"]!;
        var expiryMin = int.Parse(_config["Jwt:AccessTokenExpiryMinutes"]!);

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub,   user.Id),
            new(JwtRegisteredClaimNames.Email, user.Email!),
            new(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString()),
        };

        foreach (var role in roles)
            claims.Add(new Claim(ClaimTypes.Role, role));

        var key   = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer:             issuer,
            audience:           audience,
            claims:             claims,
            expires:            DateTime.UtcNow.AddMinutes(expiryMin),
            signingCredentials: creds);

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

    public string GenerateRefreshToken()
    {
        var bytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(bytes);
        return Convert.ToBase64String(bytes);
    }

    public string HashToken(string rawToken)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
        return Convert.ToHexString(bytes).ToLower();
    }
}
  • Step 7: Run TokenService tests — expect all 7 pass
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter "FullyQualifiedName~TokenServiceTests" -v normal

Expected:

Test Run Successful.
Total tests: 7     Passed: 7
  • Step 8: Commit
git add API/ROLAC.API/Services/ITokenService.cs API/ROLAC.API/Services/TokenService.cs `
       API/ROLAC.API.Tests/ API/ROLAC.API/ROLAC.API.sln
git commit -m "feat: add TokenService with JWT generation and refresh token hashing (TDD, 7 tests)"

Task 6: Create AuthService (TDD)

Files:

  • Create API/ROLAC.API.Tests/Services/AuthServiceTests.cs

  • Create API/ROLAC.API/Services/IAuthService.cs

  • Create API/ROLAC.API/Services/AuthService.cs

  • Step 1: Write failing tests for AuthService

Create API/ROLAC.API.Tests/Services/AuthServiceTests.cs:

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Entities;
using ROLAC.API.Services;

namespace ROLAC.API.Tests.Services;

public class AuthServiceTests : IDisposable
{
    private readonly Mock<UserManager<AppUser>> _userManagerMock;
    private readonly AppDbContext _context;
    private readonly Mock<ITokenService> _tokenServiceMock;
    private readonly IConfiguration _config;
    private readonly AuthService _sut;

    public AuthServiceTests()
    {
        // UserManager requires IUserStore; all other deps can be null for unit tests
        var store = new Mock<IUserStore<AppUser>>();
        _userManagerMock = new Mock<UserManager<AppUser>>(
            store.Object, null, null, null, null, null, null, null, null);

        // Fresh in-memory DB per test class instance — prevents cross-test pollution
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;
        _context = new AppDbContext(options);

        _tokenServiceMock = new Mock<ITokenService>();

        _config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                { "Jwt:RefreshTokenExpiryDays", "30" },
            })
            .Build();

        _sut = new AuthService(_userManagerMock.Object, _context, _tokenServiceMock.Object, _config);
    }

    public void Dispose() => _context.Dispose();

    // ── LoginAsync ─────────────────────────────────────────────────────────────

    [Fact]
    public async Task LoginAsync_ValidCredentials_ReturnsTokensAndPersistsRefreshToken()
    {
        var user = new AppUser
        {
            Id       = "u1",
            Email    = "alice@rolac.org",
            UserName = "alice@rolac.org",
            IsActive = true,
        };

        _userManagerMock.Setup(m => m.FindByEmailAsync("alice@rolac.org")).ReturnsAsync(user);
        _userManagerMock.Setup(m => m.CheckPasswordAsync(user, "Password1!")).ReturnsAsync(true);
        _userManagerMock.Setup(m => m.IsLockedOutAsync(user)).ReturnsAsync(false);
        _userManagerMock.Setup(m => m.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
        _userManagerMock.Setup(m => m.GetRolesAsync(user)).ReturnsAsync(new List<string> { "member" });
        _userManagerMock.Setup(m => m.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);

        _tokenServiceMock.Setup(t => t.GenerateAccessToken(user, It.IsAny<IList<string>>())).Returns("access.token.value");
        _tokenServiceMock.Setup(t => t.GenerateRefreshToken()).Returns("raw-refresh-token");
        _tokenServiceMock.Setup(t => t.HashToken("raw-refresh-token")).Returns("hashed-token-64chars");

        var result = await _sut.LoginAsync("alice@rolac.org", "Password1!", "127.0.0.1", "TestAgent/1.0");

        Assert.True(result.Success);
        Assert.Equal("access.token.value", result.AccessToken);
        Assert.Equal("raw-refresh-token",  result.RefreshToken);
        Assert.Null(result.Error);

        // Refresh token must be persisted to DB as a hash
        var stored = await _context.RefreshTokens.SingleAsync();
        Assert.Equal("hashed-token-64chars", stored.TokenHash);
        Assert.Equal("u1", stored.UserId);
        Assert.Equal("127.0.0.1", stored.IpAddress);
    }

    [Fact]
    public async Task LoginAsync_UserNotFound_ReturnsFailure()
    {
        _userManagerMock.Setup(m => m.FindByEmailAsync("nobody@rolac.org")).ReturnsAsync((AppUser?)null);

        var result = await _sut.LoginAsync("nobody@rolac.org", "Password1!", null, null);

        Assert.False(result.Success);
        Assert.Equal("Invalid credentials", result.Error);
    }

    [Fact]
    public async Task LoginAsync_InactiveUser_ReturnsFailureWithoutCheckingPassword()
    {
        var user = new AppUser { Id = "u2", Email = "inactive@rolac.org", UserName = "inactive@rolac.org", IsActive = false };
        _userManagerMock.Setup(m => m.FindByEmailAsync("inactive@rolac.org")).ReturnsAsync(user);

        var result = await _sut.LoginAsync("inactive@rolac.org", "Password1!", null, null);

        Assert.False(result.Success);
        Assert.Equal("Invalid credentials", result.Error);
        // CheckPasswordAsync must never be called for inactive users
        _userManagerMock.Verify(m => m.CheckPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>()), Times.Never);
    }

    [Fact]
    public async Task LoginAsync_WrongPassword_IncrementsFailCountAndReturnsFailure()
    {
        var user = new AppUser { Id = "u3", Email = "bob@rolac.org", UserName = "bob@rolac.org", IsActive = true };
        _userManagerMock.Setup(m => m.FindByEmailAsync("bob@rolac.org")).ReturnsAsync(user);
        _userManagerMock.Setup(m => m.CheckPasswordAsync(user, "WrongPass!")).ReturnsAsync(false);
        _userManagerMock.Setup(m => m.AccessFailedAsync(user)).ReturnsAsync(IdentityResult.Success);

        var result = await _sut.LoginAsync("bob@rolac.org", "WrongPass!", null, null);

        Assert.False(result.Success);
        Assert.Equal("Invalid credentials", result.Error);
        _userManagerMock.Verify(m => m.AccessFailedAsync(user), Times.Once);
    }

    [Fact]
    public async Task LoginAsync_LockedOutUser_ReturnsLockoutError()
    {
        var user = new AppUser { Id = "u4", Email = "locked@rolac.org", UserName = "locked@rolac.org", IsActive = true };
        _userManagerMock.Setup(m => m.FindByEmailAsync("locked@rolac.org")).ReturnsAsync(user);
        _userManagerMock.Setup(m => m.CheckPasswordAsync(user, "Password1!")).ReturnsAsync(true);
        _userManagerMock.Setup(m => m.IsLockedOutAsync(user)).ReturnsAsync(true);

        var result = await _sut.LoginAsync("locked@rolac.org", "Password1!", null, null);

        Assert.False(result.Success);
        Assert.Equal("Account is locked. Try again later.", result.Error);
    }

    // ── RefreshAsync ────────────────────────────────────────────────────────────

    [Fact]
    public async Task RefreshAsync_ValidToken_RotatesTokenAndReturnsNewPair()
    {
        var user = new AppUser { Id = "u5", Email = "carol@rolac.org", UserName = "carol@rolac.org", IsActive = true };
        _context.Users.Add(user);

        var existing = new RefreshToken
        {
            UserId    = "u5",
            User      = user,
            TokenHash = "existing-hash",
            ExpiresAt = DateTime.UtcNow.AddDays(25),
            CreatedAt = DateTime.UtcNow.AddDays(-5),
        };
        _context.RefreshTokens.Add(existing);
        await _context.SaveChangesAsync();

        _tokenServiceMock.Setup(t => t.HashToken("raw-existing")).Returns("existing-hash");
        _tokenServiceMock.Setup(t => t.GenerateRefreshToken()).Returns("new-raw-refresh");
        _tokenServiceMock.Setup(t => t.HashToken("new-raw-refresh")).Returns("new-hash");
        _tokenServiceMock.Setup(t => t.GenerateAccessToken(It.IsAny<AppUser>(), It.IsAny<IList<string>>())).Returns("new.access.token");
        _userManagerMock.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>())).ReturnsAsync(new List<string> { "member" });

        var result = await _sut.RefreshAsync("raw-existing", "127.0.0.1");

        Assert.True(result.Success);
        Assert.Equal("new.access.token",  result.AccessToken);
        Assert.Equal("new-raw-refresh",   result.NewRefreshToken);

        // Old token revoked
        Assert.NotNull(existing.RevokedAt);
        Assert.Equal("new-hash", existing.ReplacedByHash);

        // New token persisted
        var newToken = await _context.RefreshTokens.SingleAsync(t => t.TokenHash == "new-hash");
        Assert.Equal("u5", newToken.UserId);
    }

    [Fact]
    public async Task RefreshAsync_RevokedToken_ReturnsFailure()
    {
        var user = new AppUser { Id = "u6", Email = "dave@rolac.org", UserName = "dave@rolac.org" };
        _context.Users.Add(user);

        var revoked = new RefreshToken
        {
            UserId    = "u6",
            User      = user,
            TokenHash = "revoked-hash",
            ExpiresAt = DateTime.UtcNow.AddDays(20),
            CreatedAt = DateTime.UtcNow.AddDays(-1),
            RevokedAt = DateTime.UtcNow.AddHours(-1),  // already revoked
        };
        _context.RefreshTokens.Add(revoked);
        await _context.SaveChangesAsync();

        _tokenServiceMock.Setup(t => t.HashToken("raw-revoked")).Returns("revoked-hash");

        var result = await _sut.RefreshAsync("raw-revoked", null);

        Assert.False(result.Success);
        Assert.Equal("Invalid or expired refresh token", result.Error);
    }

    // ── LogoutAsync ─────────────────────────────────────────────────────────────

    [Fact]
    public async Task LogoutAsync_ActiveToken_RevokesTokenAndReturnsTrue()
    {
        var user = new AppUser { Id = "u7", Email = "eve@rolac.org", UserName = "eve@rolac.org" };
        _context.Users.Add(user);

        var active = new RefreshToken
        {
            UserId    = "u7",
            User      = user,
            TokenHash = "active-hash",
            ExpiresAt = DateTime.UtcNow.AddDays(29),
            CreatedAt = DateTime.UtcNow,
        };
        _context.RefreshTokens.Add(active);
        await _context.SaveChangesAsync();

        _tokenServiceMock.Setup(t => t.HashToken("raw-active")).Returns("active-hash");

        var result = await _sut.LogoutAsync("raw-active");

        Assert.True(result);
        Assert.NotNull(active.RevokedAt);
    }

    [Fact]
    public async Task LogoutAsync_UnknownToken_ReturnsFalse()
    {
        _tokenServiceMock.Setup(t => t.HashToken("unknown-raw")).Returns("unknown-hash");

        var result = await _sut.LogoutAsync("unknown-raw");

        Assert.False(result);
    }
}
  • Step 2: Run build — expect failure (AuthService doesn't exist yet)
dotnet build API/ROLAC.API.Tests/ROLAC.API.Tests.csproj

Expected: Build failure — The type or namespace name 'AuthService' could not be found

  • Step 3: Create Services/IAuthService.cs
namespace ROLAC.API.Services;

public interface IAuthService
{
    /// <summary>
    /// Validates credentials and issues access + refresh tokens.
    /// Returns (Success=false, Error=message) on any failure — same message for user-not-found
    /// and wrong-password to prevent user enumeration.
    /// </summary>
    Task<(bool Success, string? AccessToken, string? RefreshToken, string? Error)> LoginAsync(
        string email, string password, string? ipAddress, string? deviceInfo);

    /// <summary>
    /// Validates the raw refresh token, revokes it, and issues a rotated replacement.
    /// </summary>
    Task<(bool Success, string? AccessToken, string? NewRefreshToken, string? Error)> RefreshAsync(
        string rawRefreshToken, string? ipAddress);

    /// <summary>
    /// Revokes the given refresh token. Returns false if not found or already inactive.
    /// </summary>
    Task<bool> LogoutAsync(string rawRefreshToken);
}
  • Step 4: Create Services/AuthService.cs
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Entities;

namespace ROLAC.API.Services;

public class AuthService : IAuthService
{
    private readonly UserManager<AppUser> _userManager;
    private readonly AppDbContext         _context;
    private readonly ITokenService        _tokenService;
    private readonly IConfiguration       _config;

    public AuthService(
        UserManager<AppUser> userManager,
        AppDbContext         context,
        ITokenService        tokenService,
        IConfiguration       config)
    {
        _userManager  = userManager;
        _context      = context;
        _tokenService = tokenService;
        _config       = config;
    }

    public async Task<(bool Success, string? AccessToken, string? RefreshToken, string? Error)> LoginAsync(
        string email, string password, string? ipAddress, string? deviceInfo)
    {
        var user = await _userManager.FindByEmailAsync(email);

        // Return the same message for "not found" and "inactive" to prevent user enumeration
        if (user is null || !user.IsActive)
            return (false, null, null, "Invalid credentials");

        var passwordValid = await _userManager.CheckPasswordAsync(user, password);
        if (!passwordValid)
        {
            await _userManager.AccessFailedAsync(user);
            return (false, null, null, "Invalid credentials");
        }

        if (await _userManager.IsLockedOutAsync(user))
            return (false, null, null, "Account is locked. Try again later.");

        await _userManager.ResetAccessFailedCountAsync(user);

        var roles       = await _userManager.GetRolesAsync(user);
        var accessToken = _tokenService.GenerateAccessToken(user, roles);
        var rawToken    = _tokenService.GenerateRefreshToken();
        var tokenHash   = _tokenService.HashToken(rawToken);
        var expiryDays  = int.Parse(_config["Jwt:RefreshTokenExpiryDays"]!);

        _context.RefreshTokens.Add(new RefreshToken
        {
            UserId     = user.Id,
            User       = user,
            TokenHash  = tokenHash,
            ExpiresAt  = DateTime.UtcNow.AddDays(expiryDays),
            CreatedAt  = DateTime.UtcNow,
            IpAddress  = ipAddress,
            DeviceInfo = deviceInfo,
        });

        user.LastLoginAt = DateTime.UtcNow;
        await _userManager.UpdateAsync(user);
        await _context.SaveChangesAsync();

        return (true, accessToken, rawToken, null);
    }

    public async Task<(bool Success, string? AccessToken, string? NewRefreshToken, string? Error)> RefreshAsync(
        string rawRefreshToken, string? ipAddress)
    {
        var tokenHash = _tokenService.HashToken(rawRefreshToken);

        var stored = await _context.RefreshTokens
            .Include(rt => rt.User)
            .FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash);

        if (stored is null || !stored.IsActive)
            return (false, null, null, "Invalid or expired refresh token");

        // Rotate: revoke old token, issue replacement
        var newRaw     = _tokenService.GenerateRefreshToken();
        var newHash    = _tokenService.HashToken(newRaw);
        var expiryDays = int.Parse(_config["Jwt:RefreshTokenExpiryDays"]!);

        stored.RevokedAt      = DateTime.UtcNow;
        stored.ReplacedByHash = newHash;

        var roles       = await _userManager.GetRolesAsync(stored.User);
        var accessToken = _tokenService.GenerateAccessToken(stored.User, roles);

        _context.RefreshTokens.Add(new RefreshToken
        {
            UserId    = stored.UserId,
            User      = stored.User,
            TokenHash = newHash,
            ExpiresAt = DateTime.UtcNow.AddDays(expiryDays),
            CreatedAt = DateTime.UtcNow,
            IpAddress = ipAddress,
        });

        await _context.SaveChangesAsync();

        return (true, accessToken, newRaw, null);
    }

    public async Task<bool> LogoutAsync(string rawRefreshToken)
    {
        var tokenHash = _tokenService.HashToken(rawRefreshToken);

        var stored = await _context.RefreshTokens
            .FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash);

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

        stored.RevokedAt = DateTime.UtcNow;
        await _context.SaveChangesAsync();
        return true;
    }
}
  • Step 5: Run AuthService tests — expect all 9 pass
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter "FullyQualifiedName~AuthServiceTests" -v normal

Expected:

Test Run Successful.
Total tests: 9     Passed: 9
  • Step 6: Run full suite — expect all 16 pass
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -v normal

Expected:

Test Run Successful.
Total tests: 16     Passed: 16
  • Step 7: Commit
git add API/ROLAC.API/Services/IAuthService.cs API/ROLAC.API/Services/AuthService.cs `
       API/ROLAC.API.Tests/Services/AuthServiceTests.cs
git commit -m "feat: add AuthService with login/refresh/logout logic (TDD, 16 tests passing)"

Task 7: Create AuthController

Files: Create API/ROLAC.API/Controllers/AuthController.cs

  • Step 1: Create Controllers/AuthController.cs
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
using ROLAC.API.Services;

namespace ROLAC.API.Controllers;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly IAuthService         _authService;
    private readonly UserManager<AppUser> _userManager;
    private const string CookieName = "rolac_rt";

    public AuthController(IAuthService authService, UserManager<AppUser> userManager)
    {
        _authService = authService;
        _userManager = userManager;
    }

    /// <summary>
    /// Authenticate with email and password.
    /// Returns a 15-minute JWT access token in the response body and a 30-day
    /// refresh token in an HttpOnly cookie named <c>rolac_rt</c>.
    /// </summary>
    [HttpPost("login")]
    [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    public async Task<IActionResult> Login([FromBody] LoginRequest request)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);

        var ipAddress  = HttpContext.Connection.RemoteIpAddress?.ToString();
        var deviceInfo = Request.Headers.UserAgent.ToString();

        var (success, accessToken, refreshToken, error) =
            await _authService.LoginAsync(request.Email, request.Password, ipAddress, deviceInfo);

        if (!success)
            return Unauthorized(new { message = error });

        var user  = await _userManager.FindByEmailAsync(request.Email);
        var roles = await _userManager.GetRolesAsync(user!);

        SetRefreshTokenCookie(refreshToken!);

        return Ok(new LoginResponse
        {
            AccessToken = accessToken!,
            ExpiresIn   = 900,
            User = new UserInfo
            {
                Id                 = user!.Id,
                Email              = user.Email!,
                Roles              = roles,
                LanguagePreference = user.LanguagePreference,
            },
        });
    }

    /// <summary>
    /// Exchange the refresh token cookie for a new access token.
    /// The old refresh token is revoked and a new one is set in the cookie (rotation).
    /// </summary>
    [HttpPost("refresh")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    public async Task<IActionResult> Refresh()
    {
        var rawToken = Request.Cookies[CookieName];
        if (string.IsNullOrEmpty(rawToken))
            return Unauthorized(new { message = "No refresh token provided" });

        var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();

        var (success, accessToken, newRefreshToken, error) =
            await _authService.RefreshAsync(rawToken, ipAddress);

        if (!success)
        {
            ClearRefreshTokenCookie();
            return Unauthorized(new { message = error });
        }

        SetRefreshTokenCookie(newRefreshToken!);

        return Ok(new { accessToken, expiresIn = 900 });
    }

    /// <summary>
    /// Revoke the current refresh token and clear the cookie.
    /// Safe to call even when no cookie is present.
    /// </summary>
    [HttpPost("logout")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    public async Task<IActionResult> Logout()
    {
        var rawToken = Request.Cookies[CookieName];
        if (!string.IsNullOrEmpty(rawToken))
            await _authService.LogoutAsync(rawToken);

        ClearRefreshTokenCookie();
        return NoContent();
    }

    private void SetRefreshTokenCookie(string rawToken)
    {
        Response.Cookies.Append(CookieName, rawToken, new CookieOptions
        {
            HttpOnly = true,
            Secure   = true,
            SameSite = SameSiteMode.Strict,
            Expires  = DateTimeOffset.UtcNow.AddDays(30),
        });
    }

    private void ClearRefreshTokenCookie()
    {
        Response.Cookies.Delete(CookieName, new CookieOptions
        {
            HttpOnly = true,
            Secure   = true,
            SameSite = SameSiteMode.Strict,
        });
    }
}
  • Step 2: Build to verify
dotnet build API/ROLAC.API/ROLAC.API.csproj

Expected: Build succeeded. 0 Error(s)

  • Step 3: Commit
git add API/ROLAC.API/Controllers/AuthController.cs
git commit -m "feat: add AuthController with POST /api/auth/login, /refresh, /logout"

Task 8: Configure appsettings

Files:

  • Modify API/ROLAC.API/appsettings.json

  • Modify API/ROLAC.API/appsettings.Development.json

  • Step 1: Replace appsettings.json — non-secret values only

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Jwt": {
    "Issuer": "rolac-api",
    "Audience": "rolac-client",
    "AccessTokenExpiryMinutes": "15",
    "RefreshTokenExpiryDays": "30"
  }
}
  • Step 2: Replace appsettings.Development.json — local dev secrets
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Port=5432;Database=rolac_dev;Username=postgres;Password=YOUR_LOCAL_POSTGRES_PASSWORD"
  },
  "Jwt": {
    "SecretKey": "dev-only-secret-key-must-be-at-least-32-chars-long-change-in-prod!!"
  }
}

⚠️ Replace YOUR_LOCAL_POSTGRES_PASSWORD with your actual local PostgreSQL password.
⚠️ This file must NOT be committed. Verify it is gitignored before proceeding.

  • Step 3: Ensure appsettings.Development.json is gitignored
Select-String "appsettings.Development" .gitignore

If the output is empty, add it:

Add-Content .gitignore "`nAPI/ROLAC.API/appsettings.Development.json"

Then verify:

git status API/ROLAC.API/appsettings.Development.json

Expected: nothing to commit (file is ignored)

  • Step 4: Commit only the non-secret file
git add API/ROLAC.API/appsettings.json .gitignore
git commit -m "config: add JWT non-secret settings; gitignore appsettings.Development.json"

Task 9: Wire up Program.cs

Files: Replace API/ROLAC.API/Program.cs

  • Step 1: Replace the full content of Program.cs
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using ROLAC.API.Data;
using ROLAC.API.Entities;
using ROLAC.API.Services;

var builder = WebApplication.CreateBuilder(args);

// ── Database ──────────────────────────────────────────────────────────────────
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

// ── ASP.NET Core Identity ─────────────────────────────────────────────────────
builder.Services.AddIdentity<AppUser, AppRole>(options =>
{
    options.Password.RequireDigit           = true;
    options.Password.RequiredLength         = 8;
    options.Password.RequireUppercase       = true;
    options.Password.RequireLowercase       = true;
    options.Password.RequireNonAlphanumeric = false;
    options.Lockout.DefaultLockoutTimeSpan  = TimeSpan.FromMinutes(5);
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.AllowedForNewUsers      = true;
    options.User.RequireUniqueEmail         = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();

// ── JWT Authentication ────────────────────────────────────────────────────────
var jwtSection = builder.Configuration.GetSection("Jwt");
var secretKey  = jwtSection["SecretKey"]
    ?? throw new InvalidOperationException("Jwt:SecretKey is not configured. Add it to appsettings.Development.json or environment variables.");

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme    = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer           = true,
        ValidateAudience         = true,
        ValidateLifetime         = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer              = jwtSection["Issuer"],
        ValidAudience            = jwtSection["Audience"],
        IssuerSigningKey         = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
        ClockSkew                = TimeSpan.Zero,  // tokens expire exactly on time — no grace period
    };
});

// ── CORS ──────────────────────────────────────────────────────────────────────
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowAngularApp", policy =>
    {
        policy
            .WithOrigins("http://localhost:4200", "https://app.rolac.org")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();  // Required — Angular must send HttpOnly cookie on /refresh
    });
});

// ── Application Services ──────────────────────────────────────────────────────
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();

// ── Controllers + Swagger ─────────────────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "ROLAC API", Version = "v1" });

    // Allow pasting a Bearer token in Swagger UI to test protected endpoints
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Description = "Enter: Bearer {your JWT}",
        Name        = "Authorization",
        In          = ParameterLocation.Header,
        Type        = SecuritySchemeType.ApiKey,
        Scheme      = "Bearer",
    });
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
            },
            Array.Empty<string>()
        }
    });
});

var app = builder.Build();

// ── Startup: migrate DB + seed roles + seed dev admin ────────────────────────
using (var scope = app.Services.CreateScope())
{
    var db          = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<AppRole>>();
    var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();

    await db.Database.MigrateAsync();           // applies pending EF migrations
    await DbSeeder.SeedRolesAsync(roleManager); // ensures all 13 RBAC roles exist

    if (app.Environment.IsDevelopment())
        await DbSeeder.SeedAdminUserAsync(userManager); // admin@rolac.org / Admin1234!
}

// ── HTTP Pipeline ─────────────────────────────────────────────────────────────
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseCors("AllowAngularApp");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();
  • Step 2: Build to verify
dotnet build API/ROLAC.API/ROLAC.API.csproj

Expected: Build succeeded. 0 Error(s)

  • Step 3: Commit
git add API/ROLAC.API/Program.cs
git commit -m "feat: wire Identity, JWT, CORS, DI, and startup seeding in Program.cs"

Task 10: Create and apply EF Core migration

Pre-requisite: PostgreSQL must be running locally with the credentials matching appsettings.Development.json.

  • Step 1: Verify dotnet-ef tool is installed
dotnet ef --version

If not found:

dotnet tool install --global dotnet-ef --version 8.0.11
  • Step 2: Create the initial migration
dotnet ef migrations add InitialAuth `
  --project API/ROLAC.API/ROLAC.API.csproj `
  --startup-project API/ROLAC.API/ROLAC.API.csproj `
  --output-dir Data/Migrations

Expected: Done. To undo this action, use 'ef migrations remove'

This creates three files in API/ROLAC.API/Data/Migrations/.

  • Step 3: Verify migration content

Open the generated <timestamp>_InitialAuth.cs. It should create:

  • AspNetUsers — with MemberId, LanguagePreference, IsActive, LastLoginAt, CreatedAt columns
  • AspNetRoles — with Description column
  • AspNetUserRoles, AspNetUserClaims, AspNetRoleClaims, AspNetUserLogins, AspNetUserTokens
  • RefreshTokens — with TokenHash unique index, UserId FK → AspNetUsers

If any of the above are missing, the entities were not picked up correctly — re-check the namespace and that AppDbContext compiles without errors.

  • Step 4: Apply migration to local PostgreSQL
dotnet ef database update `
  --project API/ROLAC.API/ROLAC.API.csproj `
  --startup-project API/ROLAC.API/ROLAC.API.csproj

Expected:

Build succeeded.
Applying migration '<timestamp>_InitialAuth'.
Done.
  • Step 5: Commit migration files
git add API/ROLAC.API/Data/Migrations/
git commit -m "feat: add InitialAuth EF Core migration (Identity tables + RefreshTokens)"

Task 11: Smoke test with Swagger UI

  • Step 1: Start the API
dotnet run --project API/ROLAC.API/ROLAC.API.csproj

Expected startup output (port numbers may vary):

info: Applying migration 'InitialAuth'.         ← skipped on subsequent runs
info: Now listening on: https://localhost:7001
info: Now listening on: http://localhost:5001
  • Step 2: Open Swagger UI

Navigate to https://localhost:7001/swagger in a browser.

You should see three endpoints under Auth:

  • POST /api/auth/login

  • POST /api/auth/refresh

  • POST /api/auth/logout

  • Step 3: Test successful login

Expand POST /api/auth/loginTry it out → paste:

{
  "email": "admin@rolac.org",
  "password": "Admin1234!"
}

Click Execute.

Expected: 200 OK with body:

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expiresIn": 900,
  "user": {
    "id": "<guid>",
    "email": "admin@rolac.org",
    "roles": ["super_admin"],
    "languagePreference": "en"
  }
}

The response Headers should include Set-Cookie: rolac_rt=...

  • Step 4: Test token refresh

Call POST /api/auth/refresh (no body needed — the browser sends the cookie).

Expected: 200 OK with a new accessToken.

  • Step 5: Test logout

Call POST /api/auth/logout.

Expected: 204 No Content. The rolac_rt cookie is cleared.

  • Step 6: Test wrong password returns 401

Call POST /api/auth/login with:

{ "email": "admin@rolac.org", "password": "WrongPassword!" }

Expected: 401 Unauthorized:

{ "message": "Invalid credentials" }
  • Step 7: Run full test suite one final time
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -v normal

Expected:

Test Run Successful.
Total tests: 16     Passed: 16

Summary

When complete, the ROLAC API will have:

Endpoint Method Auth Cookie
/api/auth/login POST None Sets rolac_rt
/api/auth/refresh POST None (reads cookie) Rotates rolac_rt
/api/auth/logout POST None (reads cookie) Clears rolac_rt

Security properties:

  • Refresh tokens never stored in plaintext — SHA-256 hashed in DB
  • Token rotation on every refresh — stolen tokens have a window of at most one use before detection
  • Account lockout after 5 failed attempts (5-minute lockout)
  • IsActive = false accounts blocked regardless of correct password
  • CORS configured for Angular dev (localhost:4200) and production (app.rolac.org)
  • 16 unit tests covering all success and failure paths