feat(auth): add Keycloak JWT authentication and role-based authorization

- Configure JWT Bearer authentication with Keycloak realm integration
- Create ClubRoleClaimsTransformation to parse 'clubs' claim and add ASP.NET roles
- Add authorization policies: RequireAdmin, RequireManager, RequireMember, RequireViewer
- Add health check endpoints (/health/live, /health/ready, /health/startup)
- Add integration tests for authorization (TDD approach - tests written first)
- Configure middleware order: Authentication → MultiTenant → Authorization
- Add Keycloak configuration to appsettings.Development.json
- Add AspNetCore.HealthChecks.NpgSql v9.0.0 package

TDD Verification:
- Tests initially FAILED (expected before implementation) ✓
- Implementation complete but blocked by Task 8 Infrastructure errors
- Cannot verify tests PASS until Finbuckle.MultiTenant types resolve

Security Notes:
- RequireHttpsMetadata=false for dev only (MUST be true in production)
- Claims transformation maps Keycloak roles (lowercase) to ASP.NET roles (PascalCase)
- Health endpoints are public by default (no authentication required)

Blockers:
- Infrastructure project has Finbuckle.MultiTenant type resolution errors from Task 8
- Tests cannot execute until TenantProvider compilation errors are fixed
This commit is contained in:
WorkClub Automation
2026-03-03 14:27:30 +01:00
parent b7854e9571
commit b9edbb8a65
6 changed files with 819 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
namespace WorkClub.Api.Auth;
public class ClubRoleClaimsTransformation : IClaimsTransformation
{
private readonly IHttpContextAccessor _httpContextAccessor;
public ClubRoleClaimsTransformation(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
{
return Task.FromResult(principal);
}
var clubsClaim = principal.FindFirst("clubs")?.Value;
if (string.IsNullOrEmpty(clubsClaim))
{
return Task.FromResult(principal);
}
Dictionary<string, string>? clubsDict;
try
{
clubsDict = JsonSerializer.Deserialize<Dictionary<string, string>>(clubsClaim);
}
catch (JsonException)
{
return Task.FromResult(principal);
}
if (clubsDict == null || clubsDict.Count == 0)
{
return Task.FromResult(principal);
}
var tenantId = _httpContextAccessor.HttpContext?.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (string.IsNullOrEmpty(tenantId))
{
return Task.FromResult(principal);
}
if (!clubsDict.TryGetValue(tenantId, out var clubRole))
{
return Task.FromResult(principal);
}
var mappedRole = MapClubRoleToAspNetRole(clubRole);
identity.AddClaim(new Claim(ClaimTypes.Role, mappedRole));
return Task.FromResult(principal);
}
private static string MapClubRoleToAspNetRole(string clubRole)
{
return clubRole.ToLowerInvariant() switch
{
"admin" => "Admin",
"manager" => "Manager",
"member" => "Member",
"viewer" => "Viewer",
_ => "Viewer"
};
}
}

View File

@@ -7,8 +7,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageReference Include="Finbuckle.MultiTenant" Version="10.0.3" />
<PackageReference Include="Finbuckle.MultiTenant.AspNetCore" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
</ItemGroup>
<ItemGroup>

View File

@@ -4,5 +4,12 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=workclub;Username=app;Password=apppass"
},
"Keycloak": {
"Authority": "http://localhost:8080/realms/workclub",
"Audience": "workclub-api"
}
}

View File

@@ -0,0 +1,134 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.IdentityModel.Tokens;
namespace WorkClub.Tests.Integration.Auth;
public class AuthorizationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public AuthorizationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task AdminCanAccessAdminEndpoints_Returns200()
{
// Arrange
var client = _factory.CreateClient();
var token = CreateTestJwtToken("admin@test.com", "club-1", "admin");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1");
// Act - using health endpoint as placeholder for admin endpoint
var response = await client.GetAsync("/health/ready");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task MemberCannotAccessAdminEndpoints_Returns403()
{
// Arrange
var client = _factory.CreateClient();
var token = CreateTestJwtToken("member@test.com", "club-1", "member");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1");
// Act - This will need actual admin endpoint in future (placeholder for now)
var response = await client.GetAsync("/admin/test");
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task ViewerCanOnlyRead_PostReturns403()
{
// Arrange
var client = _factory.CreateClient();
var token = CreateTestJwtToken("viewer@test.com", "club-1", "viewer");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1");
// Act - Placeholder for actual POST endpoint
var content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/tasks", content);
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task UnauthenticatedUser_Returns401()
{
// Arrange
var client = _factory.CreateClient();
// No Authorization header
// Act
var response = await client.GetAsync("/api/tasks");
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task HealthEndpointsArePublic_NoAuthRequired()
{
// Arrange
var client = _factory.CreateClient();
// No Authorization header
// Act
var liveResponse = await client.GetAsync("/health/live");
var readyResponse = await client.GetAsync("/health/ready");
var startupResponse = await client.GetAsync("/health/startup");
// Assert
Assert.Equal(HttpStatusCode.OK, liveResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, readyResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, startupResponse.StatusCode);
}
/// <summary>
/// Creates a test JWT token with specified user, club, and role
/// </summary>
private string CreateTestJwtToken(string username, string clubId, string role)
{
var clubsDict = new Dictionary<string, string>
{
[clubId] = role
};
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, username),
new Claim(JwtRegisteredClaimNames.Email, username),
new Claim("clubs", JsonSerializer.Serialize(clubsDict)),
new Claim(JwtRegisteredClaimNames.Aud, "workclub-api"),
new Claim(JwtRegisteredClaimNames.Iss, "http://localhost:8080/realms/workclub")
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-secret-key-must-be-at-least-32-chars-long-for-hmac-sha256"));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: "http://localhost:8080/realms/workclub",
audience: "workclub-api",
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}