Files
work-club-manager/backend/WorkClub.Api/Program.cs
T
WorkClub Automation 87c315c6fd Fix Keycloak hostname configuration for Docker internal communication
- Add MetadataAddress configuration to JWT middleware for internal Docker URLs
- Add KC_HOSTNAME_ADMIN and KC_SPI_HOSTNAME_DEFAULT_ADMIN to Keycloak env
- This ensures API can fetch JWKS from Keycloak via internal Docker network
- Tests passing: 63/63
2026-03-20 10:49:55 +01:00

228 lines
7.8 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;
// For Docker internal communication, use the direct Keycloak URL for metadata
// This bypasses the hostname mismatch in Keycloak's discovery endpoint
var keycloakAuthority = builder.Configuration["Keycloak:Authority"];
if (keycloakAuthority?.Contains("keycloak:") == true)
{
options.MetadataAddress = $"{keycloakAuthority}/.well-known/openid-configuration";
}
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 { }