using System.Threading.RateLimiting; using Budget.Api.Services; using Budget.Infrastructure.Data; using Budget.Infrastructure.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? $"Host={builder.Configuration["POSTGRES_HOST"] ?? "localhost"};" + $"Port={builder.Configuration["POSTGRES_PORT"] ?? "5432"};" + $"Database={builder.Configuration["POSTGRES_DB"] ?? "budget"};" + $"Username={builder.Configuration["POSTGRES_USER"] ?? "budget"};" + $"Password={builder.Configuration["POSTGRES_PASSWORD"] ?? "changeme"}"; builder.Services.AddDbContext(opt => opt.UseNpgsql(connStr)); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); var oidc = builder.Configuration.GetSection("Oidc"); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = oidc["Authority"]; options.Audience = oidc["Audience"]; options.MapInboundClaims = false; var metadataAddress = oidc["MetadataAddress"]; if (!string.IsNullOrEmpty(metadataAddress)) { options.MetadataAddress = metadataAddress; options.RequireHttpsMetadata = false; } options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, RoleClaimType = "role", NameClaimType = "sub", }; }); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.GlobalLimiter = PartitionedRateLimiter.Create(ctx => { var key = ctx.User.FindFirst("sub")?.Value ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions { PermitLimit = 120, Window = TimeSpan.FromMinutes(1), QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0, }); }); options.AddPolicy("writes", ctx => { var key = ctx.User.FindFirst("sub")?.Value ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return RateLimitPartition.GetFixedWindowLimiter("writes:" + key, _ => new FixedWindowRateLimiterOptions { PermitLimit = 30, Window = TimeSpan.FromMinutes(1), QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0, }); }); }); builder.Services.AddAuthorization(); builder.Services.AddScoped(); builder.Services.AddControllers() .AddJsonOptions(opts => opts.JsonSerializerOptions.Converters.Add( new System.Text.Json.Serialization.JsonStringEnumConverter())); builder.Services.AddHealthChecks() .AddDbContextCheck(); var app = builder.Build(); // Block startup until OIDC discovery succeeds (handles auth/app container race). var jwtOpts = app.Services.GetRequiredService>() .Get(JwtBearerDefaults.AuthenticationScheme); var startupLogger = app.Services.GetRequiredService().CreateLogger("Startup"); var deadline = DateTime.UtcNow + TimeSpan.FromMinutes(2); while (true) { try { jwtOpts.ConfigurationManager!.RequestRefresh(); await jwtOpts.ConfigurationManager!.GetConfigurationAsync(CancellationToken.None); startupLogger.LogInformation("OIDC discovery succeeded"); break; } catch (Exception ex) { if (DateTime.UtcNow >= deadline) { startupLogger.LogWarning(ex, "OIDC discovery timed out after 2 min; continuing startup"); break; } startupLogger.LogWarning(ex, "OIDC discovery failed; retrying in 5 s"); Thread.Sleep(TimeSpan.FromSeconds(5)); } } // Apply EF migrations automatically on startup using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); } app.UseForwardedHeaders(); app.UseMiddleware(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); app.UseRateLimiter(); app.MapControllers(); app.MapHealthChecks("/healthz", new HealthCheckOptions { ResultStatusCodes = { [HealthStatus.Healthy] = StatusCodes.Status200OK, [HealthStatus.Degraded] = StatusCodes.Status200OK, [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable, } }); app.MapFallbackToFile("index.html"); app.Run();