Files
SharepointToolbox-Web/Program.cs
T
kawa c4a1775d7d Harden auth, headers, and container per OWASP review
- Add per-account lockout + IP rate limiter on local sign-in (A07)
- Emit CSP and security headers on every response (A05)
- Run container as non-root `app`, /data 0700 (A05/A02)
- Stop reflecting raw token-endpoint body into redirect URL (A09)
- Handle missing refresh_token in connect callback without a 500

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:30:19 +02:00

578 lines
31 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
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.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Serilog;
using SharepointToolbox.Web.Core.Config;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
using SharepointToolbox.Web.Infrastructure.OAuth;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services;
using SharepointToolbox.Web.Services.Audit;
using SharepointToolbox.Web.Services.Auth;
using SharepointToolbox.Web.Services.Export;
using SharepointToolbox.Web.Services.OAuth;
using SharepointToolbox.Web.Services.Reports;
using SharepointToolbox.Web.Services.Session;
var builder = WebApplication.CreateBuilder(args);
// ── Serilog ───────────────────────────────────────────────────────────────────
var dataFolder = builder.Configuration["DataFolder"] ?? "/data";
Directory.CreateDirectory(dataFolder);
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.WriteTo.Console()
.WriteTo.File(
Path.Combine(dataFolder, "logs", "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30)
.CreateLogger();
builder.Host.UseSerilog();
// ── Blazor / Razor Components ─────────────────────────────────────────────────
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
// ephemeral home dir, so a redeploy would log everyone out and make stored certs
// undecryptable. Pin keys + app name to the data volume.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(dataFolder, "dpkeys")))
.SetApplicationName("SharepointToolbox.Web");
// Localization string source — Scoped: one per circuit, with its own explicit culture.
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
// ── Public domain ─────────────────────────────────────────────────────────────
// App__Domain (e.g. sptb.example.com) drives both OIDC sign-in (below) and the
// SharePoint-connect redirect URI. Bound once here so both consumers share it.
var appDomain = new AppDomainOptions();
builder.Configuration.GetSection("App").Bind(appDomain);
// ── Authentication ────────────────────────────────────────────────────────────
if (builder.Environment.IsDevelopment())
{
// Dev: cookie-only, no OIDC. /account/login auto-signs in a hardcoded Admin.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/account/login";
options.LogoutPath = "/account/logout";
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
});
}
else
{
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// Challenge the cookie scheme (→ redirect to /account/login, the combined
// local + Microsoft page). OIDC is triggered explicitly from the "Sign in
// with Microsoft" button (/account/login/entra), never as the implicit
// challenge — otherwise logged-out hits on protected pages force OIDC and
// 404 when it is unconfigured/unreachable.
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/account/login";
options.LogoutPath = "/account/logout";
// Auth state lives entirely in the browser cookie (Data Protection encrypted)
options.SessionStore = null;
options.Cookie.SameSite = SameSiteMode.Lax;
// Always mark the auth cookie Secure in prod. The app sits behind a TLS-terminating
// proxy and forwarded headers are trusted from any source (proxy IP is unknown inside
// the container network), so SameAsRequest would let a spoofed X-Forwarded-Proto: http
// — or any direct plaintext hit — emit a non-Secure cookie. Always wins regardless.
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
})
.AddOpenIdConnect(options =>
{
var oidc = builder.Configuration.GetSection("Oidc");
// 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;
// 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");
options.GetClaimsFromUserInfoEndpoint = true;
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "preferred_username";
// When App__Domain is set, pin the OIDC redirect_uri (and post-logout redirect) to that
// public host instead of deriving it from the request scheme/host. Keeps sign-in working
// when the app can't see its real external host (no/incorrect forwarded Host header, or
// several hostnames reach the same instance). The value must match the /signin-oidc URI
// registered on the Oidc app. The authorize request and the code→token redemption MUST
// send the identical redirect_uri, so override it in both events.
var oidcRedirectUri = appDomain.BuildUrl(options.CallbackPath.Value ?? "/signin-oidc");
var postLogoutUri = appDomain.BuildUrl(options.SignedOutCallbackPath.Value ?? "/signout-callback-oidc");
if (oidcRedirectUri is not null)
{
options.Events.OnRedirectToIdentityProvider = ctx =>
{
ctx.ProtocolMessage.RedirectUri = oidcRedirectUri;
return Task.CompletedTask;
};
options.Events.OnAuthorizationCodeReceived = ctx =>
{
if (ctx.TokenEndpointRequest is not null)
ctx.TokenEndpointRequest.RedirectUri = oidcRedirectUri;
return Task.CompletedTask;
};
options.Events.OnRedirectToIdentityProviderForSignOut = ctx =>
{
ctx.ProtocolMessage.PostLogoutRedirectUri = postLogoutUri;
return Task.CompletedTask;
};
}
options.Events.OnTokenValidated = async ctx =>
{
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
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);
};
});
}
// "Admin" policy checks the app_role claim value directly, rather than [Authorize(Roles=…)]
// — the local/dev sign-in identities don't set a ClaimTypes.Role claim, so a Roles check would
// silently deny local admins. Every identity (OIDC, local, dev) carries app_role.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", p => p.RequireClaim("app_role", nameof(UserRole.Admin)));
});
// ── Rate limiting ───────────────────────────────────────────────────────────────
// Volumetric defence on the sign-in endpoints, partitioned by client IP (RemoteIpAddress
// reflects X-Forwarded-For — UseForwardedHeaders runs first). This is the coarse layer;
// the per-account lockout in UserService is what holds up when XFF is spoofed/rotated,
// since forwarded headers are trusted from any source behind the proxy.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("login", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
});
// ── Memory cache (used by OAuth flow cache) ───────────────────────────────────
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient("oauth");
// ── ClientConnect options ─────────────────────────────────────────────────────
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
// Derive the SharePoint-connect redirect URI from the app's public domain (App__Domain)
// when ClientConnect__RedirectUri isn't set explicitly. Lets a deployment configure a
// single domain (e.g. sptb.example.com) instead of spelling out the full callback URL.
// An explicit RedirectUri still wins, so existing configs are unaffected.
builder.Services.PostConfigure<ClientConnectOptions>(opts =>
{
if (string.IsNullOrWhiteSpace(opts.RedirectUri) &&
appDomain.BuildUrl("/connect/callback") is { } callback)
{
opts.RedirectUri = callback;
}
});
// ── App config ────────────────────────────────────────────────────────────────
var certsFolder = Path.Combine(dataFolder, "appcerts");
builder.Services.Configure<AppConfiguration>(opt =>
{
opt.DataFolder = dataFolder;
opt.ExportsFolder = Path.Combine(dataFolder, "exports");
opt.CertsFolder = certsFolder;
Directory.CreateDirectory(opt.ExportsFolder);
Directory.CreateDirectory(opt.CertsFolder);
});
// ── Persistence (Singleton — files on disk) ───────────────────────────────────
builder.Services.AddSingleton(new ProfileRepository(Path.Combine(dataFolder, "profiles.json")));
builder.Services.AddSingleton(new SettingsRepository(Path.Combine(dataFolder, "settings.json")));
builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates")));
builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json")));
builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl")));
builder.Services.AddSingleton(new ScheduledReportRepository(Path.Combine(dataFolder, "schedules.json")));
builder.Services.AddSingleton(new GeneratedReportRepository(Path.Combine(dataFolder, "reports-index.json")));
// ── App-only (unattended) auth for scheduled reports ──────────────────────────
builder.Services.AddSingleton<IAppOnlyCertStore>(sp =>
new AppOnlyCertStore(certsFolder, sp.GetRequiredService<IDataProtectionProvider>()));
builder.Services.AddSingleton<IAppOnlyContextFactory, AppOnlyContextFactory>();
// ── Auth infrastructure ───────────────────────────────────────────────────────
builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
builder.Services.AddSingleton<ICertProvisioningService, CertProvisioningService>();
builder.Services.AddScoped<GraphClientFactory>();
// ── User session (Scoped = one per Blazor circuit = one per browser tab) ─────
builder.Services.AddScoped<IUserSessionService, UserSessionService>();
builder.Services.AddScoped<IUserContextAccessor, UserContextAccessor>();
builder.Services.AddScoped<ISessionCredentialStore, SessionCredentialStore>();
// ── Audit (Scoped — reads user context from circuit) ─────────────────────────
builder.Services.AddScoped<IAuditService, AuditService>();
// ── Business services (Scoped — each user circuit gets its own instances) ─────
builder.Services.AddScoped<ISessionManager, SessionManager>();
builder.Services.AddScoped<IPermissionsService, PermissionsService>();
builder.Services.AddScoped<IStorageService, StorageService>();
builder.Services.AddScoped<ISearchService, SearchService>();
builder.Services.AddScoped<IDuplicatesService, DuplicatesService>();
builder.Services.AddScoped<IFileTransferService, FileTransferService>();
builder.Services.AddScoped<IBulkMemberService, BulkMemberService>();
builder.Services.AddScoped<IBulkSiteService, BulkSiteService>();
builder.Services.AddScoped<IVersionCleanupService, VersionCleanupService>();
builder.Services.AddScoped<IUserAccessAuditService, UserAccessAuditService>();
builder.Services.AddScoped<IGraphUserDirectoryService, GraphUserDirectoryService>();
builder.Services.AddScoped<ISiteDiscoveryService, SiteDiscoveryService>();
builder.Services.AddScoped<ILibraryDiscoveryService, LibraryDiscoveryService>();
builder.Services.AddScoped<IFolderStructureService, FolderStructureService>();
builder.Services.AddScoped<ITemplateService, TemplateService>();
builder.Services.AddScoped<ICsvValidationService, CsvValidationService>();
builder.Services.AddScoped<ISharePointGroupResolver, SharePointGroupResolver>();
builder.Services.AddScoped<IOwnershipElevationService, OwnershipElevationService>();
builder.Services.AddScoped<IElevationCoordinator, ElevationCoordinator>();
// ── Export services (Scoped) ──────────────────────────────────────────────────
builder.Services.AddScoped<CsvExportService>();
builder.Services.AddScoped<HtmlExportService>();
builder.Services.AddScoped<StorageCsvExportService>();
builder.Services.AddScoped<StorageHtmlExportService>();
builder.Services.AddScoped<SearchCsvExportService>();
builder.Services.AddScoped<SearchHtmlExportService>();
builder.Services.AddScoped<DuplicatesCsvExportService>();
builder.Services.AddScoped<DuplicatesHtmlExportService>();
builder.Services.AddScoped<UserAccessCsvExportService>();
builder.Services.AddScoped<UserAccessHtmlExportService>();
builder.Services.AddScoped<VersionCleanupHtmlExportService>();
builder.Services.AddScoped<BulkResultCsvExportService>();
builder.Services.AddScoped<WebExportService>();
// ── Scheduled reports (background generation) ─────────────────────────────────
builder.Services.AddSingleton<ScheduledRunCoordinator>();
builder.Services.AddScoped<IReportMailService, ReportMailService>();
builder.Services.AddScoped<IScheduledReportRunner, ScheduledReportRunner>();
builder.Services.AddHostedService<ScheduledReportHostedService>();
var app = builder.Build();
// Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies).
app.UseForwardedHeaders();
// When App__Domain is set, rewrite every request's scheme + host to the public domain. The
// framework builds absolute URLs (the cookie login redirect, the OIDC redirect_uri, …) from
// Request.Scheme/Host; behind a proxy that doesn't forward the Host header these are the
// internal host (server IP:port), so loading https://<domain>/ would 302 to http://<ip>:8080.
// Forcing the host here keeps every generated URL on the public domain. Must run before auth.
var publicBaseUri = appDomain.GetBaseUri();
if (publicBaseUri is not null)
{
var publicHost = HostString.FromUriComponent(publicBaseUri);
app.Use((context, next) =>
{
context.Request.Scheme = publicBaseUri.Scheme;
context.Request.Host = publicHost;
return next(context);
});
}
// ── 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);
}
}
}
// ── Security response headers ───────────────────────────────────────────────────
// Defence-in-depth on every response. CSP is tuned to this app: all scripts are external
// (_framework/blazor.web.js, js/app.js) so script-src can stay 'self' with no unsafe-*;
// the login page and the Blazor components use inline <style>/style="" so style-src needs
// 'unsafe-inline'. img-src allows data: for the base64 client logos and CSV download links.
// connect-src 'self' covers the Blazor Server WebSocket. frame-ancestors blocks clickjacking
// of the live circuit (X-Frame-Options for legacy agents).
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers["Content-Security-Policy"] =
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"font-src 'self' data:; " +
"connect-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"form-action 'self'; " +
"frame-ancestors 'none'";
headers["X-Frame-Options"] = "DENY";
headers["X-Content-Type-Options"] = "nosniff";
headers["Referrer-Policy"] = "no-referrer";
await next();
});
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
// Re-execute unmatched (404) requests into the branded not-found page
app.UseStatusCodePagesWithReExecute("/not-found");
app.MapStaticAssets();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseAntiforgery();
// ── Login / Logout endpoints ──────────────────────────────────────────────────
var isDev = app.Environment.IsDevelopment();
// Combined login page. Dev: local form + "Quick sign in as Dev Admin" (no OIDC scheme registered).
// Prod: local form + "Sign in with Microsoft".
app.MapGet("/account/login", (HttpContext ctx, IAntiforgery antiforgery, string? returnUrl, bool? error) =>
{
var html = LoginPageRenderer.Build(
ctx, antiforgery, returnUrl, error == true,
showEntra: !isDev,
showDevButton: isDev);
return Results.Content(html, "text/html");
});
if (isDev)
{
// Dev shortcut: provision + sign in the hardcoded Dev Admin (first run = Admin).
app.MapGet("/account/login/dev", async (HttpContext ctx, string? returnUrl, IUserService userService) =>
{
const string devEmail = "dev@local.test";
const string devName = "Dev Admin";
var provisionPrincipal = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim("preferred_username", devEmail), new Claim("name", devName) },
CookieAuthenticationDefaults.AuthenticationScheme));
var user = await userService.ProvisionAsync(provisionPrincipal);
var principal = new ClaimsPrincipal(new ClaimsIdentity(
new Claim[] {
new("preferred_username", devEmail),
new("name", devName),
new("app_role", user.Role.ToString()),
},
CookieAuthenticationDefaults.AuthenticationScheme));
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
ctx.Response.Redirect(returnUrl.ToLocalReturnUrl());
});
}
else
{
// Microsoft / Entra OIDC challenge (the "Sign in with Microsoft" button).
app.MapGet("/account/login/entra", async (HttpContext ctx, string? returnUrl) =>
{
var props = new AuthenticationProperties
{
RedirectUri = returnUrl.ToLocalReturnUrl()
};
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
}).RequireRateLimiting("login");
}
// Local password sign-in — available in every environment.
app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antiforgery, IUserService userService) =>
{
try { await antiforgery.ValidateRequestAsync(ctx); }
catch (AntiforgeryValidationException) { return Results.BadRequest(); }
var form = await ctx.Request.ReadFormAsync();
var email = form["email"].ToString();
var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
var safeReturn = returnUrl.ToLocalReturnUrl();
var user = await userService.ValidateLocalCredentialsAsync(email, password);
if (user is null)
return Results.Redirect($"/account/login?error=true&returnUrl={Uri.EscapeDataString(safeReturn)}");
var principal = new ClaimsPrincipal(new ClaimsIdentity(
new Claim[]
{
new("preferred_username", user.Email),
new("name", user.DisplayName),
new("app_role", user.Role.ToString()),
new("auth_provider", nameof(AuthProvider.Local)),
},
CookieAuthenticationDefaults.AuthenticationScheme));
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Results.Redirect(safeReturn);
}).RequireRateLimiting("login");
app.MapGet("/account/logout", async (HttpContext ctx) =>
{
// Local/dev accounts only hold the cookie; Entra accounts also have an OIDC session to end.
var isLocal = ctx.User.HasClaim("auth_provider", nameof(AuthProvider.Local));
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (!isDev && !isLocal && ctx.User.Identity?.IsAuthenticated == true)
await ctx.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme,
new AuthenticationProperties { RedirectUri = "/" });
else
ctx.Response.Redirect("/");
});
// ── OAuth2 connect endpoints ──────────────────────────────────────────────────
app.MapOAuthEndpoints();
// ── File download endpoint ────────────────────────────────────────────────────
app.MapGet("/export/download/{fileName}", async (string fileName, IOptions<AppConfiguration> opts, HttpContext ctx) =>
{
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
var path = Path.Combine(opts.Value.ExportsFolder, Path.GetFileName(fileName));
if (!File.Exists(path)) return Results.NotFound();
var bytes = await File.ReadAllBytesAsync(path);
var ct = fileName.EndsWith(".csv") ? "text/csv" : "text/html";
return Results.File(bytes, ct, fileName);
});
// ── Scheduled report download (id-based, scoped to the client's exports subfolder) ──
app.MapGet("/reports/download/{id}", async (string id, GeneratedReportRepository index, IOptions<AppConfiguration> opts, HttpContext ctx) =>
{
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
var report = await index.GetAsync(id);
if (report is null || report.Status != ReportRunStatus.Success || string.IsNullOrEmpty(report.FileName))
return Results.NotFound();
// ProfileId and FileName are app-generated; GetFileName strips any traversal just in case.
var path = Path.Combine(opts.Value.ExportsFolder, report.ProfileId, Path.GetFileName(report.FileName));
if (!File.Exists(path)) return Results.NotFound();
var bytes = await File.ReadAllBytesAsync(path);
var mime = string.IsNullOrEmpty(report.Mime) ? "application/octet-stream" : report.Mime;
return Results.File(bytes, mime, report.FileName);
});
// ── Audit CSV download ────────────────────────────────────────────────────────
app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =>
{
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
// Role check via the app-role claim set during OIDC provisioning
var rolesClaim = ctx.User.FindFirst("app_role")?.Value;
if (rolesClaim != nameof(UserRole.Admin)) return Results.Forbid();
var entries = await auditRepo.LoadAllAsync();
var sb = new System.Text.StringBuilder();
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
{
// CsvSanitizer neutralizes spreadsheet formula prefixes (= + - @) on top of RFC 4180
// quoting — audited fields (display name, client/site names, details) are user-controlled.
sb.AppendLine(string.Join(",",
CsvSanitizer.Escape(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
CsvSanitizer.Escape(e.UserEmail), CsvSanitizer.Escape(e.UserDisplay), CsvSanitizer.Escape(e.UserRole.ToString()),
CsvSanitizer.Escape(e.Action), CsvSanitizer.Escape(e.ClientName),
CsvSanitizer.Escape(string.Join("; ", e.Sites)), CsvSanitizer.Escape(e.Details)));
}
return Results.File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "audit-log.csv");
});
app.MapRazorComponents<SharepointToolbox.Web.Components.App>()
.AddInteractiveServerRenderMode();
app.Run();