Files
work-club-manager/backend/WorkClub.Api/Middleware/TenantValidationMiddleware.cs
T
WorkClub Automation 26d7d83811 Fix middleware order - place Authentication before TenantValidation
The JWT middleware needs to fetch signing keys from Keycloak before
tenant validation runs. The previous order caused signature validation
to fail because the middleware was blocking the JWKS endpoint requests.

- Moved Authentication before TenantValidationMiddleware
- Removed realm endpoint from exemption list (not needed with correct order)
- This allows JWT middleware to fetch signing keys and validate tokens
2026-03-20 10:42:31 +01:00

104 lines
3.7 KiB
C#

using System.Text.Json;
namespace WorkClub.Api.Middleware;
public class TenantValidationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TenantValidationMiddleware> _logger;
public TenantValidationMiddleware(RequestDelegate next, ILogger<TenantValidationMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
_logger.LogInformation("TenantValidationMiddleware: Processing request for {Path}", context.Request.Path);
if (!context.User.Identity?.IsAuthenticated ?? true)
{
await _next(context);
return;
}
// Exempt bootstrap, admin, debug, and Keycloak OIDC endpoints from tenant validation
if (context.Request.Path.StartsWithSegments("/api/clubs/me") ||
context.Request.Path.StartsWithSegments("/api/admin") ||
context.Request.Path.StartsWithSegments("/api/debug") ||
context.Request.Path.StartsWithSegments("/realms"))
{
_logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path);
await _next(context);
return;
}
if (!context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantIdHeader) ||
string.IsNullOrWhiteSpace(tenantIdHeader))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new { error = "X-Tenant-Id header is required" });
return;
}
var requestedTenantId = tenantIdHeader.ToString();
var clubsClaim = context.User.FindFirst("clubs")?.Value;
if (string.IsNullOrEmpty(clubsClaim))
{
// NEW: Skip check if user is a global admin
var realmAccess = context.User.FindFirst("realm_access")?.Value;
if (!string.IsNullOrEmpty(realmAccess) && IsAdminUser(realmAccess))
{
await _next(context);
return;
}
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;
}
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { error = "User does not have clubs claim" });
return;
}
// Parse comma-separated club UUIDs
var clubIds = clubsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.ToArray();
if (clubIds.Length == 0 || !clubIds.Contains(requestedTenantId))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { error = $"User is not a member of tenant {requestedTenantId}" });
return;
}
// Store validated tenant ID in HttpContext.Items for downstream middleware/services
context.Items["TenantId"] = requestedTenantId;
_logger.LogInformation("TenantValidationMiddleware: Set TenantId={TenantId} in HttpContext.Items", requestedTenantId);
await _next(context);
}
}