55 KiB
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.jsonis 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— withMemberId,LanguagePreference,IsActive,LastLoginAt,CreatedAtcolumnsAspNetRoles— withDescriptioncolumnAspNetUserRoles,AspNetUserClaims,AspNetRoleClaims,AspNetUserLogins,AspNetUserTokensRefreshTokens— withTokenHashunique index,UserIdFK →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/login → Try 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 = falseaccounts 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