using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Serilog; using SharepointToolbox.Web.Core.Config; using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Infrastructure.Auth; using SharepointToolbox.Web.Infrastructure.OAuth; using SharepointToolbox.Web.Infrastructure.Persistence; using SharepointToolbox.Web.Services; using SharepointToolbox.Web.Services.Audit; using SharepointToolbox.Web.Services.Auth; using SharepointToolbox.Web.Services.Export; using SharepointToolbox.Web.Services.OAuth; using SharepointToolbox.Web.Services.Reports; using SharepointToolbox.Web.Services.Session; var builder = WebApplication.CreateBuilder(args); // ── Serilog ─────────────────────────────────────────────────────────────────── var dataFolder = builder.Configuration["DataFolder"] ?? "/data"; Directory.CreateDirectory(dataFolder); Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) .WriteTo.Console() .WriteTo.File( Path.Combine(dataFolder, "logs", "app-.log"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30) .CreateLogger(); builder.Host.UseSerilog(); // ── Blazor / Razor Components ───────────────────────────────────────────────── builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); builder.Services.AddHttpContextAccessor(); // ── Forwarded headers ───────────────────────────────────────────────────────── // Behind a TLS-terminating reverse proxy the app receives plain HTTP; without this // it would see scheme=http and build an http:// OIDC redirect_uri (which Entra // rejects for non-localhost hosts) and set the auth cookie non-Secure. Honour // X-Forwarded-Proto/For so the app sees the real https scheme + client IP. Proxy IP // is unknown inside the container network, so don't restrict to known proxies. builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); }); // ── Data Protection ─────────────────────────────────────────────────────────── // Keys MUST persist across container recreates: they encrypt the auth cookie AND // the app-only certs on disk (/data/appcerts). Default storage is the container's // ephemeral home dir, so a redeploy would log everyone out and make stored certs // undecryptable. Pin keys + app name to the data volume. builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(dataFolder, "dpkeys"))) .SetApplicationName("SharepointToolbox.Web"); // Localization string source — Scoped: one per circuit, with its own explicit culture. builder.Services.AddScoped(); // ── Authentication ──────────────────────────────────────────────────────────── if (builder.Environment.IsDevelopment()) { // Dev: cookie-only, no OIDC. /account/login auto-signs in a hardcoded Admin. builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/account/login"; options.LogoutPath = "/account/logout"; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; }); } else { builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; // Challenge the cookie scheme (→ redirect to /account/login, the combined // local + Microsoft page). OIDC is triggered explicitly from the "Sign in // with Microsoft" button (/account/login/entra), never as the implicit // challenge — otherwise logged-out hits on protected pages force OIDC and // 404 when it is unconfigured/unreachable. options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie(options => { options.LoginPath = "/account/login"; options.LogoutPath = "/account/logout"; // Auth state lives entirely in the browser cookie (Data Protection encrypted) options.SessionStore = null; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; }) .AddOpenIdConnect(options => { var oidc = builder.Configuration.GetSection("Oidc"); options.Authority = $"https://login.microsoftonline.com/{oidc["TenantId"]}/v2.0"; options.ClientId = oidc["ClientId"]; options.ClientSecret = oidc["ClientSecret"]; options.ResponseType = OpenIdConnectResponseType.Code; // Do NOT persist the OIDC access/id/refresh tokens in the auth cookie. They are // never read (SharePoint/Graph auth runs through the separate connect flow + // app-only cert paths), and storing them bloats the cookie past ~4 KB so it gets // chunked. The chunked cookie survives the prerender GET but is dropped on the // WebSocket upgrade that establishes the interactive circuit → the circuit comes // up anonymous and the app sticks on "Chargement…". Keeping the cookie small fixes it. options.SaveTokens = false; options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("email"); options.GetClaimsFromUserInfoEndpoint = true; options.MapInboundClaims = false; options.TokenValidationParameters.NameClaimType = "preferred_username"; options.Events.OnTokenValidated = async ctx => { var userService = ctx.HttpContext.RequestServices.GetRequiredService(); await userService.ProvisionAsync(ctx.Principal!); }; }); } builder.Services.AddAuthorization(); // ── Memory cache (used by OAuth flow cache) ─────────────────────────────────── builder.Services.AddMemoryCache(); builder.Services.AddHttpClient("oauth"); // ── ClientConnect options ───────────────────────────────────────────────────── builder.Services.Configure(builder.Configuration.GetSection("ClientConnect")); // ── App config ──────────────────────────────────────────────────────────────── var certsFolder = Path.Combine(dataFolder, "appcerts"); builder.Services.Configure(opt => { opt.DataFolder = dataFolder; opt.ExportsFolder = Path.Combine(dataFolder, "exports"); opt.CertsFolder = certsFolder; Directory.CreateDirectory(opt.ExportsFolder); Directory.CreateDirectory(opt.CertsFolder); }); // ── Persistence (Singleton — files on disk) ─────────────────────────────────── builder.Services.AddSingleton(new ProfileRepository(Path.Combine(dataFolder, "profiles.json"))); builder.Services.AddSingleton(new SettingsRepository(Path.Combine(dataFolder, "settings.json"))); builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates"))); builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json"))); builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl"))); builder.Services.AddSingleton(new ScheduledReportRepository(Path.Combine(dataFolder, "schedules.json"))); builder.Services.AddSingleton(new GeneratedReportRepository(Path.Combine(dataFolder, "reports-index.json"))); // ── App-only (unattended) auth for scheduled reports ────────────────────────── builder.Services.AddSingleton(sp => new AppOnlyCertStore(certsFolder, sp.GetRequiredService())); builder.Services.AddSingleton(); // ── Auth infrastructure ─────────────────────────────────────────────────────── builder.Services.AddSingleton, PasswordHasher>(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddScoped(); // ── User session (Scoped = one per Blazor circuit = one per browser tab) ───── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // ── Audit (Scoped — reads user context from circuit) ───────────────────────── builder.Services.AddScoped(); // ── Business services (Scoped — each user circuit gets its own instances) ───── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // ── Export services (Scoped) ────────────────────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // ── Scheduled reports (background generation) ───────────────────────────────── builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); var app = builder.Build(); // Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies). app.UseForwardedHeaders(); // ── First-run bootstrap ─────────────────────────────────────────────────────── // Seed a local admin when no users exist yet, so a plain-HTTP / LAN deployment that // can't use Microsoft OIDC (which requires HTTPS + a matching Entra redirect URI) can // still sign in via the local email/password form. Only fires while the user store is // empty; set Bootstrap__AdminEmail and Bootstrap__AdminPassword to enable. { var bootEmail = app.Configuration["Bootstrap:AdminEmail"]; var bootPass = app.Configuration["Bootstrap:AdminPassword"]; if (!string.IsNullOrWhiteSpace(bootEmail) && !string.IsNullOrWhiteSpace(bootPass)) { var users = app.Services.GetRequiredService(); // Seed if this email has no account yet — covers both an empty store and a store // that already holds an Entra-provisioned user from a failed sign-in attempt. if (await users.GetByEmailAsync(bootEmail) is null) { await users.CreateLocalUserAsync(bootEmail, "Administrator", UserRole.Admin, bootPass); Log.Information("Bootstrap: created local admin {Email}.", bootEmail); } else { Log.Information("Bootstrap: local admin {Email} already present; skipping seed.", bootEmail); } } } if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); } // Re-execute unmatched (404) requests into the branded not-found page app.UseStatusCodePagesWithReExecute("/not-found"); app.MapStaticAssets(); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); // ── Login / Logout endpoints ────────────────────────────────────────────────── var isDev = app.Environment.IsDevelopment(); // Combined login page. Dev: local form + "Quick sign in as Dev Admin" (no OIDC scheme registered). // Prod: local form + "Sign in with Microsoft". app.MapGet("/account/login", (HttpContext ctx, IAntiforgery antiforgery, string? returnUrl, bool? error) => { var html = LoginPageRenderer.Build( ctx, antiforgery, returnUrl, error == true, showEntra: !isDev, showDevButton: isDev); return Results.Content(html, "text/html"); }); if (isDev) { // Dev shortcut: provision + sign in the hardcoded Dev Admin (first run = Admin). app.MapGet("/account/login/dev", async (HttpContext ctx, string? returnUrl, IUserService userService) => { const string devEmail = "dev@local.test"; const string devName = "Dev Admin"; var provisionPrincipal = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim("preferred_username", devEmail), new Claim("name", devName) }, CookieAuthenticationDefaults.AuthenticationScheme)); var user = await userService.ProvisionAsync(provisionPrincipal); var principal = new ClaimsPrincipal(new ClaimsIdentity( new Claim[] { new("preferred_username", devEmail), new("name", devName), new("app_role", user.Role.ToString()), }, CookieAuthenticationDefaults.AuthenticationScheme)); await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); }); } else { // Microsoft / Entra OIDC challenge (the "Sign in with Microsoft" button). app.MapGet("/account/login/entra", async (HttpContext ctx, string? returnUrl) => { var props = new AuthenticationProperties { RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl }; await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props); }); } // Local password sign-in — available in every environment. app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antiforgery, IUserService userService) => { try { await antiforgery.ValidateRequestAsync(ctx); } catch (AntiforgeryValidationException) { return Results.BadRequest(); } var form = await ctx.Request.ReadFormAsync(); var email = form["email"].ToString(); var password = form["password"].ToString(); var returnUrl = form["returnUrl"].ToString(); var safeReturn = string.IsNullOrEmpty(returnUrl) || !returnUrl.StartsWith('/') ? "/" : returnUrl; var user = await userService.ValidateLocalCredentialsAsync(email, password); if (user is null) return Results.Redirect($"/account/login?error=true&returnUrl={Uri.EscapeDataString(safeReturn)}"); var principal = new ClaimsPrincipal(new ClaimsIdentity( new Claim[] { new("preferred_username", user.Email), new("name", user.DisplayName), new("app_role", user.Role.ToString()), new("auth_provider", nameof(AuthProvider.Local)), }, CookieAuthenticationDefaults.AuthenticationScheme)); await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); return Results.Redirect(safeReturn); }); app.MapGet("/account/logout", async (HttpContext ctx) => { // Local/dev accounts only hold the cookie; Entra accounts also have an OIDC session to end. var isLocal = ctx.User.HasClaim("auth_provider", nameof(AuthProvider.Local)); await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); if (!isDev && !isLocal && ctx.User.Identity?.IsAuthenticated == true) await ctx.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/" }); else ctx.Response.Redirect("/"); }); // ── OAuth2 connect endpoints ────────────────────────────────────────────────── app.MapOAuthEndpoints(); // ── File download endpoint ──────────────────────────────────────────────────── app.MapGet("/export/download/{fileName}", async (string fileName, IOptions opts, HttpContext ctx) => { if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized(); var path = Path.Combine(opts.Value.ExportsFolder, Path.GetFileName(fileName)); if (!File.Exists(path)) return Results.NotFound(); var bytes = await File.ReadAllBytesAsync(path); var ct = fileName.EndsWith(".csv") ? "text/csv" : "text/html"; return Results.File(bytes, ct, fileName); }); // ── Scheduled report download (id-based, scoped to the client's exports subfolder) ── app.MapGet("/reports/download/{id}", async (string id, GeneratedReportRepository index, IOptions opts, HttpContext ctx) => { if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized(); var report = await index.GetAsync(id); if (report is null || report.Status != ReportRunStatus.Success || string.IsNullOrEmpty(report.FileName)) return Results.NotFound(); // ProfileId and FileName are app-generated; GetFileName strips any traversal just in case. var path = Path.Combine(opts.Value.ExportsFolder, report.ProfileId, Path.GetFileName(report.FileName)); if (!File.Exists(path)) return Results.NotFound(); var bytes = await File.ReadAllBytesAsync(path); var mime = string.IsNullOrEmpty(report.Mime) ? "application/octet-stream" : report.Mime; return Results.File(bytes, mime, report.FileName); }); // ── Audit CSV download ──────────────────────────────────────────────────────── app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) => { if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized(); // Role check via the app-role claim set during OIDC provisioning var rolesClaim = ctx.User.FindFirst("app_role")?.Value; if (rolesClaim != nameof(UserRole.Admin)) return Results.Forbid(); var entries = await auditRepo.LoadAllAsync(); var sb = new System.Text.StringBuilder(); sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details"); foreach (var e in entries.OrderByDescending(x => x.Timestamp)) { string Esc(string v) => v.Contains(',') || v.Contains('"') || v.Contains('\n') ? $"\"{v.Replace("\"", "\"\"")}\"" : v; sb.AppendLine(string.Join(",", Esc(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")), Esc(e.UserEmail), Esc(e.UserDisplay), Esc(e.UserRole.ToString()), Esc(e.Action), Esc(e.ClientName), Esc(string.Join("; ", e.Sites)), Esc(e.Details))); } return Results.File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "audit-log.csv"); }); app.MapRazorComponents() .AddInteractiveServerRenderMode(); app.Run();