26d7d83811
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
219 lines
7.3 KiB
C#
219 lines
7.3 KiB
C#
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using WorkClub.Api.Auth;
|
|
using WorkClub.Api.Endpoints.Clubs;
|
|
using WorkClub.Api.Endpoints.Members;
|
|
using WorkClub.Api.Endpoints.Shifts;
|
|
using WorkClub.Api.Endpoints.Tasks;
|
|
using WorkClub.Api.Middleware;
|
|
using WorkClub.Api.Services;
|
|
using WorkClub.Application.Interfaces;
|
|
using WorkClub.Infrastructure.Data;
|
|
using WorkClub.Infrastructure.Data.Interceptors;
|
|
using WorkClub.Infrastructure.Services;
|
|
using WorkClub.Infrastructure.Seed;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Services.AddOpenApi();
|
|
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<ITenantProvider, TenantProvider>();
|
|
builder.Services.AddScoped<SeedDataService>();
|
|
builder.Services.AddScoped<TaskService>();
|
|
builder.Services.AddScoped<ShiftService>();
|
|
builder.Services.AddScoped<ClubService>();
|
|
builder.Services.AddScoped<AdminClubService>();
|
|
builder.Services.AddScoped<MemberService>();
|
|
builder.Services.AddScoped<MemberSyncService>();
|
|
|
|
builder.Services.AddScoped<TenantDbTransactionInterceptor>();
|
|
builder.Services.AddSingleton<SaveChangesTenantInterceptor>();
|
|
|
|
// Add CORS to allow frontend requests
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy("AllowFrontend", policy =>
|
|
{
|
|
policy.WithOrigins("http://localhost:3000")
|
|
.AllowAnyHeader()
|
|
.AllowAnyMethod()
|
|
.AllowCredentials();
|
|
});
|
|
});
|
|
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.Authority = builder.Configuration["Keycloak:Authority"];
|
|
options.Audience = builder.Configuration["Keycloak:Audience"];
|
|
options.RequireHttpsMetadata = false;
|
|
options.MapInboundClaims = false;
|
|
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
|
{
|
|
ValidateIssuer = false, // Disabled for local dev - external clients use localhost:8080, internal use keycloak:8080
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true
|
|
};
|
|
options.Events = new JwtBearerEvents
|
|
{
|
|
OnAuthenticationFailed = context =>
|
|
{
|
|
Console.WriteLine($"JWT Authentication Failed: {context.Exception.Message}");
|
|
if (context.Exception.InnerException != null)
|
|
{
|
|
Console.WriteLine($"Inner Exception: {context.Exception.InnerException.Message}");
|
|
}
|
|
return Task.CompletedTask;
|
|
},
|
|
OnTokenValidated = context =>
|
|
{
|
|
Console.WriteLine($"JWT Token Validated for user: {context.Principal?.Identity?.Name ?? "unknown"}");
|
|
return Task.CompletedTask;
|
|
},
|
|
OnChallenge = context =>
|
|
{
|
|
Console.WriteLine($"JWT Challenge: {context.Error}");
|
|
return Task.CompletedTask;
|
|
}
|
|
};
|
|
});
|
|
|
|
builder.Services.AddScoped<IClaimsTransformation, ClubRoleClaimsTransformation>();
|
|
|
|
builder.Services.AddAuthorizationBuilder()
|
|
.AddPolicy("RequireGlobalAdmin", policy => policy.RequireAssertion(context =>
|
|
{
|
|
var realmAccess = context.User.FindFirst("realm_access")?.Value;
|
|
if (string.IsNullOrEmpty(realmAccess))
|
|
return false;
|
|
|
|
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() == "admin")
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// If JSON parsing fails, fallback to string contains check
|
|
return realmAccess.Contains("admin", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
return false;
|
|
}))
|
|
.AddPolicy("RequireManager", policy => policy.RequireRole("Manager"))
|
|
.AddPolicy("RequireMember", policy => policy.RequireRole("Manager", "Member"))
|
|
.AddPolicy("RequireViewer", policy => policy.RequireAuthenticatedUser());
|
|
|
|
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
|
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
|
|
.AddInterceptors(
|
|
sp.GetRequiredService<TenantDbTransactionInterceptor>(),
|
|
sp.GetRequiredService<SaveChangesTenantInterceptor>()));
|
|
|
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
|
if (!string.IsNullOrEmpty(connectionString))
|
|
{
|
|
builder.Services.AddHealthChecks()
|
|
.AddNpgSql(connectionString);
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddHealthChecks();
|
|
}
|
|
|
|
var app = builder.Build();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi();
|
|
|
|
using var scope = app.Services.CreateScope();
|
|
var seedService = scope.ServiceProvider.GetRequiredService<SeedDataService>();
|
|
await seedService.SeedAsync();
|
|
}
|
|
|
|
app.UseHttpsRedirection();
|
|
|
|
app.UseCors("AllowFrontend");
|
|
|
|
// IMPORTANT: Order matters!
|
|
// 1. Authentication must come before tenant validation so JWT middleware can fetch JWKS
|
|
// 2. Tenant validation should come after auth but before endpoints
|
|
app.UseAuthentication();
|
|
app.UseMiddleware<TenantValidationMiddleware>();
|
|
app.UseAuthorization();
|
|
app.UseMiddleware<MemberSyncMiddleware>();
|
|
|
|
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
|
{
|
|
Predicate = _ => false
|
|
});
|
|
|
|
app.MapHealthChecks("/health/ready");
|
|
app.MapHealthChecks("/health/startup");
|
|
|
|
var summaries = new[]
|
|
{
|
|
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
|
};
|
|
|
|
app.MapGet("/weatherforecast", () =>
|
|
{
|
|
var forecast = Enumerable.Range(1, 5).Select(index =>
|
|
new WeatherForecast
|
|
(
|
|
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
|
Random.Shared.Next(-20, 55),
|
|
summaries[Random.Shared.Next(summaries.Length)]
|
|
))
|
|
.ToArray();
|
|
return forecast;
|
|
})
|
|
.WithName("GetWeatherForecast");
|
|
|
|
app.MapGet("/api/debug/claims", (HttpContext context) =>
|
|
{
|
|
var claims = context.User.Claims.Select(c => new { c.Type, c.Value }).ToList();
|
|
var realmAccess = context.User.FindFirst("realm_access")?.Value;
|
|
|
|
// Check if the authorization header is present
|
|
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
|
|
|
return Results.Ok(new
|
|
{
|
|
isAuthenticated = context.User.Identity?.IsAuthenticated ?? false,
|
|
authenticationType = context.User.Identity?.AuthenticationType,
|
|
claimCount = claims.Count,
|
|
claims = claims,
|
|
realmAccess = realmAccess,
|
|
hasAuthHeader = !string.IsNullOrEmpty(authHeader),
|
|
authHeaderPrefix = authHeader?.Substring(0, Math.Min(20, authHeader?.Length ?? 0))
|
|
});
|
|
});
|
|
|
|
app.MapTaskEndpoints();
|
|
app.MapShiftEndpoints();
|
|
app.MapClubEndpoints();
|
|
app.MapAdminClubEndpoints();
|
|
app.MapMemberEndpoints();
|
|
|
|
app.Run();
|
|
|
|
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
|
{
|
|
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
|
}
|
|
|
|
public partial class Program { }
|