Files
work-club-manager/backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs
T
WorkClub Automation bb373a6b8e Fix admin authorization check - properly parse realm_access claim
The realm_access claim in JWT is a JSON object, not a simple string.
Previous string contains check was looking for escaped quotes in wrong format.

- Parse realm_access as JSON to extract roles array
- Check if 'admin' exists in roles array
- Fallback to string contains check if JSON parsing fails
- Applied fix in RequireGlobalAdmin policy, TenantValidationMiddleware,
  and ClubRoleClaimsTransformation

Fixes: Admin users getting 401 when trying to create clubs
2026-03-19 22:13:40 +01:00

127 lines
3.8 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using WorkClub.Domain.Enums;
using WorkClub.Infrastructure.Data;
namespace WorkClub.Api.Auth;
public class ClubRoleClaimsTransformation : IClaimsTransformation
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly AppDbContext _context;
public ClubRoleClaimsTransformation(
IHttpContextAccessor httpContextAccessor,
AppDbContext context)
{
_httpContextAccessor = httpContextAccessor;
_context = context;
}
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);
}
// Parse comma-separated club UUIDs
var clubIds = clubsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.ToArray();
if (clubIds.Length == 0)
{
return Task.FromResult(principal);
}
var tenantId = _httpContextAccessor.HttpContext?.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (string.IsNullOrEmpty(tenantId) || !clubIds.Contains(tenantId))
{
return Task.FromResult(principal);
}
var userIdClaim = principal.FindFirst("preferred_username")?.Value;
if (string.IsNullOrEmpty(userIdClaim))
{
return Task.FromResult(principal);
}
// --- NEW: Skip DB role lookup if user is a global admin ---
var realmAccess = principal.FindFirst("realm_access")?.Value;
if (!string.IsNullOrEmpty(realmAccess) && IsAdminUser(realmAccess))
{
return Task.FromResult(principal);
}
// ---------------------------------------------------------
static bool IsAdminUser(string realmAccess)
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(realmAccess);
if (doc.RootElement.TryGetProperty("roles", out var rolesElement) &&
rolesElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var role in rolesElement.EnumerateArray())
{
if (role.GetString()?.Equals("admin", StringComparison.OrdinalIgnoreCase) == true)
return true;
}
}
}
catch
{
// If JSON parsing fails, fallback to string contains check
return realmAccess.Contains("admin", StringComparison.OrdinalIgnoreCase);
}
return false;
}
// Look up the user's role in the database for the requested tenant
_httpContextAccessor.HttpContext!.Items["TenantId"] = tenantId;
var memberRole = GetMemberRole(userIdClaim, tenantId);
if (memberRole.HasValue)
{
var mappedRole = MapClubRoleToAspNetRole(memberRole.Value);
identity.AddClaim(new Claim(ClaimTypes.Role, mappedRole));
}
return Task.FromResult(principal);
}
private ClubRole? GetMemberRole(string externalUserId, string tenantId)
{
try
{
var member = _context.Members
.FirstOrDefault(m => m.Email == externalUserId && m.TenantId == tenantId);
return member?.Role;
}
catch
{
return null;
}
}
private static string MapClubRoleToAspNetRole(ClubRole clubRole)
{
return clubRole switch
{
ClubRole.Manager => "Manager",
ClubRole.Member => "Member",
ClubRole.Viewer => "Viewer",
_ => "Viewer"
};
}
}