Fix stuck-on-loading after sign-in; enable HTTP/LAN local login #3
@@ -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
@@ -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);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user