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:
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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