- 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
73 lines
2.0 KiB
C#
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"
|
|
};
|
|
}
|
|
}
|