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:
72
backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs
Normal file
72
backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user