using System.Threading.RateLimiting; using Budget.Api.Services; using Budget.Infrastructure.Data; using Budget.Infrastructure.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using OpenTelemetry; using OpenTelemetry.Resources; using OpenTelemetry.Trace; var builder = WebApplication.CreateBuilder(args); var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; if (!string.IsNullOrEmpty(otlpEndpoint)) { builder.Logging.AddOpenTelemetry(logging => { logging.IncludeFormattedMessage = true; logging.IncludeScopes = true; }); builder.Services.AddOpenTelemetry() .ConfigureResource(resource => resource .AddService(serviceName: "budget", serviceVersion: "1.0.0")) .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation(opts => opts.Filter = ctx => ctx.Request.Path.StartsWithSegments("/api")) .AddHttpClientInstrumentation() .AddEntityFrameworkCoreInstrumentation()) .UseOtlpExporter(); } 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.AddSingleton(); builder.Services.AddDbContext((sp, opt) => { opt.UseNpgsql(connStr); opt.AddInterceptors(sp.GetRequiredService()); }); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); var trustedNetworks = builder.Configuration["Budget:TrustedProxyNetworks"] ?? "172.16.0.0/12"; foreach (var cidr in trustedNetworks.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var parts = cidr.Split('/'); var prefix = System.Net.IPAddress.Parse(parts[0]); var prefixLength = parts.Length > 1 ? int.Parse(parts[1]) : (prefix.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? 128 : 32); options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength)); } }); 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())); 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"); startupLogger.LogInformation("Trusted proxy networks: {Networks}", app.Configuration["Budget:TrustedProxyNetworks"] ?? "172.16.0.0/12 (default)"); 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"); await Task.Delay(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(); // Authentication must run before the rate limiter so limiter partitions can // read the validated "sub" claim; otherwise every authenticated request falls // back to the per-IP bucket (auth-hardening-review.md, finding B-1). app.UseAuthentication(); app.UseRateLimiter(); app.UseAuthorization(); app.MapControllers(); app.MapFallbackToFile("index.html"); app.Run();