using System.Security.Claims; using System.Threading.RateLimiting; using Asp.Versioning; using FluentValidation.AspNetCore; using InboxIntel.Api.Auth; using InboxIntel.Application; using InboxIntel.Application.Abstractions; using InboxIntel.Infrastructure; using InboxIntel.Infrastructure.Configuration; using InboxIntel.Infrastructure.Persistence; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Npgsql; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; var builder = WebApplication.CreateBuilder(args); // Structured logging (Serilog). Note: token values are never logged. builder.Host.UseSerilog((ctx, cfg) => cfg .ReadFrom.Configuration(ctx.Configuration) .Enrich.FromLogContext() .WriteTo.Console()); // Persist Data Protection keys so encrypted refresh tokens survive restarts. // AUDIT M-3: optionally encrypt the Data Protection key ring with an X.509 certificate so // the keys are not readable in plaintext from the /keys volume (which would otherwise let // anyone with volume access decrypt all stored refresh tokens). Configure // DataProtection:CertificatePath (+ CertificatePassword) to enable; without it, keys are // persisted unprotected and a startup warning documents the residual risk. var dp = builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys")) .SetApplicationName("InboxIntel"); var dpCertPath = builder.Configuration["DataProtection:CertificatePath"]; if (!string.IsNullOrWhiteSpace(dpCertPath)) { dp.ProtectKeysWithCertificate(System.Security.Cryptography.X509Certificates.X509CertificateLoader .LoadPkcs12FromFile(dpCertPath, builder.Configuration["DataProtection:CertificatePassword"])); } builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); // Authentication: cookie session established via Google OAuth2 (only login method). var google = builder.Configuration.GetSection(GoogleOAuthOptions.SectionName).Get() ?? new(); builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; // Challenge via the cookie scheme so unauthenticated API (XHR) calls get a // 401 instead of a redirect to Google. The SPA's axios interceptor turns // that 401 into a top-level navigation to /auth/login, which then starts // the Google flow explicitly. (A 302 to Google on an XHR is CORS-blocked.) options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie(options => { options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; // V-04: never emit the session cookie over plain HTTP in non-dev. Behind nginx // the forwarded proto (now only trusted from known proxies, see below) makes // Always work; local http://localhost dev still functions via SameAsRequest. options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; options.Cookie.Name = "inboxintel.session"; options.ExpireTimeSpan = TimeSpan.FromDays(7); options.SlidingExpiration = true; // AUDIT M-1: sliding expiration alone lets a stolen cookie renew forever. Stamp an // absolute start at sign-in and reject principals older than the configured cap, // forcing a full re-login. (Pre-existing sessions without the stamp are rejected // once — a single re-login, then they carry the stamp.) var absoluteDays = builder.Configuration.GetValue("Auth:AbsoluteSessionDays", 30); options.Events.OnSigningIn = ctx => { ctx.Properties.SetString("abs-start", DateTimeOffset.UtcNow.ToString("O")); return Task.CompletedTask; }; options.Events.OnValidatePrincipal = async ctx => { if (SessionLifetime.IsExpired(ctx.Properties.GetString("abs-start"), DateTimeOffset.UtcNow, TimeSpan.FromDays(absoluteDays))) { ctx.RejectPrincipal(); await ctx.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); } }; // API-style behaviour: return status codes rather than redirecting to a login page. options.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }; options.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; }; }) .AddGoogle(options => { options.ClientId = google.ClientId; options.ClientSecret = google.ClientSecret; options.AccessType = "offline"; // request a refresh token options.SaveTokens = true; foreach (var scope in google.Scopes) options.Scope.Add(scope); options.Events.OnCreatingTicket = GoogleAuthEvents.OnCreatingTicketAsync; // Force the consent screen so Google ALWAYS returns a refresh token. // Without this, Google omits the refresh token on re-authorisation, // leaving offline Gmail sync with no usable credential. options.Events.OnRedirectToAuthorizationEndpoint = context => { context.Response.Redirect(context.RedirectUri + "&prompt=consent"); return Task.CompletedTask; }; }); builder.Services.AddAuthorization(); // RECOMMENDATIONS #5: OpenTelemetry traces + metrics (ASP.NET, outbound HTTP, Npgsql). // The OTLP exporter only activates when Otel:Endpoint (or the standard // OTEL_EXPORTER_OTLP_ENDPOINT env var) is configured — zero overhead otherwise. // Logs stay on Serilog. Pair with the compose "observability" profile (grafana/otel-lgtm). var otlpEndpoint = builder.Configuration["Otel:Endpoint"] ?? Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT"); if (!string.IsNullOrWhiteSpace(otlpEndpoint)) { builder.Services.AddOpenTelemetry() .ConfigureResource(r => r.AddService("inboxintel-api")) .WithTracing(t => t .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddNpgsql() .AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint))) .WithMetrics(m => m .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddNpgsqlInstrumentation() .AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint))); } builder.Services.AddApiVersioning(o => { o.DefaultApiVersion = new ApiVersion(1, 0); o.AssumeDefaultVersionWhenUnspecified = true; o.ReportApiVersions = true; o.ApiVersionReader = new UrlSegmentApiVersionReader(); }).AddApiExplorer(o => { o.GroupNameFormat = "'v'VVV"; o.SubstituteApiVersionInUrl = true; }); builder.Services.AddControllers(); // AUDIT H-1: the validators in InboxIntel.Application/Validation were registered but never // executed (FluentValidation 11.x needs explicit auto-validation). This wires them into // model binding so invalid DTOs 400 at the boundary instead of reaching services. builder.Services.AddFluentValidationAutoValidation(); // AUDIT H-2: rate limiting. Global per-user (or per-IP when anonymous) window, plus stricter // named policies for auth and expensive endpoints (export/unsubscribe/AI). Limits are // config-driven so tests and deployments can tune them. var rl = builder.Configuration.GetSection("RateLimiting"); int Limit(string key, int def) => rl.GetValue(key, def); var rlWindow = TimeSpan.FromSeconds(Limit("WindowSeconds", 60)); static string Partition(HttpContext ctx) => ctx.User.Identity?.IsAuthenticated == true ? ctx.User.FindFirstValue("inboxintel:uid") ?? "auth-unknown" : ctx.Connection.RemoteIpAddress?.ToString() ?? "anon"; builder.Services.AddRateLimiter(o => { o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; o.GlobalLimiter = PartitionedRateLimiter.Create(ctx => RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = Limit("GlobalPermitLimit", 300), Window = rlWindow, QueueLimit = 0 })); o.AddPolicy("auth", ctx => RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = Limit("AuthPermitLimit", 10), Window = rlWindow, QueueLimit = 0 })); o.AddPolicy("expensive", ctx => RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = Limit("ExpensivePermitLimit", 20), Window = rlWindow, QueueLimit = 0 })); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // V-06: RFC7807 ProblemDetails so the global exception handler returns a safe, // generic error body instead of leaking framework stack traces / internal messages. builder.Services.AddProblemDetails(); builder.Services.AddCors(o => o.AddPolicy("frontend", p => p .WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get() ?? new[] { "http://localhost:5173" }) .AllowAnyHeader().AllowAnyMethod().AllowCredentials())); var app = builder.Build(); // Apply migrations on startup so `docker-compose up` yields a ready schema. using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); if (db.Database.IsRelational() && app.Configuration.GetValue("Database:AutoMigrate", true)) { // AUDIT M-4: the shipped appsettings no longer carries a guessable default DB // password. Fail fast with a clear message rather than connecting with weak or // missing credentials (compose/staging/prod inject the full connection string). var connStr = app.Configuration.GetConnectionString("Postgres") ?? string.Empty; var csb = new Npgsql.NpgsqlConnectionStringBuilder(connStr); if (string.IsNullOrWhiteSpace(csb.Password)) throw new InvalidOperationException( "ConnectionStrings:Postgres has no password. Set the full connection string via " + "environment/user-secrets (see README) — a default password is deliberately not shipped."); await db.Database.MigrateAsync(); } } // Honor X-Forwarded-* from the nginx reverse proxy so OAuth redirect URIs and // cookie Secure flags reflect the external scheme/host, not the container's. // V-08: only trust these headers from KNOWN proxy networks (configurable). The // default covers private/Docker ranges so the compose nginx works, while a client // reaching the API directly can no longer spoof scheme/host/forwarded-for. var forwardedOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost, ForwardLimit = app.Configuration.GetValue("ForwardedHeaders:ForwardLimit") ?? 1 }; forwardedOptions.KnownIPNetworks.Clear(); forwardedOptions.KnownProxies.Clear(); var trustedNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get() ?? new[] { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128" }; foreach (var cidr in trustedNetworks) { if (System.Net.IPNetwork.TryParse(cidr, out var network)) forwardedOptions.KnownIPNetworks.Add(network); } app.UseForwardedHeaders(forwardedOptions); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } else { // V-06: generic ProblemDetails for unhandled exceptions (no stack traces to clients). app.UseExceptionHandler(); // V-11: HSTS once TLS is enforced at the proxy (forwarded proto now trustworthy). app.UseHsts(); } // V-11: baseline security response headers. CSP is report-only for now so it can be // tuned against the SPA before enforcing (the SPA itself is also served with headers // by its nginx). Applied to API responses here as defense-in-depth. app.Use(async (ctx, next) => { var h = ctx.Response.Headers; h["X-Content-Type-Options"] = "nosniff"; h["X-Frame-Options"] = "DENY"; h["Referrer-Policy"] = "no-referrer"; h["Cross-Origin-Opener-Policy"] = "same-origin"; await next(); }); app.UseSerilogRequestLogging(); app.UseCors("frontend"); app.UseAuthentication(); // AUDIT H-2: after authentication so authenticated traffic partitions per-user; anonymous // traffic partitions per-IP. Endpoint policies ("auth", "expensive") apply via attributes. app.UseRateLimiter(); app.UseAuthorization(); app.MapControllers(); app.Run(); public partial class Program { } /// /// AUDIT M-1: absolute session lifetime check, extracted for unit testing. A session with no /// issued stamp (pre-dating this feature) is treated as expired — one forced re-login. /// public static class SessionLifetime { public static bool IsExpired(string? issuedAtIso, DateTimeOffset now, TimeSpan maxAge) => !DateTimeOffset.TryParse(issuedAtIso, out var issued) || now - issued > maxAge; }