# 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` + `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`: ```powershell 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** ```powershell 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** ```powershell 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`** ```csharp using Microsoft.AspNetCore.Identity; namespace ROLAC.API.Entities; public class AppUser : IdentityUser { /// Links this login account to a church member record. Null for admin-only accounts. public int? MemberId { get; set; } /// UI language preference: 'en' or 'zh-TW'. public string LanguagePreference { get; set; } = "en"; /// False = account suspended (returns 403 even with correct password). public bool IsActive { get; set; } = true; public DateTime? LastLoginAt { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public ICollection RefreshTokens { get; set; } = new List(); } ``` - [ ] **Step 2: Create `Entities/AppRole.cs`** ```csharp using Microsoft.AspNetCore.Identity; namespace ROLAC.API.Entities; public class AppRole : IdentityRole { public string? Description { get; set; } } ``` - [ ] **Step 3: Create `Entities/RefreshToken.cs`** ```csharp namespace ROLAC.API.Entities; public class RefreshToken { public int Id { get; set; } public string UserId { get; set; } = null!; public AppUser User { get; set; } = null!; /// SHA-256 hex of the raw token sent to the client. Never store raw tokens. public string TokenHash { get; set; } = null!; public DateTime ExpiresAt { get; set; } public DateTime CreatedAt { get; set; } /// Set when this token is revoked (logout or rotation). public DateTime? RevokedAt { get; set; } /// Points to the hash of the token that replaced this one during rotation. 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** ```powershell dotnet build API/ROLAC.API/ROLAC.API.csproj ``` Expected: `Build succeeded. 0 Error(s)` - [ ] **Step 5: Commit** ```powershell 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`** ```csharp using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using ROLAC.API.Entities; namespace ROLAC.API.Data; public class AppDbContext : IdentityDbContext { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet RefreshTokens => Set(); protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.Entity(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(entity => { entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en"); }); builder.Entity(entity => { entity.Property(e => e.Description).HasMaxLength(500); }); } } ``` - [ ] **Step 2: Create `Data/DbSeeder.cs`** ```csharp 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 roleManager) { foreach (var (name, description) in Roles) { if (!await roleManager.RoleExistsAsync(name)) { await roleManager.CreateAsync(new AppRole { Name = name, Description = description, }); } } } /// /// 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! /// public static async Task SeedAdminUserAsync(UserManager 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** ```powershell dotnet build API/ROLAC.API/ROLAC.API.csproj ``` Expected: `Build succeeded. 0 Error(s)` - [ ] **Step 4: Commit** ```powershell 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`** ```csharp 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`** ```csharp namespace ROLAC.API.DTOs.Auth; public class LoginResponse { /// Short-lived JWT (15 min). Store in memory — never in localStorage. public string AccessToken { get; set; } = null!; /// Seconds until the access token expires. Always 900 (15 × 60). 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 Roles { get; set; } = []; public string LanguagePreference { get; set; } = "en"; } ``` - [ ] **Step 3: Commit** ```powershell 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`** ```xml net8.0 enable enable false true runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive all ``` - [ ] **Step 2: Add test project to solution** ```powershell 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`: ```csharp 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 { { "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 { "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()); 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)** ```powershell 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`** ```csharp using ROLAC.API.Entities; namespace ROLAC.API.Services; public interface ITokenService { /// Generates a signed HS256 JWT containing userId, email, and roles claims. string GenerateAccessToken(AppUser user, IList roles); /// Generates a cryptographically-random 64-byte base64 string (the raw token value). string GenerateRefreshToken(); /// Returns the SHA-256 hex hash of the raw token. Always hash before storing to DB. string HashToken(string rawToken); } ``` - [ ] **Step 6: Create `Services/TokenService.cs`** ```csharp 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 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 { 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** ```powershell 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** ```powershell 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`: ```csharp 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> _userManagerMock; private readonly AppDbContext _context; private readonly Mock _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>(); _userManagerMock = new Mock>( 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() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; _context = new AppDbContext(options); _tokenServiceMock = new Mock(); _config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "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 { "member" }); _userManagerMock.Setup(m => m.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); _tokenServiceMock.Setup(t => t.GenerateAccessToken(user, It.IsAny>())).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(), It.IsAny()), 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(), It.IsAny>())).Returns("new.access.token"); _userManagerMock.Setup(m => m.GetRolesAsync(It.IsAny())).ReturnsAsync(new List { "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)** ```powershell 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`** ```csharp namespace ROLAC.API.Services; public interface IAuthService { /// /// 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. /// Task<(bool Success, string? AccessToken, string? RefreshToken, string? Error)> LoginAsync( string email, string password, string? ipAddress, string? deviceInfo); /// /// Validates the raw refresh token, revokes it, and issues a rotated replacement. /// Task<(bool Success, string? AccessToken, string? NewRefreshToken, string? Error)> RefreshAsync( string rawRefreshToken, string? ipAddress); /// /// Revokes the given refresh token. Returns false if not found or already inactive. /// Task LogoutAsync(string rawRefreshToken); } ``` - [ ] **Step 4: Create `Services/AuthService.cs`** ```csharp 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 _userManager; private readonly AppDbContext _context; private readonly ITokenService _tokenService; private readonly IConfiguration _config; public AuthService( UserManager 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 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** ```powershell 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** ```powershell dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -v normal ``` Expected: ``` Test Run Successful. Total tests: 16 Passed: 16 ``` - [ ] **Step 7: Commit** ```powershell 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`** ```csharp 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 _userManager; private const string CookieName = "rolac_rt"; public AuthController(IAuthService authService, UserManager userManager) { _authService = authService; _userManager = userManager; } /// /// 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 rolac_rt. /// [HttpPost("login")] [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task 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, }, }); } /// /// 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). /// [HttpPost("refresh")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task 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 }); } /// /// Revoke the current refresh token and clear the cookie. /// Safe to call even when no cookie is present. /// [HttpPost("logout")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task 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** ```powershell dotnet build API/ROLAC.API/ROLAC.API.csproj ``` Expected: `Build succeeded. 0 Error(s)` - [ ] **Step 3: Commit** ```powershell 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** ```json { "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** ```json { "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** ```powershell Select-String "appsettings.Development" .gitignore ``` If the output is empty, add it: ```powershell Add-Content .gitignore "`nAPI/ROLAC.API/appsettings.Development.json" ``` Then verify: ```powershell git status API/ROLAC.API/appsettings.Development.json ``` Expected: `nothing to commit` (file is ignored) - [ ] **Step 4: Commit only the non-secret file** ```powershell 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`** ```csharp 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(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); // ── ASP.NET Core Identity ───────────────────────────────────────────────────── builder.Services.AddIdentity(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() .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(); builder.Services.AddScoped(); // ── 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() } }); }); var app = builder.Build(); // ── Startup: migrate DB + seed roles + seed dev admin ──────────────────────── using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var roleManager = scope.ServiceProvider.GetRequiredService>(); var userManager = scope.ServiceProvider.GetRequiredService>(); 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** ```powershell dotnet build API/ROLAC.API/ROLAC.API.csproj ``` Expected: `Build succeeded. 0 Error(s)` - [ ] **Step 3: Commit** ```powershell 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** ```powershell dotnet ef --version ``` If not found: ```powershell dotnet tool install --global dotnet-ef --version 8.0.11 ``` - [ ] **Step 2: Create the initial migration** ```powershell 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 `_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** ```powershell dotnet ef database update ` --project API/ROLAC.API/ROLAC.API.csproj ` --startup-project API/ROLAC.API/ROLAC.API.csproj ``` Expected: ``` Build succeeded. Applying migration '_InitialAuth'. Done. ``` - [ ] **Step 5: Commit migration files** ```powershell 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** ```powershell 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: ```json { "email": "admin@rolac.org", "password": "Admin1234!" } ``` Click **Execute**. Expected: **200 OK** with body: ```json { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expiresIn": 900, "user": { "id": "", "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: ```json { "email": "admin@rolac.org", "password": "WrongPassword!" } ``` Expected: **401 Unauthorized**: ```json { "message": "Invalid credentials" } ``` - [ ] **Step 7: Run full test suite one final time** ```powershell 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