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

1688 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Login API (JWT + ASP.NET Identity) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement three auth endpoints (`POST /api/auth/login`, `/api/auth/refresh`, `/api/auth/logout`) using ASP.NET Core Identity for user management and custom JWT + DB-stored refresh tokens for stateless authentication.
**Architecture:** ASP.NET Core Identity manages users, roles, and password hashing. A `TokenService` generates HS256 JWTs (15-minute lifetime) and cryptographically-random refresh tokens stored as SHA-256 hashes in a PostgreSQL `RefreshTokens` table. The refresh token travels in an `HttpOnly; Secure; SameSite=Strict` cookie (`rolac_rt`); the access token is returned in the JSON response body. Every refresh rotates the token (old revoked, new issued) to limit replay windows.
**Tech Stack:** .NET 8, ASP.NET Core Identity, EF Core 8, Npgsql.EntityFrameworkCore.PostgreSQL, Microsoft.IdentityModel.Tokens (JWT), xUnit, Moq, EF InMemory (tests)
---
## File Map
**New files:**
| Path | Responsibility |
|------|----------------|
| `API/ROLAC.API/Entities/AppUser.cs` | `IdentityUser` subclass — adds MemberId, IsActive, LastLoginAt, LanguagePreference |
| `API/ROLAC.API/Entities/AppRole.cs` | `IdentityRole` subclass — adds Description |
| `API/ROLAC.API/Entities/RefreshToken.cs` | DB record per issued refresh token (hashed, revocable, rotatable) |
| `API/ROLAC.API/Data/AppDbContext.cs` | `IdentityDbContext<AppUser, AppRole, string>` + `RefreshTokens` DbSet |
| `API/ROLAC.API/Data/DbSeeder.cs` | Seeds 13 RBAC roles + a dev super_admin user on startup |
| `API/ROLAC.API/DTOs/Auth/LoginRequest.cs` | `{ Email, Password }` with validation attributes |
| `API/ROLAC.API/DTOs/Auth/LoginResponse.cs` | `{ AccessToken, ExpiresIn, User }` |
| `API/ROLAC.API/Services/ITokenService.cs` | Interface: GenerateAccessToken / GenerateRefreshToken / HashToken |
| `API/ROLAC.API/Services/TokenService.cs` | Implementation |
| `API/ROLAC.API/Services/IAuthService.cs` | Interface: LoginAsync / RefreshAsync / LogoutAsync |
| `API/ROLAC.API/Services/AuthService.cs` | Implementation |
| `API/ROLAC.API/Controllers/AuthController.cs` | 3 endpoints wired to AuthService |
| `API/ROLAC.API.Tests/ROLAC.API.Tests.csproj` | xUnit + Moq test project |
| `API/ROLAC.API.Tests/Services/TokenServiceTests.cs` | 7 unit tests for TokenService |
| `API/ROLAC.API.Tests/Services/AuthServiceTests.cs` | 9 unit tests for AuthService |
**Modified files:**
| Path | What changes |
|------|--------------|
| `API/ROLAC.API/ROLAC.API.csproj` | Add 5 NuGet packages |
| `API/ROLAC.API/Program.cs` | Wire Identity, EF Core, JWT auth, CORS, DI, startup seeding |
| `API/ROLAC.API/appsettings.json` | Add Jwt section (non-secret values only) |
| `API/ROLAC.API/appsettings.Development.json` | Add ConnectionStrings + Jwt:SecretKey (gitignored) |
| `API/ROLAC.API/ROLAC.API.sln` | Add test project reference |
---
## Task 1: Install NuGet packages
**Files:** Modify `API/ROLAC.API/ROLAC.API.csproj`
- [ ] **Step 1: Add the 5 required packages**
Run from `E:\VSProject\ROLAC`:
```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
{
/// <summary>Links this login account to a church member record. Null for admin-only accounts.</summary>
public int? MemberId { get; set; }
/// <summary>UI language preference: 'en' or 'zh-TW'.</summary>
public string LanguagePreference { get; set; } = "en";
/// <summary>False = account suspended (returns 403 even with correct password).</summary>
public bool IsActive { get; set; } = true;
public DateTime? LastLoginAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
```
- [ ] **Step 2: Create `Entities/AppRole.cs`**
```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!;
/// <summary>SHA-256 hex of the raw token sent to the client. Never store raw tokens.</summary>
public string TokenHash { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Set when this token is revoked (logout or rotation).</summary>
public DateTime? RevokedAt { get; set; }
/// <summary>Points to the hash of the token that replaced this one during rotation.</summary>
public string? ReplacedByHash { get; set; }
public string? DeviceInfo { get; set; }
public string? IpAddress { get; set; }
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
public bool IsRevoked => RevokedAt.HasValue;
public bool IsActive => !IsRevoked && !IsExpired;
}
```
- [ ] **Step 4: Build to verify**
```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<AppUser, AppRole, string>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<RefreshToken>(entity =>
{
entity.HasKey(e => e.Id);
// Unique index on hash — enables fast lookup and prevents duplicate tokens
entity.HasIndex(e => e.TokenHash).IsUnique();
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
entity.Property(e => e.DeviceInfo).HasMaxLength(200);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.ReplacedByHash).HasMaxLength(64);
entity.HasOne(e => e.User)
.WithMany(u => u.RefreshTokens)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
// Computed properties are not DB columns
entity.Ignore(e => e.IsExpired);
entity.Ignore(e => e.IsRevoked);
entity.Ignore(e => e.IsActive);
});
builder.Entity<AppUser>(entity =>
{
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
});
builder.Entity<AppRole>(entity =>
{
entity.Property(e => e.Description).HasMaxLength(500);
});
}
}
```
- [ ] **Step 2: Create `Data/DbSeeder.cs`**
```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<AppRole> roleManager)
{
foreach (var (name, description) in Roles)
{
if (!await roleManager.RoleExistsAsync(name))
{
await roleManager.CreateAsync(new AppRole
{
Name = name,
Description = description,
});
}
}
}
/// <summary>
/// Creates a super_admin test account for local development.
/// DO NOT call this in production — remove or guard with IsDevelopment().
/// Credentials: admin@rolac.org / Admin1234!
/// </summary>
public static async Task SeedAdminUserAsync(UserManager<AppUser> userManager)
{
const string adminEmail = "admin@rolac.org";
const string adminPassword = "Admin1234!";
if (await userManager.FindByEmailAsync(adminEmail) is null)
{
var admin = new AppUser
{
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true,
IsActive = true,
LanguagePreference = "en",
CreatedAt = DateTime.UtcNow,
};
var result = await userManager.CreateAsync(admin, adminPassword);
if (result.Succeeded)
await userManager.AddToRoleAsync(admin, "super_admin");
}
}
}
```
- [ ] **Step 3: Build to verify**
```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
{
/// <summary>Short-lived JWT (15 min). Store in memory — never in localStorage.</summary>
public string AccessToken { get; set; } = null!;
/// <summary>Seconds until the access token expires. Always 900 (15 × 60).</summary>
public int ExpiresIn { get; set; }
public UserInfo User { get; set; } = null!;
}
public class UserInfo
{
public string Id { get; set; } = null!;
public string Email { get; set; } = null!;
public IList<string> Roles { get; set; } = [];
public string LanguagePreference { get; set; } = "en";
}
```
- [ ] **Step 3: Commit**
```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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Memory" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ROLAC.API\ROLAC.API.csproj" />
</ItemGroup>
</Project>
```
- [ ] **Step 2: Add test project to solution**
```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<string, string?>
{
{ "Jwt:SecretKey", "test-secret-key-that-is-at-least-32-characters-long!!" },
{ "Jwt:Issuer", "rolac-api-test" },
{ "Jwt:Audience", "rolac-client-test" },
{ "Jwt:AccessTokenExpiryMinutes", "15" },
{ "Jwt:RefreshTokenExpiryDays", "30" },
})
.Build();
_sut = new TokenService(config);
}
[Fact]
public void GenerateAccessToken_ReturnsValidJwt_WithUserIdAndRoles()
{
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
var roles = new List<string> { "member", "finance" };
var token = _sut.GenerateAccessToken(user, roles);
Assert.NotEmpty(token);
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
Assert.Equal("user-123", jwt.Subject);
Assert.Equal("test@rolac.org", jwt.Claims.First(c => c.Type == JwtRegisteredClaimNames.Email).Value);
var roleClaims = jwt.Claims
.Where(c => c.Type == ClaimTypes.Role)
.Select(c => c.Value)
.ToList();
Assert.Contains("member", roleClaims);
Assert.Contains("finance", roleClaims);
}
[Fact]
public void GenerateAccessToken_ExpiresIn15Minutes()
{
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
var token = _sut.GenerateAccessToken(user, new List<string>());
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
var expectedExpiry = DateTime.UtcNow.AddMinutes(15);
Assert.True((jwt.ValidTo - expectedExpiry).Duration() < TimeSpan.FromSeconds(5));
}
[Fact]
public void GenerateRefreshToken_Returns64ByteBase64String()
{
var token = _sut.GenerateRefreshToken();
Assert.NotEmpty(token);
var bytes = Convert.FromBase64String(token);
Assert.Equal(64, bytes.Length);
}
[Fact]
public void GenerateRefreshToken_ProducesUniqueTokensEachCall()
{
var token1 = _sut.GenerateRefreshToken();
var token2 = _sut.GenerateRefreshToken();
Assert.NotEqual(token1, token2);
}
[Fact]
public void HashToken_SameInputProducesSameHash()
{
const string raw = "some-raw-token-value";
var hash1 = _sut.HashToken(raw);
var hash2 = _sut.HashToken(raw);
Assert.Equal(hash1, hash2);
}
[Fact]
public void HashToken_DifferentInputsProduceDifferentHashes()
{
var hash1 = _sut.HashToken("token-a");
var hash2 = _sut.HashToken("token-b");
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void HashToken_Returns64CharLowercaseHexString()
{
var hash = _sut.HashToken("any-token");
Assert.Equal(64, hash.Length);
Assert.True(hash.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')));
}
}
```
- [ ] **Step 4: Run build — expect failure (TokenService doesn't exist yet)**
```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
{
/// <summary>Generates a signed HS256 JWT containing userId, email, and roles claims.</summary>
string GenerateAccessToken(AppUser user, IList<string> roles);
/// <summary>Generates a cryptographically-random 64-byte base64 string (the raw token value).</summary>
string GenerateRefreshToken();
/// <summary>Returns the SHA-256 hex hash of the raw token. Always hash before storing to DB.</summary>
string HashToken(string rawToken);
}
```
- [ ] **Step 6: Create `Services/TokenService.cs`**
```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<string> roles)
{
var secretKey = _config["Jwt:SecretKey"]!;
var issuer = _config["Jwt:Issuer"]!;
var audience = _config["Jwt:Audience"]!;
var expiryMin = int.Parse(_config["Jwt:AccessTokenExpiryMinutes"]!);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Email, user.Email!),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
foreach (var role in roles)
claims.Add(new Claim(ClaimTypes.Role, role));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expiryMin),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string GenerateRefreshToken()
{
var bytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes);
}
public string HashToken(string rawToken)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
return Convert.ToHexString(bytes).ToLower();
}
}
```
- [ ] **Step 7: Run TokenService tests — expect all 7 pass**
```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<UserManager<AppUser>> _userManagerMock;
private readonly AppDbContext _context;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly IConfiguration _config;
private readonly AuthService _sut;
public AuthServiceTests()
{
// UserManager requires IUserStore; all other deps can be null for unit tests
var store = new Mock<IUserStore<AppUser>>();
_userManagerMock = new Mock<UserManager<AppUser>>(
store.Object, null, null, null, null, null, null, null, null);
// Fresh in-memory DB per test class instance — prevents cross-test pollution
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options);
_tokenServiceMock = new Mock<ITokenService>();
_config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Jwt:RefreshTokenExpiryDays", "30" },
})
.Build();
_sut = new AuthService(_userManagerMock.Object, _context, _tokenServiceMock.Object, _config);
}
public void Dispose() => _context.Dispose();
// ── LoginAsync ─────────────────────────────────────────────────────────────
[Fact]
public async Task LoginAsync_ValidCredentials_ReturnsTokensAndPersistsRefreshToken()
{
var user = new AppUser
{
Id = "u1",
Email = "alice@rolac.org",
UserName = "alice@rolac.org",
IsActive = true,
};
_userManagerMock.Setup(m => m.FindByEmailAsync("alice@rolac.org")).ReturnsAsync(user);
_userManagerMock.Setup(m => m.CheckPasswordAsync(user, "Password1!")).ReturnsAsync(true);
_userManagerMock.Setup(m => m.IsLockedOutAsync(user)).ReturnsAsync(false);
_userManagerMock.Setup(m => m.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
_userManagerMock.Setup(m => m.GetRolesAsync(user)).ReturnsAsync(new List<string> { "member" });
_userManagerMock.Setup(m => m.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
_tokenServiceMock.Setup(t => t.GenerateAccessToken(user, It.IsAny<IList<string>>())).Returns("access.token.value");
_tokenServiceMock.Setup(t => t.GenerateRefreshToken()).Returns("raw-refresh-token");
_tokenServiceMock.Setup(t => t.HashToken("raw-refresh-token")).Returns("hashed-token-64chars");
var result = await _sut.LoginAsync("alice@rolac.org", "Password1!", "127.0.0.1", "TestAgent/1.0");
Assert.True(result.Success);
Assert.Equal("access.token.value", result.AccessToken);
Assert.Equal("raw-refresh-token", result.RefreshToken);
Assert.Null(result.Error);
// Refresh token must be persisted to DB as a hash
var stored = await _context.RefreshTokens.SingleAsync();
Assert.Equal("hashed-token-64chars", stored.TokenHash);
Assert.Equal("u1", stored.UserId);
Assert.Equal("127.0.0.1", stored.IpAddress);
}
[Fact]
public async Task LoginAsync_UserNotFound_ReturnsFailure()
{
_userManagerMock.Setup(m => m.FindByEmailAsync("nobody@rolac.org")).ReturnsAsync((AppUser?)null);
var result = await _sut.LoginAsync("nobody@rolac.org", "Password1!", null, null);
Assert.False(result.Success);
Assert.Equal("Invalid credentials", result.Error);
}
[Fact]
public async Task LoginAsync_InactiveUser_ReturnsFailureWithoutCheckingPassword()
{
var user = new AppUser { Id = "u2", Email = "inactive@rolac.org", UserName = "inactive@rolac.org", IsActive = false };
_userManagerMock.Setup(m => m.FindByEmailAsync("inactive@rolac.org")).ReturnsAsync(user);
var result = await _sut.LoginAsync("inactive@rolac.org", "Password1!", null, null);
Assert.False(result.Success);
Assert.Equal("Invalid credentials", result.Error);
// CheckPasswordAsync must never be called for inactive users
_userManagerMock.Verify(m => m.CheckPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task LoginAsync_WrongPassword_IncrementsFailCountAndReturnsFailure()
{
var user = new AppUser { Id = "u3", Email = "bob@rolac.org", UserName = "bob@rolac.org", IsActive = true };
_userManagerMock.Setup(m => m.FindByEmailAsync("bob@rolac.org")).ReturnsAsync(user);
_userManagerMock.Setup(m => m.CheckPasswordAsync(user, "WrongPass!")).ReturnsAsync(false);
_userManagerMock.Setup(m => m.AccessFailedAsync(user)).ReturnsAsync(IdentityResult.Success);
var result = await _sut.LoginAsync("bob@rolac.org", "WrongPass!", null, null);
Assert.False(result.Success);
Assert.Equal("Invalid credentials", result.Error);
_userManagerMock.Verify(m => m.AccessFailedAsync(user), Times.Once);
}
[Fact]
public async Task LoginAsync_LockedOutUser_ReturnsLockoutError()
{
var user = new AppUser { Id = "u4", Email = "locked@rolac.org", UserName = "locked@rolac.org", IsActive = true };
_userManagerMock.Setup(m => m.FindByEmailAsync("locked@rolac.org")).ReturnsAsync(user);
_userManagerMock.Setup(m => m.CheckPasswordAsync(user, "Password1!")).ReturnsAsync(true);
_userManagerMock.Setup(m => m.IsLockedOutAsync(user)).ReturnsAsync(true);
var result = await _sut.LoginAsync("locked@rolac.org", "Password1!", null, null);
Assert.False(result.Success);
Assert.Equal("Account is locked. Try again later.", result.Error);
}
// ── RefreshAsync ────────────────────────────────────────────────────────────
[Fact]
public async Task RefreshAsync_ValidToken_RotatesTokenAndReturnsNewPair()
{
var user = new AppUser { Id = "u5", Email = "carol@rolac.org", UserName = "carol@rolac.org", IsActive = true };
_context.Users.Add(user);
var existing = new RefreshToken
{
UserId = "u5",
User = user,
TokenHash = "existing-hash",
ExpiresAt = DateTime.UtcNow.AddDays(25),
CreatedAt = DateTime.UtcNow.AddDays(-5),
};
_context.RefreshTokens.Add(existing);
await _context.SaveChangesAsync();
_tokenServiceMock.Setup(t => t.HashToken("raw-existing")).Returns("existing-hash");
_tokenServiceMock.Setup(t => t.GenerateRefreshToken()).Returns("new-raw-refresh");
_tokenServiceMock.Setup(t => t.HashToken("new-raw-refresh")).Returns("new-hash");
_tokenServiceMock.Setup(t => t.GenerateAccessToken(It.IsAny<AppUser>(), It.IsAny<IList<string>>())).Returns("new.access.token");
_userManagerMock.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>())).ReturnsAsync(new List<string> { "member" });
var result = await _sut.RefreshAsync("raw-existing", "127.0.0.1");
Assert.True(result.Success);
Assert.Equal("new.access.token", result.AccessToken);
Assert.Equal("new-raw-refresh", result.NewRefreshToken);
// Old token revoked
Assert.NotNull(existing.RevokedAt);
Assert.Equal("new-hash", existing.ReplacedByHash);
// New token persisted
var newToken = await _context.RefreshTokens.SingleAsync(t => t.TokenHash == "new-hash");
Assert.Equal("u5", newToken.UserId);
}
[Fact]
public async Task RefreshAsync_RevokedToken_ReturnsFailure()
{
var user = new AppUser { Id = "u6", Email = "dave@rolac.org", UserName = "dave@rolac.org" };
_context.Users.Add(user);
var revoked = new RefreshToken
{
UserId = "u6",
User = user,
TokenHash = "revoked-hash",
ExpiresAt = DateTime.UtcNow.AddDays(20),
CreatedAt = DateTime.UtcNow.AddDays(-1),
RevokedAt = DateTime.UtcNow.AddHours(-1), // already revoked
};
_context.RefreshTokens.Add(revoked);
await _context.SaveChangesAsync();
_tokenServiceMock.Setup(t => t.HashToken("raw-revoked")).Returns("revoked-hash");
var result = await _sut.RefreshAsync("raw-revoked", null);
Assert.False(result.Success);
Assert.Equal("Invalid or expired refresh token", result.Error);
}
// ── LogoutAsync ─────────────────────────────────────────────────────────────
[Fact]
public async Task LogoutAsync_ActiveToken_RevokesTokenAndReturnsTrue()
{
var user = new AppUser { Id = "u7", Email = "eve@rolac.org", UserName = "eve@rolac.org" };
_context.Users.Add(user);
var active = new RefreshToken
{
UserId = "u7",
User = user,
TokenHash = "active-hash",
ExpiresAt = DateTime.UtcNow.AddDays(29),
CreatedAt = DateTime.UtcNow,
};
_context.RefreshTokens.Add(active);
await _context.SaveChangesAsync();
_tokenServiceMock.Setup(t => t.HashToken("raw-active")).Returns("active-hash");
var result = await _sut.LogoutAsync("raw-active");
Assert.True(result);
Assert.NotNull(active.RevokedAt);
}
[Fact]
public async Task LogoutAsync_UnknownToken_ReturnsFalse()
{
_tokenServiceMock.Setup(t => t.HashToken("unknown-raw")).Returns("unknown-hash");
var result = await _sut.LogoutAsync("unknown-raw");
Assert.False(result);
}
}
```
- [ ] **Step 2: Run build — expect failure (AuthService doesn't exist yet)**
```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
{
/// <summary>
/// Validates credentials and issues access + refresh tokens.
/// Returns (Success=false, Error=message) on any failure — same message for user-not-found
/// and wrong-password to prevent user enumeration.
/// </summary>
Task<(bool Success, string? AccessToken, string? RefreshToken, string? Error)> LoginAsync(
string email, string password, string? ipAddress, string? deviceInfo);
/// <summary>
/// Validates the raw refresh token, revokes it, and issues a rotated replacement.
/// </summary>
Task<(bool Success, string? AccessToken, string? NewRefreshToken, string? Error)> RefreshAsync(
string rawRefreshToken, string? ipAddress);
/// <summary>
/// Revokes the given refresh token. Returns false if not found or already inactive.
/// </summary>
Task<bool> LogoutAsync(string rawRefreshToken);
}
```
- [ ] **Step 4: Create `Services/AuthService.cs`**
```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<AppUser> _userManager;
private readonly AppDbContext _context;
private readonly ITokenService _tokenService;
private readonly IConfiguration _config;
public AuthService(
UserManager<AppUser> userManager,
AppDbContext context,
ITokenService tokenService,
IConfiguration config)
{
_userManager = userManager;
_context = context;
_tokenService = tokenService;
_config = config;
}
public async Task<(bool Success, string? AccessToken, string? RefreshToken, string? Error)> LoginAsync(
string email, string password, string? ipAddress, string? deviceInfo)
{
var user = await _userManager.FindByEmailAsync(email);
// Return the same message for "not found" and "inactive" to prevent user enumeration
if (user is null || !user.IsActive)
return (false, null, null, "Invalid credentials");
var passwordValid = await _userManager.CheckPasswordAsync(user, password);
if (!passwordValid)
{
await _userManager.AccessFailedAsync(user);
return (false, null, null, "Invalid credentials");
}
if (await _userManager.IsLockedOutAsync(user))
return (false, null, null, "Account is locked. Try again later.");
await _userManager.ResetAccessFailedCountAsync(user);
var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.GenerateAccessToken(user, roles);
var rawToken = _tokenService.GenerateRefreshToken();
var tokenHash = _tokenService.HashToken(rawToken);
var expiryDays = int.Parse(_config["Jwt:RefreshTokenExpiryDays"]!);
_context.RefreshTokens.Add(new RefreshToken
{
UserId = user.Id,
User = user,
TokenHash = tokenHash,
ExpiresAt = DateTime.UtcNow.AddDays(expiryDays),
CreatedAt = DateTime.UtcNow,
IpAddress = ipAddress,
DeviceInfo = deviceInfo,
});
user.LastLoginAt = DateTime.UtcNow;
await _userManager.UpdateAsync(user);
await _context.SaveChangesAsync();
return (true, accessToken, rawToken, null);
}
public async Task<(bool Success, string? AccessToken, string? NewRefreshToken, string? Error)> RefreshAsync(
string rawRefreshToken, string? ipAddress)
{
var tokenHash = _tokenService.HashToken(rawRefreshToken);
var stored = await _context.RefreshTokens
.Include(rt => rt.User)
.FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash);
if (stored is null || !stored.IsActive)
return (false, null, null, "Invalid or expired refresh token");
// Rotate: revoke old token, issue replacement
var newRaw = _tokenService.GenerateRefreshToken();
var newHash = _tokenService.HashToken(newRaw);
var expiryDays = int.Parse(_config["Jwt:RefreshTokenExpiryDays"]!);
stored.RevokedAt = DateTime.UtcNow;
stored.ReplacedByHash = newHash;
var roles = await _userManager.GetRolesAsync(stored.User);
var accessToken = _tokenService.GenerateAccessToken(stored.User, roles);
_context.RefreshTokens.Add(new RefreshToken
{
UserId = stored.UserId,
User = stored.User,
TokenHash = newHash,
ExpiresAt = DateTime.UtcNow.AddDays(expiryDays),
CreatedAt = DateTime.UtcNow,
IpAddress = ipAddress,
});
await _context.SaveChangesAsync();
return (true, accessToken, newRaw, null);
}
public async Task<bool> LogoutAsync(string rawRefreshToken)
{
var tokenHash = _tokenService.HashToken(rawRefreshToken);
var stored = await _context.RefreshTokens
.FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash);
if (stored is null || !stored.IsActive)
return false;
stored.RevokedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
}
```
- [ ] **Step 5: Run AuthService tests — expect all 9 pass**
```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<AppUser> _userManager;
private const string CookieName = "rolac_rt";
public AuthController(IAuthService authService, UserManager<AppUser> userManager)
{
_authService = authService;
_userManager = userManager;
}
/// <summary>
/// Authenticate with email and password.
/// Returns a 15-minute JWT access token in the response body and a 30-day
/// refresh token in an HttpOnly cookie named <c>rolac_rt</c>.
/// </summary>
[HttpPost("login")]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var deviceInfo = Request.Headers.UserAgent.ToString();
var (success, accessToken, refreshToken, error) =
await _authService.LoginAsync(request.Email, request.Password, ipAddress, deviceInfo);
if (!success)
return Unauthorized(new { message = error });
var user = await _userManager.FindByEmailAsync(request.Email);
var roles = await _userManager.GetRolesAsync(user!);
SetRefreshTokenCookie(refreshToken!);
return Ok(new LoginResponse
{
AccessToken = accessToken!,
ExpiresIn = 900,
User = new UserInfo
{
Id = user!.Id,
Email = user.Email!,
Roles = roles,
LanguagePreference = user.LanguagePreference,
},
});
}
/// <summary>
/// Exchange the refresh token cookie for a new access token.
/// The old refresh token is revoked and a new one is set in the cookie (rotation).
/// </summary>
[HttpPost("refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Refresh()
{
var rawToken = Request.Cookies[CookieName];
if (string.IsNullOrEmpty(rawToken))
return Unauthorized(new { message = "No refresh token provided" });
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var (success, accessToken, newRefreshToken, error) =
await _authService.RefreshAsync(rawToken, ipAddress);
if (!success)
{
ClearRefreshTokenCookie();
return Unauthorized(new { message = error });
}
SetRefreshTokenCookie(newRefreshToken!);
return Ok(new { accessToken, expiresIn = 900 });
}
/// <summary>
/// Revoke the current refresh token and clear the cookie.
/// Safe to call even when no cookie is present.
/// </summary>
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout()
{
var rawToken = Request.Cookies[CookieName];
if (!string.IsNullOrEmpty(rawToken))
await _authService.LogoutAsync(rawToken);
ClearRefreshTokenCookie();
return NoContent();
}
private void SetRefreshTokenCookie(string rawToken)
{
Response.Cookies.Append(CookieName, rawToken, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Expires = DateTimeOffset.UtcNow.AddDays(30),
});
}
private void ClearRefreshTokenCookie()
{
Response.Cookies.Delete(CookieName, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
});
}
}
```
- [ ] **Step 2: Build to verify**
```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<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// ── ASP.NET Core Identity ─────────────────────────────────────────────────────
builder.Services.AddIdentity<AppUser, AppRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.Password.RequireUppercase = true;
options.Password.RequireLowercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
// ── JWT Authentication ────────────────────────────────────────────────────────
var jwtSection = builder.Configuration.GetSection("Jwt");
var secretKey = jwtSection["SecretKey"]
?? throw new InvalidOperationException("Jwt:SecretKey is not configured. Add it to appsettings.Development.json or environment variables.");
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSection["Issuer"],
ValidAudience = jwtSection["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
ClockSkew = TimeSpan.Zero, // tokens expire exactly on time — no grace period
};
});
// ── CORS ──────────────────────────────────────────────────────────────────────
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAngularApp", policy =>
{
policy
.WithOrigins("http://localhost:4200", "https://app.rolac.org")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials(); // Required — Angular must send HttpOnly cookie on /refresh
});
});
// ── Application Services ──────────────────────────────────────────────────────
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
// ── Controllers + Swagger ─────────────────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "ROLAC API", Version = "v1" });
// Allow pasting a Bearer token in Swagger UI to test protected endpoints
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "Enter: Bearer {your JWT}",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer",
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
Array.Empty<string>()
}
});
});
var app = builder.Build();
// ── Startup: migrate DB + seed roles + seed dev admin ────────────────────────
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<AppRole>>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();
await db.Database.MigrateAsync(); // applies pending EF migrations
await DbSeeder.SeedRolesAsync(roleManager); // ensures all 13 RBAC roles exist
if (app.Environment.IsDevelopment())
await DbSeeder.SeedAdminUserAsync(userManager); // admin@rolac.org / Admin1234!
}
// ── HTTP Pipeline ─────────────────────────────────────────────────────────────
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors("AllowAngularApp");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
```
- [ ] **Step 2: Build to verify**
```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 `<timestamp>_InitialAuth.cs`. It should create:
- `AspNetUsers` — with `MemberId`, `LanguagePreference`, `IsActive`, `LastLoginAt`, `CreatedAt` columns
- `AspNetRoles` — with `Description` column
- `AspNetUserRoles`, `AspNetUserClaims`, `AspNetRoleClaims`, `AspNetUserLogins`, `AspNetUserTokens`
- `RefreshTokens` — with `TokenHash` unique index, `UserId` FK → `AspNetUsers`
If any of the above are missing, the entities were not picked up correctly — re-check the namespace and that `AppDbContext` compiles without errors.
- [ ] **Step 4: Apply migration to local PostgreSQL**
```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 '<timestamp>_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": "<guid>",
"email": "admin@rolac.org",
"roles": ["super_admin"],
"languagePreference": "en"
}
}
```
The response **Headers** should include `Set-Cookie: rolac_rt=...`
- [ ] **Step 4: Test token refresh**
Call `POST /api/auth/refresh` (no body needed — the browser sends the cookie).
Expected: **200 OK** with a new `accessToken`.
- [ ] **Step 5: Test logout**
Call `POST /api/auth/logout`.
Expected: **204 No Content**. The `rolac_rt` cookie is cleared.
- [ ] **Step 6: Test wrong password returns 401**
Call `POST /api/auth/login` with:
```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