Files
work-club-manager/backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs
WorkClub Automation b9edbb8a65 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
2026-03-03 14:27:30 +01:00

73 lines
2.0 KiB
C#

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"
};
}
}