Files
budget/src/Budget.Api/Program.cs
T
2026-05-03 06:15:29 -05:00

158 lines
5.3 KiB
C#

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<AppDbContext>(opt => opt.UseNpgsql(connStr));
builder.Services.Configure<ForwardedHeadersOptions>(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<HttpContext, string>(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<BudgetAuthorizationService>();
builder.Services.AddControllers()
.AddJsonOptions(opts =>
opts.JsonSerializerOptions.Converters.Add(
new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Block startup until OIDC discovery succeeds (handles auth/app container race).
var jwtOpts = app.Services.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
.Get(JwtBearerDefaults.AuthenticationScheme);
var startupLogger = app.Services.GetRequiredService<ILoggerFactory>().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<AppDbContext>();
await db.Database.MigrateAsync();
}
app.UseForwardedHeaders();
app.UseMiddleware<ErrorHandlingMiddleware>();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseMiddleware<KnownUserMiddleware>();
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();