using System.Security.Claims; using Asp.Versioning; 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.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; 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. builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys")) .SetApplicationName("InboxIntel"); 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; options.Cookie.Name = "inboxintel.session"; options.ExpireTimeSpan = TimeSpan.FromDays(7); options.SlidingExpiration = true; // 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(); 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(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); 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)) await db.Database.MigrateAsync(); } if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // 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. var forwardedOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost }; forwardedOptions.KnownNetworks.Clear(); forwardedOptions.KnownProxies.Clear(); app.UseForwardedHeaders(forwardedOptions); app.UseSerilogRequestLogging(); app.UseCors("frontend"); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run(); public partial class Program { }