Merge pull request 'Fix stuck-on-loading after sign-in; enable HTTP/LAN local login' (#3) from fix/prod-auth-http-deploy into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-06-10 11:54:10 +02:00
4 changed files with 208 additions and 10 deletions
+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);
} }
} }
+80 -5
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
@@ -96,11 +110,22 @@ else
.AddOpenIdConnect(options => .AddOpenIdConnect(options =>
{ {
var oidc = builder.Configuration.GetSection("Oidc"); var oidc = builder.Configuration.GetSection("Oidc");
options.Authority = $"https://login.microsoftonline.com/{oidc["TenantId"]}/v2.0"; // Strip accidental surrounding quotes/whitespace. docker-compose's `environment` list form
options.ClientId = oidc["ClientId"]; // (`- Oidc__TenantId="<guid>"`) embeds the literal quotes in the value, producing a malformed
options.ClientSecret = oidc["ClientSecret"]; // Authority (…/"<tenant>"/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; 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");
@@ -111,7 +136,29 @@ else
options.Events.OnTokenValidated = async ctx => options.Events.OnTokenValidated = async ctx =>
{ {
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>(); var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
await userService.ProvisionAsync(ctx.Principal!); 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);
}; };
}); });
} }
@@ -212,6 +259,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);
+11 -2
View File
@@ -28,13 +28,22 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se
| `Oidc__TenantId` | Entra tenant GUID | | `Oidc__TenantId` | Entra tenant GUID |
| `Oidc__ClientId` | App registration client ID | | `Oidc__ClientId` | App registration client ID |
| `Oidc__ClientSecret` | App registration client secret | | `Oidc__ClientSecret` | App registration client secret |
| `ClientConnect__RedirectUri` | Public callback URL, e.g. `https://your-host/connect/callback` |
| `DataFolder` | Persistent data path (default `/data`) | | `DataFolder` | Persistent data path (default `/data`) |
| `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC | | `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC |
> In `Development`, OIDC is disabled — the app uses a cookie-only auto-login (hardcoded Admin) for local work. > In `Development`, OIDC is disabled — the app uses a cookie-only auto-login (hardcoded Admin) for local work.
**Entra app registration** must include redirect URI `https://your-host/signin-oidc` and the Graph permissions required by the audit/reporting features (`GroupMember.Read.All`, `Group.Read.All`, `User.Read.All`). ### Two distinct OAuth flows — two redirect URIs
These are separate and registered on **different** Entra apps. Don't conflate them.
1. **App sign-in (OIDC).** Logging into the toolbox itself via "Sign in with Microsoft". Uses the `Oidc__*` app above. Callback path is the framework default `/signin-oidc` (not configurable here).
→ On **this** app registration, add redirect URI `https://your-host/signin-oidc` under the **Web** platform. This app also needs the Graph permissions the audit/reporting features require: `GroupMember.Read.All`, `Group.Read.All`, `User.Read.All`.
2. **SharePoint connect (per-profile).** Getting a delegated SharePoint/Graph token for a client tenant. A PKCE public-client flow that uses **each connection profile's own `ClientId`/`TenantId`** — not the `Oidc__*` app. `ClientConnect__RedirectUri` is the callback for this flow.
→ On **each client-tenant profile's** app registration, add the `ClientConnect__RedirectUri` value (e.g. `https://your-host/connect/callback`) under the **Mobile and desktop / public client** platform.
> **HTTPS note.** The sign-in app is a confidential (Web) client, so Entra requires its `/signin-oidc` redirect URI to be **HTTPS** — plain HTTP is allowed only for `http://localhost`, not a LAN host/IP. To run OIDC on a plain-HTTP LAN deployment, put the app behind an HTTPS-terminating reverse proxy: register `https://your-host/signin-oidc`, and the app honours `X-Forwarded-Proto` (see `UseForwardedHeaders`) to build the correct `https` redirect. Without a proxy, OIDC sign-in won't work over a non-localhost HTTP host — use the local email/password login instead.
Persistent state (profiles, settings, templates, logs, exports, certs) lives in `DataFolder`. Persistent state (profiles, settings, templates, logs, exports, certs) lives in `DataFolder`.
+96
View File
@@ -0,0 +1,96 @@
#requires -Version 5.1
<#
.SYNOPSIS
Build the SharepointToolbox.Web Docker image and push it to the Gitea
container registry at git.azuze.fr.
.DESCRIPTION
Builds the image from the local Dockerfile, tags it for the Gitea registry
(both the given tag and :latest), logs in, and pushes.
Login uses a Gitea access token, NOT your account password. Create one at:
git.azuze.fr -> Settings -> Applications -> Generate New Token
(scope: write:package — read:package too if you also pull)
Provide credentials via -Username / -Token, or set env vars
GITEA_USER / GITEA_TOKEN, or you'll be prompted.
.EXAMPLE
.\build-and-push.ps1
Builds and pushes :latest (prompts for token if not cached).
.EXAMPLE
.\build-and-push.ps1 -Tag v1.2.0
Builds and pushes both :v1.2.0 and :latest.
.EXAMPLE
$env:GITEA_USER='kawa'; $env:GITEA_TOKEN='xxxx'; .\build-and-push.ps1 -Tag v1.2.0
#>
[CmdletBinding()]
param(
[string]$Tag = 'latest',
[string]$Registry = 'git.azuze.fr',
[string]$Owner = 'kawa',
[string]$Image = 'sptb-web',
[string]$Username = $env:GITEA_USER,
[string]$Token = $env:GITEA_TOKEN,
[switch]$SkipLatest, # don't also tag/push :latest when -Tag is something else
[switch]$NoCache # build with --no-cache
)
$ErrorActionPreference = 'Stop'
Set-Location -LiteralPath $PSScriptRoot
function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 }
function Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
# --- preflight ---------------------------------------------------------------
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
Fail 'docker not on PATH. Start Docker Desktop / install docker CLI.'
}
try { docker info *> $null } catch { Fail 'docker daemon not reachable. Is Docker Desktop running?' }
if (-not (Test-Path .\Dockerfile)) { Fail "Dockerfile not found in $PSScriptRoot" }
$repo = "$Registry/$Owner/$Image"
$primary = "${repo}:$Tag"
$pushLatest = (-not $SkipLatest) -and ($Tag -ne 'latest')
# --- build -------------------------------------------------------------------
Step "Building $primary"
$buildArgs = @('build', '-t', $primary)
if ($pushLatest) { $buildArgs += @('-t', "${repo}:latest") }
if ($NoCache) { $buildArgs += '--no-cache' }
$buildArgs += '.'
docker @buildArgs
if ($LASTEXITCODE -ne 0) { Fail 'docker build failed.' }
# --- login -------------------------------------------------------------------
if (-not $Username) { $Username = Read-Host "Gitea username for $Registry" }
if (-not $Token) {
$sec = Read-Host "Gitea access token for $Registry (input hidden)" -AsSecureString
$Token = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
}
if (-not $Username -or -not $Token) { Fail 'Username/token required to push.' }
Step "Logging in to $Registry as $Username"
$Token | docker login $Registry --username $Username --password-stdin
if ($LASTEXITCODE -ne 0) { Fail 'docker login failed (bad token? wrong scope?).' }
# --- push --------------------------------------------------------------------
Step "Pushing $primary"
docker push $primary
if ($LASTEXITCODE -ne 0) { Fail "docker push failed for $primary" }
if ($pushLatest) {
Step "Pushing ${repo}:latest"
docker push "${repo}:latest"
if ($LASTEXITCODE -ne 0) { Fail "docker push failed for ${repo}:latest" }
}
Write-Host ""
Write-Host "Done. Pushed:" -ForegroundColor Green
Write-Host " $primary"
if ($pushLatest) { Write-Host " ${repo}:latest" }
Write-Host ""
Write-Host "Pull with: docker pull $primary"