From c23039efa14a565c9dbb0b14a0a30e679e6600c6 Mon Sep 17 00:00:00 2001 From: kawa Date: Tue, 9 Jun 2026 15:46:53 +0200 Subject: [PATCH 1/5] Fix stuck-on-loading after sign-in; enable HTTP/LAN local login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app stuck on "Chargement…" after sign-in because the interactive Blazor circuit came up anonymous: no auth cookie reached this origin. Root cause was the deployment (plain HTTP on an IP, http://host:8080), which Microsoft OIDC cannot serve — Entra forbids http redirect URIs for non-localhost hosts, so the sign-in cookie never lands on the origin. Changes: - ForwardedHeaders (X-Forwarded-Proto/For) so that behind a TLS proxy the app sees the real https scheme, builds a matching OIDC redirect_uri, and sets the auth cookie Secure. Proxy IP unknown in-container → known proxy/network restrictions cleared. - First-run bootstrap: seed a local admin (Bootstrap__AdminEmail / Bootstrap__AdminPassword) when that email has no account, so HTTP/LAN deployments that can't use OIDC can sign in via the local form. Idempotent. - OIDC SaveTokens=false: the cookie-stored access/id/refresh tokens were never read (SharePoint/Graph auth uses the separate connect-flow + cert paths). Dropping them keeps the auth cookie small/unchunked. - AppInitializer now logs which branch leaves UserContext unseeded (unauthenticated principal / missing claim / no user row) instead of failing silently — this is what surfaced the anonymous-circuit cause. Co-Authored-By: Claude Opus 4.8 (1M context) --- Components/Shared/AppInitializer.razor | 24 +++++++++++-- Program.cs | 50 +++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) 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); From 8dfbf7c18a7264aa6a007d7a759c869c93e22780 Mon Sep 17 00:00:00 2001 From: kawa Date: Tue, 9 Jun 2026 17:21:46 +0200 Subject: [PATCH 2/5] Fix OIDC stuck-on-loading: slim auth cookie principal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OIDC OnTokenValidated handler stored the raw principal (all id_token + userinfo claims) in the auth cookie. Encrypted + base64 it exceeds ~4 KB, so ChunkingCookieManager splits it across …CookiesC1/C2. The chunked cookie survives the prerender GET but is dropped on the Blazor interactive WebSocket upgrade, so the circuit comes up anonymous and the page sticks on "Chargement…". SaveTokens=false alone didn't shrink it enough — the claims themselves bloat it. Replace the principal with a slim 4-claim identity (preferred_username, name, app_role, auth_provider), identical to the local-login path, so the cookie stays single + unchunked and the circuit authenticates. Also fixes a latent bug: the OIDC principal never carried app_role or auth_provider, so Entra admins got no admin nav and logout skipped the OIDC sign-out branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- Program.cs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Program.cs b/Program.cs index 1b8c944..9321fab 100644 --- a/Program.cs +++ b/Program.cs @@ -131,7 +131,29 @@ else options.Events.OnTokenValidated = async ctx => { var userService = ctx.HttpContext.RequestServices.GetRequiredService(); - 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); }; }); } From 80f660053da9d92e62178e08d7a5451b117d944a Mon Sep 17 00:00:00 2001 From: kawa Date: Tue, 9 Jun 2026 17:32:58 +0200 Subject: [PATCH 3/5] Strip quotes/whitespace from Oidc config values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose's `environment` list form embeds literal quotes in the value (`- Oidc__TenantId=""` → the value is "" with quotes), producing a malformed Authority URL (…/""/v2.0). Metadata discovery then fails with IDX20803 and the Microsoft sign-in challenge 500s. The same trap on ClientSecret would silently break the token exchange. Trim surrounding quotes and whitespace from TenantId, ClientId and ClientSecret so a quoted env var no longer breaks OIDC. Co-Authored-By: Claude Opus 4.8 (1M context) --- Program.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Program.cs b/Program.cs index 9321fab..b6ac86c 100644 --- a/Program.cs +++ b/Program.cs @@ -110,9 +110,14 @@ else .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"]; + // 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 + From e3926804a920bc50f6d0aa4aa65d5f3b145fc2ac Mon Sep 17 00:00:00 2001 From: kawa Date: Tue, 9 Jun 2026 17:51:55 +0200 Subject: [PATCH 4/5] Clarify the two OAuth redirect URIs in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Configuration table listed ClientConnect__RedirectUri (/connect/callback) alongside the Oidc__* settings, implying it was an OIDC sign-in redirect URI on the toolbox's own Entra app. It isn't: /connect/callback is the per-profile SharePoint connect flow (PKCE public client using each profile's own ClientId), registered on the client-tenant apps — not the sign-in app. Split the two flows out explicitly: /signin-oidc on the sign-in (Web) app, /connect/callback on each profile's (public client) app. Also document that the confidential sign-in app needs an HTTPS redirect URI (http only for localhost), so a plain-HTTP LAN deployment needs an HTTPS-terminating proxy or must fall back to local login. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b74b096..41549f5 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,23 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se | `Oidc__TenantId` | Entra tenant GUID | | `Oidc__ClientId` | App registration client ID | | `Oidc__ClientSecret` | App registration client secret | -| `ClientConnect__RedirectUri` | Public callback URL, e.g. `https://your-host/connect/callback` | +| `ClientConnect__RedirectUri` | Callback for the per-profile SharePoint connect flow, e.g. `https://your-host/connect/callback` (see below — **not** an OIDC setting) | | `DataFolder` | Persistent data path (default `/data`) | | `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. -**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`. From 4c2605b53209baf6509a8167592e90120e4d9a1e Mon Sep 17 00:00:00 2001 From: kawa Date: Wed, 10 Jun 2026 11:51:35 +0200 Subject: [PATCH 5/5] Added a docker publish script --- README.md | 1 - build-and-push.ps1 | 96 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 build-and-push.ps1 diff --git a/README.md b/README.md index 41549f5..639ded6 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se | `Oidc__TenantId` | Entra tenant GUID | | `Oidc__ClientId` | App registration client ID | | `Oidc__ClientSecret` | App registration client secret | -| `ClientConnect__RedirectUri` | Callback for the per-profile SharePoint connect flow, e.g. `https://your-host/connect/callback` (see below — **not** an OIDC setting) | | `DataFolder` | Persistent data path (default `/data`) | | `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC | diff --git a/build-and-push.ps1 b/build-and-push.ps1 new file mode 100644 index 0000000..c7b48bd --- /dev/null +++ b/build-and-push.ps1 @@ -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"