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>
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>
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>
Two deployment-breaking issues caused 404s on protected pages after a
container recreate:
1. DataProtection keys were stored in the container's ephemeral home dir.
Every redeploy regenerated them, invalidating all auth cookies (users
silently logged out) and — worse — making the app-only certs encrypted
under /data/appcerts undecryptable. Persist keys to /data/dpkeys with a
stable application name so they survive recreates.
2. DefaultChallengeScheme was OpenIdConnect, so a logged-out request to any
[Authorize] Blazor page forced an OIDC challenge. When OIDC is
unconfigured/unreachable the challenge throws and the request 404s, with
no path to the login page. Challenge the cookie scheme instead, which
redirects to /account/login (the combined local + Microsoft page). OIDC
is still triggered explicitly from /account/login/entra.
Also harden the container image:
- Pin base images to exact patch (sdk:10.0.300, aspnet:10.0.8). Floating
:10.0 tags drift; a stale/pre-GA SDK base silently drops blazor.web.js
from the publish manifest, 404ing framework assets in production.
- Install curl and switch the compose healthcheck to it (the aspnet image
ships no wget/curl, so the old healthcheck always reported unhealthy).
Probe /account/login (anonymous, 200) since / now 302-redirects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blazor framework assets (blazor.web.js) under _framework are served via
the static-asset endpoints manifest, not physical wwwroot files. Plain
UseStaticFiles only serves physical files, so published deployments
returned 404 for blazor.web.js (worked in dev via the dev-time static
web assets provider). Switch to MapStaticAssets, which reads the
endpoints manifest shipped with publish.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feature work:
- Certificate (app-only) auth per profile: cert store, context/Graph client
factories, automated app-registration provisioning (delegated + application
permissions, admin consent), and a SessionManager seam that resolves the auth
model per profile.
- Scheduled reports: repositories, hosted service/runner/coordinator, report
pages, and email delivery (app-only Mail.Send).
- Tenant-wide user-access audit when no site is selected.
Audit fixes:
- Site enumeration: app-only discovery used Graph getAllSites (needs Graph
Sites.Read.All the cert app lacks) and silently returned empty. Switched to
the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth
models now share one enumeration path.
- Group expansion: the scan records a SharePoint group as a single principal, so
user-centric audits found nothing for group-granted access. Resolve group
membership (shared by audit + scheduler) and attribute it to the target user.
- M365 group claims: the resolver only recognized AAD security groups
(c:0t.c|). Group-connected/Teams sites grant via the M365 group claim
(c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim.
- Provision Directory.Read.All as an application permission so M365/AAD group
expansion works under the cert identity.
Also: ignore data/appcerts/ (encrypted certificate key material).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Report branding (top-left MSP logo, top-right client logo):
- Add MspLogo to AppSettings; client logo already on TenantProfile
- IUserSessionService.CurrentBranding composes MSP + active profile logo
- New reusable LogoUpload component (InputFile -> base64 LogoData, 512KB cap)
- MSP logo upload in Settings; optional client logo in profile create/edit
- Wire ReportBranding into all 6 HTML export pages
- Fix EditProfile dropping ClientLogo on edit
Storage metrics: expose folder scan depth (0-20) in scan options UI,
passed to existing StorageScanOptions.FolderDepth recursion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Logs showed the failure was a transient 403 on the tenant admin endpoint
(loading CurrentUser on -admin.sharepoint.com returned E_ACCESSDENIED on a
cold token), and that re-running the operation a few seconds later succeeded.
The site-collection admin grant is also eventually consistent on Group/Teams
sites, taking a few seconds to reach the content endpoint.
Retry both stages with backoff (3s, 6s, 9s; 4 attempts) instead of failing
on the first denial:
- ElevateAsync retries the admin-endpoint grant on transient access-denied; a
genuine lack of tenant-admin rights still surfaces after retries exhaust.
- After a successful grant, the post-elevation operation retries on continued
access-denied to absorb grant-propagation lag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A SharePoint admin reported the grant runs without a logged error yet the
account never appears as site-collection admin on Group/Teams sites. The
failure was invisible: ElevateAsync called ExecuteQueryAsync directly (no
enrichment/logging) and the coordinator only surfaced elevate failures on the
page, not to Serilog.
- Route the admin-endpoint ExecuteQuery through ExecuteQueryRetryHelper so a
denial there is enriched (serverErrorType/httpStatus) and logged.
- Log the resolved login and SetSiteAdmin acceptance in OwnershipElevationService.
- Log elevate failures to Serilog in the coordinator.
- Add a post-elevation verify that reads CurrentUser.IsSiteAdmin on the target
site so logs distinguish a failed/no-op grant from a scan failing for another
reason. Diagnostic only; never throws into the operation flow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Microsoft 365 Group / Teams-connected sites surface access-denied on some
CSOM calls as a raw "(403) FORBIDDEN" WebException carrying
0x80070005 (E_ACCESSDENIED), not as a typed ServerException with
ServerErrorTypeName = System.UnauthorizedAccessException. IsAccessDenied
only matched the typed shape, so those denials became generic
InvalidOperationExceptions the elevation coordinator never caught — no
auto-elevation ran and the operation failed even for a SharePoint admin.
Walk the inner-exception chain and treat any of these as access-denied:
the typed ServerException, a WebException with HTTP 403, or a message
containing the E_ACCESSDENIED HRESULT. Per-site dedupe still caps elevation
to one retry, so a 403 elevation cannot fix (policy/endpoint block) won't loop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Auto-elevate ownership when permission scan is denied" setting was
dead code: the toggle was persisted but never read, the audit flow never
passed its onAccessDenied callback, and EnrichException wrapped every CSOM
error (including ServerUnauthorizedAccessException) into a generic
InvalidOperationException so the access-denied catch could never match.
Centralize elevation instead of per-call-site callbacks:
- Throw typed SharePointAccessDeniedException from EnrichException on
access-denied, preserving the failing site URL and enriched diagnostic.
- Add scoped IElevationCoordinator that catches it, and when AutoTakeOwnership
is enabled takes site-collection admin via the tenant admin endpoint and
retries the operation once. Per-site dedupe prevents loops; admin-host
denials are not treated as ownership issues. Retry is safe because each
wrapped operation closure re-issues its own CSOM loads.
- Wrap all site-scoped operations (Storage, Permissions, Duplicates, Search,
VersionCleanup, FolderStructure, BulkMembers, FileTransfer, Templates) and
the UserAccessAudit per-site scan in the coordinator.
- Drop the unused onAccessDenied parameter from IUserAccessAuditService.
Elevation still requires SharePoint tenant admin rights on the signed-in
account; the coordinator surfaces a clear message when that is missing.
Also keeps the prior StorageService change that avoids admin-gated
folder.StorageMetrics (403 for delegated non-admin tokens).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-client app registered its redirect URI under the `web` platform,
so Entra treated it as a confidential client and the connect token
exchange (PKCE, no secret) failed with AADSTS7000218 (client_secret
required). Register the redirect under `publicClient` instead — matching
the desktop reference (PublicClient.RedirectUris) — so the secretless
PKCE code redemption is accepted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AADSTS700016 came from the register flow sending the configured
Oidc:ClientId (still a placeholder) as the auth client. The desktop
reference app never needs config: it bootstraps with the first-party
"Microsoft Graph Command Line Tools" public client (14d82eec-...) via
MSAL interactive, which exists in every tenant.
Replicate that for the web app. A server can't do MSAL loopback and the
bootstrap client's redirect URIs don't include /connect/callback, so use
the OAuth 2.0 device authorization grant instead — the web-equivalent of
the desktop interactive flow:
- Add EntraDeviceCodeFlow: POST /devicecode then poll /token with the
bootstrap client. No backing app, no client id/secret, no redirect URI.
- Profiles "Register in Entra" now shows the verification URL + user code
and polls until the admin signs in, then calls AppRegistrationService
to create the per-client app and adopts its appId.
- Remove the dead /connect/register-initiate endpoint and the
IsRegistration branch from the callback (connect flow only now).
The client-tenant register/connect flows are now fully secretless. The
Oidc:* config is used only by the toolbox's own sign-in (unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
data/profiles.json and data/users.json hold live tenant config and
user records that the app rewrites at runtime (lastLogin etc.), so they
dirtied git status on every run. Untrack them and gitignore data/*.json
(plus logs/exports/templates/audit), keeping committed *.example.json
seeds. Repositories already create empty files on first save, so a
fresh clone starts clean and provisions the first user as Admin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The register flow exchanged the auth code as a confidential client
(Oidc:ClientId + Oidc:ClientSecret), requiring a pre-provisioned
backing app with a secret. Drop client_secret from the exchange so it
uses PKCE only — the backing app is now a public client and no secret
touches the client-tenant register/connect flows.
The toolbox's own OIDC sign-in still uses Oidc:ClientSecret (unchanged).
Also enable user-secrets (UserSecretsId) so Oidc config stays out of
the committed appsettings.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add missing modal CSS (.modal-overlay/.modal-dialog/.modal-header):
the "Connect to Microsoft" auth modal was rendering unstyled inline
at the bottom of the page. Now a centered dialog with backdrop.
- Surface OAuth connect errors in the modal instead of silently
reopening it with no explanation.
- MainLayout: implement IDisposable so event handlers are actually
unsubscribed (Dispose existed but was never invoked).
- Wire up the Settings theme selector (was a dead control): drop the
unsupported Dark option, call sptb.setTheme on save and on load,
resolve System via prefers-color-scheme.
- Add branded 404 page via UseStatusCodePagesWithReExecute + Routes
<NotFound> (blank white page before).
- Add .progress-fill.indeterminate animation and .progress-panel.
- Home: replace inline JS hover handlers with a .feature-card CSS class.
- Define missing --surface-2 variable referenced by MainLayout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>