Fix stuck-on-loading after sign-in; enable HTTP/LAN local login #3

Merged
kawa merged 5 commits from fix/prod-auth-http-deploy into main 2026-06-10 11:54:10 +02:00
2 changed files with 70 additions and 4 deletions
Showing only changes of commit c23039efa1 - Show all commits
+21 -3
View File
@@ -1,6 +1,7 @@
@inject AuthenticationStateProvider AuthProvider @inject AuthenticationStateProvider AuthProvider
@inject IUserService UserService @inject IUserService UserService
@inject IUserContextAccessor UserContext @inject IUserContextAccessor UserContext
@inject ILogger<AppInitializer> Logger
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using SharepointToolbox.Web.Services.Auth @using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
@@ -12,13 +13,30 @@
{ {
var state = await AuthProvider.GetAuthenticationStateAsync(); var state = await AuthProvider.GetAuthenticationStateAsync();
var principal = state.User; 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 var email = principal.FindFirst("preferred_username")?.Value
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.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); 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);
} }
} }
+49 -1
View File
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
@@ -43,6 +44,19 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.AddHttpContextAccessor(); 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<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// ── Data Protection ─────────────────────────────────────────────────────────── // ── Data Protection ───────────────────────────────────────────────────────────
// Keys MUST persist across container recreates: they encrypt the auth cookie AND // 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 // the app-only certs on disk (/data/appcerts). Default storage is the container's
@@ -100,7 +114,13 @@ else
options.ClientId = oidc["ClientId"]; options.ClientId = oidc["ClientId"];
options.ClientSecret = oidc["ClientSecret"]; options.ClientSecret = oidc["ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code; 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("openid");
options.Scope.Add("profile"); options.Scope.Add("profile");
options.Scope.Add("email"); options.Scope.Add("email");
@@ -212,6 +232,34 @@ builder.Services.AddHostedService<ScheduledReportHostedService>();
var app = builder.Build(); 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<IUserService>();
// 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()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseExceptionHandler("/Error", createScopeForErrors: true);