From cab4c6778f7cf4851129b96915f10fa9d336a35d Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Mon, 25 May 2026 18:57:18 -0700 Subject: [PATCH] docs: add Login API implementation plan (JWT + ASP.NET Identity) --- .../superpowers/plans/2026-05-25-login-api.md | 1687 +++++++++++++++++ 1 file changed, 1687 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-login-api.md diff --git a/docs/superpowers/plans/2026-05-25-login-api.md b/docs/superpowers/plans/2026-05-25-login-api.md new file mode 100644 index 0000000..4d36d4a --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-login-api.md @@ -0,0 +1,1687 @@ +# 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