diff --git a/Components/Shared/AppInitializer.razor b/Components/Shared/AppInitializer.razor index 2cd1b23..6161c77 100644 --- a/Components/Shared/AppInitializer.razor +++ b/Components/Shared/AppInitializer.razor @@ -1,6 +1,7 @@ @inject AuthenticationStateProvider AuthProvider @inject IUserService UserService @inject IUserContextAccessor UserContext +@inject ILogger Logger @using Microsoft.AspNetCore.Components.Authorization @using SharepointToolbox.Web.Services.Auth @using SharepointToolbox.Web.Services.Session @@ -12,13 +13,30 @@ { var state = await AuthProvider.GetAuthenticationStateAsync(); var principal = state.User; - if (principal.Identity?.IsAuthenticated != true) return; + + if (principal.Identity?.IsAuthenticated != true) + { + Logger.LogWarning("AppInitializer: circuit principal NOT authenticated; UserContext left unseeded → page stays on loading."); + return; + } var email = principal.FindFirst("preferred_username")?.Value ?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; - if (string.IsNullOrEmpty(email)) return; + if (string.IsNullOrEmpty(email)) + { + var claims = string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}")); + Logger.LogWarning("AppInitializer: authenticated but no preferred_username/email claim. Claims present: [{Claims}]", claims); + return; + } var user = await UserService.GetByEmailAsync(email); - if (user is not null) UserContext.Initialize(user); + if (user is null) + { + Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", email); + return; + } + + Logger.LogInformation("AppInitializer: seeded UserContext for '{Email}' (role {Role}).", user.Email, user.Role); + UserContext.Initialize(user); } } diff --git a/Program.cs b/Program.cs index 5b45224..1b8c944 100644 --- a/Program.cs +++ b/Program.cs @@ -4,6 +4,7 @@ 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; @@ -43,6 +44,19 @@ 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 @@ -100,7 +114,13 @@ else options.ClientId = oidc["ClientId"]; options.ClientSecret = oidc["ClientSecret"]; options.ResponseType = OpenIdConnectResponseType.Code; - options.SaveTokens = true; + // 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"); @@ -212,6 +232,34 @@ 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);