Compare commits

..

6 Commits

Author SHA1 Message Date
kawa 82b7640f31 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
2026-06-10 11:54:10 +02:00
kawa 4c2605b532 Added a docker publish script 2026-06-10 11:51:35 +02:00
kawa e3926804a9 Clarify the two OAuth redirect URIs in README
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) <noreply@anthropic.com>
2026-06-09 17:51:55 +02:00
kawa 80f660053d Strip quotes/whitespace from Oidc config values
docker-compose's `environment` list form embeds literal quotes in the value
(`- Oidc__TenantId="<guid>"` → the value is "<guid>" with quotes), producing a
malformed Authority URL (…/"<tenant>"/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) <noreply@anthropic.com>
2026-06-09 17:32:58 +02:00
kawa 8dfbf7c18a Fix OIDC stuck-on-loading: slim auth cookie principal
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) <noreply@anthropic.com>
2026-06-09 17:21:46 +02:00
kawa c23039efa1 Fix stuck-on-loading after sign-in; enable HTTP/LAN local login
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) <noreply@anthropic.com>
2026-06-09 15:46:53 +02:00
4 changed files with 208 additions and 10 deletions
+21 -3
View File
@@ -1,6 +1,7 @@
@inject AuthenticationStateProvider AuthProvider
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject ILogger<AppInitializer> 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);
}
}
+80 -5
View File
@@ -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<ForwardedHeadersOptions>(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
@@ -96,11 +110,22 @@ 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="<guid>"`) embeds the literal quotes in the value, producing a malformed
// 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.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");
@@ -111,7 +136,29 @@ else
options.Events.OnTokenValidated = async ctx =>
{
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();
// 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())
{
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__ClientId` | App registration client ID |
| `Oidc__ClientSecret` | App registration client secret |
| `ClientConnect__RedirectUri` | Public callback URL, e.g. `https://your-host/connect/callback` |
| `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`.
+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"