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.AspNetCore.RateLimiting; using System.Threading.RateLimiting; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Serilog; using SharepointToolbox.Web.Core.Config; using SharepointToolbox.Web.Core.Helpers; 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(); // ── Public domain ───────────────────────────────────────────────────────────── // App__Domain (e.g. sptb.example.com) drives both OIDC sign-in (below) and the // SharePoint-connect redirect URI. Bound once here so both consumers share it. var appDomain = new AppDomainOptions(); builder.Configuration.GetSection("App").Bind(appDomain); // ── 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; // Always mark the auth cookie Secure in prod. The app sits behind a TLS-terminating // proxy and forwarded headers are trusted from any source (proxy IP is unknown inside // the container network), so SameAsRequest would let a spoofed X-Forwarded-Proto: http // — or any direct plaintext hit — emit a non-Secure cookie. Always wins regardless. options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; }) .AddOpenIdConnect(options => { var oidc = builder.Configuration.GetSection("Oidc"); // Strip accidental surrounding quotes/whitespace. docker-compose's `environment` list form // (`- Oidc__TenantId=""`) embeds the literal quotes in the value, producing a malformed // Authority (…/""/v2.0) that fails metadata discovery with IDX20803. Same trap on the // secret would silently break the token exchange. Trim defensively. static string Clean(string? v) => v?.Trim().Trim('"', '\'') ?? string.Empty; options.Authority = $"https://login.microsoftonline.com/{Clean(oidc["TenantId"])}/v2.0"; options.ClientId = Clean(oidc["ClientId"]); options.ClientSecret = Clean(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"; // When App__Domain is set, pin the OIDC redirect_uri (and post-logout redirect) to that // public host instead of deriving it from the request scheme/host. Keeps sign-in working // when the app can't see its real external host (no/incorrect forwarded Host header, or // several hostnames reach the same instance). The value must match the /signin-oidc URI // registered on the Oidc app. The authorize request and the code→token redemption MUST // send the identical redirect_uri, so override it in both events. var oidcRedirectUri = appDomain.BuildUrl(options.CallbackPath.Value ?? "/signin-oidc"); var postLogoutUri = appDomain.BuildUrl(options.SignedOutCallbackPath.Value ?? "/signout-callback-oidc"); if (oidcRedirectUri is not null) { options.Events.OnRedirectToIdentityProvider = ctx => { ctx.ProtocolMessage.RedirectUri = oidcRedirectUri; return Task.CompletedTask; }; options.Events.OnAuthorizationCodeReceived = ctx => { if (ctx.TokenEndpointRequest is not null) ctx.TokenEndpointRequest.RedirectUri = oidcRedirectUri; return Task.CompletedTask; }; options.Events.OnRedirectToIdentityProviderForSignOut = ctx => { ctx.ProtocolMessage.PostLogoutRedirectUri = postLogoutUri; return Task.CompletedTask; }; } options.Events.OnTokenValidated = async ctx => { var userService = ctx.HttpContext.RequestServices.GetRequiredService(); var user = await userService.ProvisionAsync(ctx.Principal!); // The whole principal is serialized into the auth cookie. The raw OIDC principal carries // dozens of id_token + userinfo claims (oid, tid, given/family_name, a long picture URL …); // encrypted + base64 it exceeds ~4 KB, so ChunkingCookieManager splits it into …CookiesC1/C2. // The chunked cookie survives the prerender GET but is dropped on the Blazor WebSocket upgrade // → the interactive circuit comes up anonymous → page sticks on "Chargement…". Replace it with // a slim principal holding only the claims the app reads — identical to the local-login path — // so the cookie stays small (single, unchunked) and the circuit authenticates. This also adds // the app_role claim (role-based authz) and auth_provider (logout's OIDC sign-out branch), // which the fat OIDC principal never had. var identity = new ClaimsIdentity( new Claim[] { new("preferred_username", user.Email), new("name", user.DisplayName), new("app_role", user.Role.ToString()), new("auth_provider", nameof(AuthProvider.Entra)), }, ctx.Principal!.Identity!.AuthenticationType, "preferred_username", "app_role"); ctx.Principal = new ClaimsPrincipal(identity); }; }); } // "Admin" policy checks the app_role claim value directly, rather than [Authorize(Roles=…)] // — the local/dev sign-in identities don't set a ClaimTypes.Role claim, so a Roles check would // silently deny local admins. Every identity (OIDC, local, dev) carries app_role. builder.Services.AddAuthorization(options => { options.AddPolicy("Admin", p => p.RequireClaim("app_role", nameof(UserRole.Admin))); }); // ── Rate limiting ─────────────────────────────────────────────────────────────── // Volumetric defence on the sign-in endpoints, partitioned by client IP (RemoteIpAddress // reflects X-Forwarded-For — UseForwardedHeaders runs first). This is the coarse layer; // the per-account lockout in UserService is what holds up when XFF is spoofed/rotated, // since forwarded headers are trusted from any source behind the proxy. builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("login", httpContext => RateLimitPartition.GetFixedWindowLimiter( partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, })); }); // ── Memory cache (used by OAuth flow cache) ─────────────────────────────────── builder.Services.AddMemoryCache(); builder.Services.AddHttpClient("oauth"); // ── ClientConnect options ───────────────────────────────────────────────────── builder.Services.Configure(builder.Configuration.GetSection("ClientConnect")); // Derive the SharePoint-connect redirect URI from the app's public domain (App__Domain) // when ClientConnect__RedirectUri isn't set explicitly. Lets a deployment configure a // single domain (e.g. sptb.example.com) instead of spelling out the full callback URL. // An explicit RedirectUri still wins, so existing configs are unaffected. builder.Services.PostConfigure(opts => { if (string.IsNullOrWhiteSpace(opts.RedirectUri) && appDomain.BuildUrl("/connect/callback") is { } callback) { opts.RedirectUri = callback; } }); // ── 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(); // When App__Domain is set, rewrite every request's scheme + host to the public domain. The // framework builds absolute URLs (the cookie login redirect, the OIDC redirect_uri, …) from // Request.Scheme/Host; behind a proxy that doesn't forward the Host header these are the // internal host (server IP:port), so loading https:/// would 302 to http://:8080. // Forcing the host here keeps every generated URL on the public domain. Must run before auth. var publicBaseUri = appDomain.GetBaseUri(); if (publicBaseUri is not null) { var publicHost = HostString.FromUriComponent(publicBaseUri); app.Use((context, next) => { context.Request.Scheme = publicBaseUri.Scheme; context.Request.Host = publicHost; return next(context); }); } // ── 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); } } } // ── Security response headers ─────────────────────────────────────────────────── // Defence-in-depth on every response. CSP is tuned to this app: all scripts are external // (_framework/blazor.web.js, js/app.js) so script-src can stay 'self' with no unsafe-*; // the login page and the Blazor components use inline