Compare commits

...

26 Commits

Author SHA1 Message Date
kawa 9480e9537a Bump dependencies to latest and document them
Update 13 NuGet packages, including major bumps: Microsoft.Graph
5.74.0 -> 6.2.0, Microsoft.Kiota.* 1.22.2 -> 2.0.0, and
Serilog.AspNetCore 9.0.0 -> 10.0.0. The set is internally consistent
(Graph 6.2.0 -> Graph.Core 4.0.1 -> Kiota 2.0.0) and builds clean with
no code changes. Add a Dependencies table to the README listing each
package, its pinned version, and purpose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:44:48 +02:00
kawa 181c82d310 Document certificate (app-only) auth as primary per-profile path
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:38:24 +02:00
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
kawa 0adc2d4300 Hide write-only features from TechN0 menu
Read-only TechN0 users could see nav items for pages that immediately
return a WriteGuard notice (transfer, versions, templates, bulk members/
sites, folder structure), landing them on empty screens. Add a `write`
nav scope (HasProfile && Role >= TechN1) so those items no longer appear
for N0. The Bulk and Config section headers drop out automatically since
all their children are now write-scoped. Per-page guards remain intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:48:36 +02:00
kawa 17f6010a93 Fix open-redirect token leak and related auth hardening
Security review fixes:
- Constrain OAuth connect returnUrl to a site-relative path so the
  redeemable token_key can't be redirected off-domain (was a refresh-
  token leak / connection hijack)
- Route all login redirects (entra/dev/local) through ToLocalReturnUrl,
  also closing a protocol-relative // open redirect in local-login
- Neutralize CSV formula prefixes in both audit-log exporters via
  CsvSanitizer
- Force Secure flag on the prod auth cookie (Always, not SameAsRequest)
- Gate admin pages with an app_role-claim "Admin" policy instead of a
  render-time check

Findings and rationale recorded in SECURITY-TODO.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:39:20 +02:00
kawa fe33960c0e Let standard techs use profiles without sign-in; flag unshared ones
Standard technicians (TechN0/TechN1) are no longer auto-prompted for a
delegated SharePoint sign-in when selecting a profile — only admins are.
Techs operate under the profile's app (certificate) identity, so a profile
selection never forces them to authenticate.

To keep that usable, the admin profile list now shows a "No shared access"
badge on any profile that isn't certificate-configured, since standard
techs can't operate against those until an admin registers a cert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:27:46 +02:00
kawa 84b77d99f6 Fixed : Certificates arent stored properly app-side when creating new profiles 2026-06-11 11:12:31 +02:00
kawa 08dd27d91d Trim "no secrets stored on disk" from profiles subtitle
Drop the trailing claim from the profiles.subtitle string (FR + EN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:59:49 +02:00
kawa 38ffe714a2 Restore clean role-change success message
Drop the temporary "saved: …" diagnostic wording now that the production
interactivity bug is fixed. Keeps the robust @onchange handler and the
previous-role return value used in the audit entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:56:47 +02:00
kawa def8647de1 Drop --no-restore: it dropped Blazor framework assets
Root cause of the production "nothing is interactive" bug. The Dockerfile
restored with only the .csproj present (the layer-cache step) and then ran
`dotnet publish --no-restore`. That combination silently omits the Blazor
framework static assets (wwwroot/_framework/blazor.web.js) from the publish
output, so MapStaticAssets 404s the boot script and no interactive circuit
starts on any page — buttons, dropdowns (role changes) all dead.

Letting publish restore against the full project re-materializes the assets.
Reproduced locally and verified the fix. The SDK pin (10.0.203) was a red
herring and is left as-is for reproducibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:51:25 +02:00
kawa f6a36f3bd9 Use SDK 10.0.203 (10.0.204 has no MCR image)
The previous pin (sdk:10.0.204) doesn't exist on MCR — only the installer
SDK uses that patch. MCR publishes band-2 images up to 10.0.203. Band 2
publishes blazor.web.js correctly (verified locally on 10.0.204), so pin
to the newest available 2xx image, 10.0.203.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:37:04 +02:00
kawa c41abc0ea5 Pin build SDK to 10.0.204 to restore blazor.web.js
SDK 10.0.300 published a static-assets endpoints manifest without
blazor.web.js, so MapStaticAssets returned 404 for the Blazor boot script
in production. With no boot script the interactive circuit never starts
and every page renders static — buttons and dropdowns (e.g. user role
changes) do nothing. SDK 10.0.204 is verified to publish blazor.web.js
(physical file + manifest entry) against the 10.0.8 runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:34:06 +02:00
kawa fe0fcdb7da Make role-change report saved value on-screen
@bind:after did not persist reliably. Move back to an explicit @onchange
handler and surface every outcome in the page alert, including the role
re-read from the store after the write. This makes a failed save visible
(unrecognized value, exception, or saved != selected) instead of silent,
so we can pinpoint where the role update breaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:17:27 +02:00
kawa cdc93d041a Fix role change silently failing via @bind
The role <select> used a manual value=/@onchange pattern that parsed
e.Value and returned silently when the parse failed, so changing a role
did nothing and showed no message. Switch to @bind + @bind:after so the
framework handles the enum conversion, and log/verify the persisted role
in UpdateRoleAsync (now returns the previous role) for diagnosis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:10:00 +02:00
kawa 98683bbd5e Re-read user role from store on navigation
Role lived in the scoped UserContextAccessor for the circuit's lifetime
and was never refreshed, so an admin promoting a user (e.g. N0 to N1) did
not reach the affected user's live session. AppInitializer now re-reads
the user on each LocationChanged, applying role changes on next navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:50:26 +02:00
kawa e190e40b07 Force request host/scheme to App__Domain behind a proxy
The cookie login redirect and other absolute URLs are built from Request.Host;
behind a proxy that doesn't forward the Host header that's the internal IP:port,
so hitting the domain 302'd to the server IP. Rewrite scheme+host to App__Domain
on every request (after UseForwardedHeaders) so all generated URLs stay on the
public domain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:54:30 +02:00
kawa 5f51e9d16d Pin OIDC redirect to App__Domain when set
Override the OIDC redirect_uri (and post-logout redirect) to <domain>/signin-oidc
instead of deriving it from the request host. Set in both the authorize request
and the code->token redemption so Entra sees a matching redirect_uri. Falls back
to request-host derivation when App__Domain is unset. Domain binding hoisted so
OIDC and ClientConnect share one AppDomainOptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:47:04 +02:00
kawa 582cc54189 Add App__Domain config to derive connect redirect URI
Let deployments set a single App__Domain (e.g. sptb.example.com) instead of
spelling out the full ClientConnect__RedirectUri. The SharePoint-connect
callback is derived as <domain>/connect/callback; an explicit RedirectUri
still wins for back-compat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:42:05 +02:00
kawa 0ded1af6bc Merge pull request 'Add prebuilt docker-compose, .env.example, and prebuilt install docs' (#4) from fix/prod-auth-http-deploy into main
Reviewed-on: #4
2026-06-10 15:38:44 +02:00
kawa ad7d20021d Add prebuilt docker-compose, .env.example, and prebuilt install docs 2026-06-10 15:33:07 +02:00
kawa 415ec7152f Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-10 14:24:32 +02:00
kawa 5333a3888e Build and push script 2026-06-10 14:24:20 +02:00
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
25 changed files with 762 additions and 89 deletions
+21
View File
@@ -0,0 +1,21 @@
# Copy to `.env` beside docker-compose.prebuilt.yml and fill in real values.
# IMPORTANT: do NOT wrap values in quotes — the compose `environment:` list form
# embeds the literal quotes, producing a malformed Authority that fails OIDC
# metadata discovery (IDX20803).
# Image tag to run (default: latest)
SPTB_TAG=latest
# Public domain the app is reached at (e.g. sptb.example.com or https://sptb.example.com).
# Scheme defaults to https when omitted. The SharePoint-connect redirect URI is derived
# from this as <domain>/connect/callback — register that on each client profile's app.
# App__Domain=sptb.example.com
# OIDC app sign-in (required in Production). Authority is derived from TenantId.
Oidc__TenantId=00000000-0000-0000-0000-000000000000
Oidc__ClientId=00000000-0000-0000-0000-000000000000
Oidc__ClientSecret=your-client-secret
# Optional: seed the first admin while the user store is empty (local form login).
# Bootstrap__AdminEmail=admin@example.com
# Bootstrap__AdminPassword=change-me
+4
View File
@@ -65,3 +65,7 @@ data/exports/
data/templates/
data/audit.jsonl
data/appcerts/
# Local secrets
.env
!.env.example
+21 -10
View File
@@ -146,15 +146,15 @@
new("/permissions", "🔐", "tab.permissions", "", "profile"),
new("/storage", "💾", "tab.storage", "", "profile"),
new("/duplicates", "📋", "tab.duplicates", "", "profile"),
new("/versions", "🗂️", "versions.tab", "", "profile"),
new("/transfer", "📦", "nav.fileTransfer", "", "profile"),
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "profile"),
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "profile"),
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
new("/versions", "🗂️", "versions.tab", "", "write"),
new("/transfer", "📦", "nav.fileTransfer", "", "write"),
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "write"),
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "write"),
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "write"),
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
new("/reports", "📑", "nav.reports", "nav.section.audit", "profile"),
new("/templates", "📐", "tab.templates", "nav.section.config", "profile"),
new("/templates", "📐", "tab.templates", "nav.section.config", "write"),
new("/scheduled-reports", "⏰", "nav.scheduledReports", "nav.section.admin", "admin"),
new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
@@ -170,6 +170,7 @@
.Where(i => i.Scope switch
{
"profile" => Session.HasProfile,
"write" => Session.HasProfile && UserContext.Role >= UserRole.TechN1,
"admin" => UserContext.Role == UserRole.Admin,
"auth" => UserContext.IsAuthenticated,
_ => true
@@ -226,8 +227,8 @@
}
// If profile selected but no credentials → show modal (cert profiles never prompt)
if (Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && _credModal is not null)
await _credModal.ShowAsync();
if (ShouldPromptForCredentials)
await _credModal!.ShowAsync();
}
// True when the selected profile authenticates app-only via a stored certificate —
@@ -235,6 +236,15 @@
private bool CurrentProfileUsesCert =>
Session.CurrentProfile is { } p && AppOnly.IsConfigured(p);
// Whether to auto-show the delegated sign-in modal. Only admins are ever asked to
// authenticate: standard technicians (TechN0/TechN1) operate under the profile's app
// (certificate) identity and must never be prompted when selecting a profile. A profile
// that isn't cert-configured is an admin setup concern, not a sign-in for the technician.
private bool ShouldPromptForCredentials =>
Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert
&& UserContext.Role == UserRole.Admin
&& _credModal is not null;
private async Task HandleOAuthCallbackAsync()
{
var uri = new Uri(Nav.Uri);
@@ -320,8 +330,9 @@
// operating on the old connection.
await RefreshCredentialState();
// New profile selected and no valid credentials for it → prompt to connect.
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
await _credModal.ShowAsync();
// Standard technicians are never prompted (see ShouldPromptForCredentials).
if (ShouldPromptForCredentials)
await _credModal!.ShowAsync();
});
}
+1 -1
View File
@@ -1,5 +1,5 @@
@page "/admin/audit"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
@inject IAuditService AuditService
@inject IUserContextAccessor UserContext
@inject NavigationManager Nav
+3 -4
View File
@@ -1,5 +1,5 @@
@page "/admin/users"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject IAuditService Audit
@@ -87,7 +87,7 @@ else
<td style="padding:8px">
<select class="form-input" style="width:130px"
value="@user.Role"
@onchange="e => OnRoleChange(user, e)"
@onchange="@(e => OnRoleChange(user, e))"
disabled="@(user.Email == UserContext.Email)">
@foreach (var role in Enum.GetValues<UserRole>())
{
@@ -198,8 +198,7 @@ else
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
try
{
var oldRole = user.Role;
await UserService.UpdateRoleAsync(user.Id, newRole);
var oldRole = await UserService.UpdateRoleAsync(user.Id, newRole);
user.Role = newRole;
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}.");
+11 -1
View File
@@ -79,6 +79,13 @@
{
<span class="chip chip-green">@T["profiles.active"]</span>
}
@if (!AppOnlyFactory.IsConfigured(p))
{
<span class="chip chip-yellow"
title="No certificate configured — standard technicians can't use this profile. Open it and run 'Register app' (or upload a certificate) to enable shared access.">
⚠ No shared access
</span>
}
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
</button>
@@ -393,7 +400,10 @@
if (_editing == null)
{
_form.Id = Guid.NewGuid().ToString();
// Keep the Id assigned by the TenantProfile constructor. "Register app" may have
// already provisioned the cert and stored it on disk under this Id; reassigning a
// fresh Guid here would orphan that cert file and make the UI re-prompt for upload.
if (string.IsNullOrEmpty(_form.Id)) _form.Id = Guid.NewGuid().ToString();
_profiles.Add(_form);
}
else
+39 -5
View File
@@ -1,14 +1,20 @@
@inject AuthenticationStateProvider AuthProvider
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject NavigationManager Nav
@inject ILogger<AppInitializer> Logger
@implements IDisposable
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing
@using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session
@* Invisible component. Run once per circuit to seed IUserContextAccessor. *@
@* Invisible component. Seeds IUserContextAccessor on circuit init and re-reads it from the
store on every navigation, so an admin's role change applies on the user's next page change. *@
@code {
private string? _email;
protected override async Task OnInitializedAsync()
{
var state = await AuthProvider.GetAuthenticationStateAsync();
@@ -20,23 +26,51 @@
return;
}
var email = principal.FindFirst("preferred_username")?.Value
_email = principal.FindFirst("preferred_username")?.Value
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(email))
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);
await SeedAsync(logSeed: true);
// Re-read the user (and current role) from the store on each navigation. The role lives
// in the scoped UserContextAccessor for the circuit's lifetime, so without this a role
// change made by an admin would not reach the affected user's live session.
Nav.LocationChanged += OnLocationChanged;
}
private async Task SeedAsync(bool logSeed)
{
if (string.IsNullOrEmpty(_email)) return;
var user = await UserService.GetByEmailAsync(_email);
if (user is null)
{
Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", email);
Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", _email);
return;
}
if (logSeed)
Logger.LogInformation("AppInitializer: seeded UserContext for '{Email}' (role {Role}).", user.Email, user.Role);
UserContext.Initialize(user);
}
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
try
{
await SeedAsync(logSeed: false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "AppInitializer: failed to refresh UserContext on navigation to '{Location}'.", e.Location);
}
}
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
}
+36
View File
@@ -0,0 +1,36 @@
namespace SharepointToolbox.Web.Core.Config;
/// <summary>
/// The app's public domain (e.g. <c>sptb.example.com</c> or <c>https://sptb.example.com</c>),
/// configured via <c>App__Domain</c>. Used to derive the SharePoint-connect redirect URI when
/// <see cref="ClientConnectOptions.RedirectUri"/> isn't set explicitly.
/// </summary>
public class AppDomainOptions
{
public string Domain { get; set; } = string.Empty;
/// <summary>
/// Builds an absolute URL for <paramref name="path"/> rooted at the configured domain, or
/// <c>null</c> when no domain is set. Defaults to <c>https</c> when the domain has no scheme,
/// and tolerates accidental surrounding quotes / trailing slashes (docker-compose's list-form
/// env values can embed literal quotes).
/// </summary>
public string? BuildUrl(string path)
{
var domain = Domain?.Trim().Trim('"', '\'').TrimEnd('/');
if (string.IsNullOrEmpty(domain))
return null;
if (!domain.Contains("://", StringComparison.Ordinal))
domain = "https://" + domain;
return domain + "/" + path.TrimStart('/');
}
/// <summary>
/// The configured domain as an absolute base <see cref="Uri"/> (scheme + host [+ port]), or
/// <c>null</c> when no domain is set or it can't be parsed.
/// </summary>
public Uri? GetBaseUri() =>
BuildUrl("/") is { } url && Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null;
}
+14
View File
@@ -4,4 +4,18 @@ public static class StringExtensions
{
public static string? TrimOrNull(this string? s)
=> string.IsNullOrWhiteSpace(s) ? null : s.Trim();
/// <summary>
/// Returns <paramref name="returnUrl"/> only when it is a safe site-relative path,
/// otherwise "/". Rejects absolute URLs and protocol-relative paths ("//evil.com",
/// "/\evil.com") so a post-auth / post-connect redirect can never leave the app.
/// Used by every login and OAuth-connect redirect to prevent open redirects.
/// </summary>
public static string ToLocalReturnUrl(this string? returnUrl)
{
if (string.IsNullOrEmpty(returnUrl)) return "/";
if (returnUrl[0] != '/') return "/"; // not site-relative
if (returnUrl.Length > 1 && (returnUrl[1] == '/' || returnUrl[1] == '\\')) return "/"; // protocol-relative
return returnUrl;
}
}
+13
View File
@@ -15,4 +15,17 @@ public class AppUser
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastLogin { get; set; }
// ── Local-account brute-force lockout ───────────────────────────────────────
// Consecutive failed password attempts and, once the threshold is hit, the UTC
// instant the account unlocks again. Only meaningful for AuthProvider.Local.
// A per-account counter (not just an IP rate limiter) is the control that holds
// up here: forwarded headers are trusted from any source, so an attacker who can
// rotate X-Forwarded-For would evade IP-based throttling but not this.
/// <summary>Consecutive failed local-login attempts since the last success.</summary>
public int FailedLoginCount { get; set; }
/// <summary>UTC instant the account unlocks; null when not locked.</summary>
public DateTimeOffset? LockoutEndUtc { get; set; }
}
+19 -4
View File
@@ -1,6 +1,5 @@
# Base images pinned to exact patch for reproducible builds. Floating `:10.0` tags
# drift; a stale/pre-GA SDK base silently drops the Blazor framework static assets
# (blazor.web.js) from the publish manifest → 404 in production. Bump deliberately.
# drift between machines; bump deliberately. (SDK 10.0.203 + runtime 10.0.8.)
FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS base
WORKDIR /app
EXPOSE 8080
@@ -9,17 +8,33 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
FROM mcr.microsoft.com/dotnet/sdk:10.0.300 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0.203 AS build
WORKDIR /src
COPY ["SharepointToolbox.Web.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
# Do NOT add --no-restore here. The restore above runs with only the .csproj present
# (no source, no wwwroot); pairing that cached state with `publish --no-restore`
# silently drops the Blazor framework static assets (wwwroot/_framework/blazor.web.js)
# from the output → the boot script 404s and no interactive circuit starts on any page.
# Letting publish restore against the full project re-materializes them. (Reproduced;
# the early restore above is kept only to cache the NuGet layer.)
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
# Run as the non-root `app` user shipped in the aspnet image (UID 1654) instead of root.
# /data holds the crown jewels (Data Protection keys, app-only certs, the user store), so
# create it owned by `app` with 0700 before declaring the volume — Docker seeds a fresh
# named volume from the image path's ownership/mode, so the running user can write it and
# other host users can't read the keys/certs at rest.
RUN mkdir -p /data \
&& chown -R app:app /app /data \
&& chmod 700 /data
USER app
# Volume for persistent data (profiles, settings, templates, logs, exports)
VOLUME ["/data"]
+27 -7
View File
@@ -4,6 +4,7 @@ using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
using SharepointToolbox.Web.Core.Config;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Auth;
@@ -51,7 +52,10 @@ public static class OAuthEndpoints
TenantId = profile.TenantId,
ClientId = profile.ClientId,
SpHost = ExtractHost(profile.TenantUrl),
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl,
// Constrain to a site-relative path: the callback appends the redeemable
// token_key to this URL, so an external returnUrl would leak the client's
// SharePoint refresh token off-domain.
ReturnUrl = returnUrl.ToLocalReturnUrl(),
IsRegistration = false,
});
@@ -68,12 +72,18 @@ public static class OAuthEndpoints
string? error_description,
IOAuthFlowCache flowCache,
IOptions<ClientConnectOptions> opts,
IHttpClientFactory httpClientFactory) =>
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory) =>
{
var log = loggerFactory.CreateLogger("SharepointToolbox.Web.OAuth.Connect");
if (!string.IsNullOrEmpty(error))
{
var errMsg = Uri.EscapeDataString(error_description ?? error);
return Results.Redirect($"/?connect_error={errMsg}");
// The provider's verbose error_description can carry correlation/trace ids and
// lands in the URL bar + proxy access logs. Log it server-side; surface only the
// short, safe OAuth error code (e.g. "access_denied") to the browser.
log.LogWarning("Connect callback returned error {Error}: {Description}", error, error_description);
return Results.Redirect($"/?connect_error={Uri.EscapeDataString(error)}");
}
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
@@ -103,14 +113,24 @@ public static class OAuthEndpoints
if (!resp.IsSuccessStatusCode)
{
var msg = Uri.EscapeDataString($"Token exchange failed: {json}");
return Results.Redirect($"/?connect_error={msg}");
// The raw token-endpoint body can contain trace ids / claim hints — keep it out of
// the URL and the proxy logs. Record it server-side, redirect with a generic notice.
log.LogWarning("Token exchange failed ({Status}): {Body}", resp.StatusCode, json);
return Results.Redirect($"/?connect_error={Uri.EscapeDataString("Token exchange failed. Please try connecting again.")}");
}
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var upn = ExtractUpnFromIdToken(root);
var refreshToken = root.GetProperty("refresh_token").GetString()!;
// offline_access should yield a refresh_token; if the tenant/app withheld it the
// session can't be persisted. Fail cleanly instead of throwing a 500 + stack trace.
if (!root.TryGetProperty("refresh_token", out var refreshTokenEl) ||
refreshTokenEl.GetString() is not { Length: > 0 } refreshToken)
{
log.LogWarning("Token response had no refresh_token for tenant {Tenant}.", flowState.TenantId);
return Results.Redirect($"/?connect_error={Uri.EscapeDataString("Sign-in did not return a refresh token. Please try again.")}");
}
var tokens = new SessionTokens
{
+1 -1
View File
@@ -1564,7 +1564,7 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<value>Sélectionné</value>
</data>
<data name="profiles.subtitle" xml:space="preserve">
<value>Gérez les connexions aux tenants SharePoint. Les identifiants sont saisis par session — aucun secret n'est stocké sur le disque.</value>
<value>Gérez les connexions aux tenants SharePoint. Les identifiants sont saisis par session.</value>
</data>
<data name="profiles.tenantid.label" xml:space="preserve">
<value>ID de tenant :</value>
+1 -1
View File
@@ -1564,7 +1564,7 @@ This tab does the reverse: you select one or more users and it finds every objec
<value>Selected</value>
</data>
<data name="profiles.subtitle" xml:space="preserve">
<value>Manage SharePoint tenant connections. Credentials are entered per session — no secrets stored on disk.</value>
<value>Manage SharePoint tenant connections. Credentials are entered per session.</value>
</data>
<data name="profiles.tenantid.label" xml:space="preserve">
<value>Tenant ID:</value>
+145 -16
View File
@@ -6,10 +6,13 @@ 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;
@@ -69,6 +72,12 @@ builder.Services.AddDataProtection()
// 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())
{
@@ -103,16 +112,25 @@ else
// Auth state lives entirely in the browser cookie (Data Protection encrypted)
options.SessionStore = null;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
// 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");
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;
// 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 +
@@ -128,6 +146,34 @@ else
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>();
@@ -158,7 +204,32 @@ else
});
}
builder.Services.AddAuthorization();
// "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();
@@ -167,6 +238,19 @@ 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 =>
@@ -257,6 +341,23 @@ 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
@@ -282,6 +383,33 @@ app.UseForwardedHeaders();
}
}
// ── 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);
@@ -294,6 +422,7 @@ app.UseStatusCodePagesWithReExecute("/not-found");
app.MapStaticAssets();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseAntiforgery();
// ── Login / Logout endpoints ──────────────────────────────────────────────────
@@ -332,7 +461,7 @@ if (isDev)
CookieAuthenticationDefaults.AuthenticationScheme));
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
ctx.Response.Redirect(returnUrl.ToLocalReturnUrl());
});
}
else
@@ -342,10 +471,10 @@ else
{
var props = new AuthenticationProperties
{
RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl
RedirectUri = returnUrl.ToLocalReturnUrl()
};
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
});
}).RequireRateLimiting("login");
}
// Local password sign-in — available in every environment.
@@ -358,7 +487,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
var email = form["email"].ToString();
var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
var safeReturn = string.IsNullOrEmpty(returnUrl) || !returnUrl.StartsWith('/') ? "/" : returnUrl;
var safeReturn = returnUrl.ToLocalReturnUrl();
var user = await userService.ValidateLocalCredentialsAsync(email, password);
if (user is null)
@@ -376,7 +505,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Results.Redirect(safeReturn);
});
}).RequireRateLimiting("login");
app.MapGet("/account/logout", async (HttpContext ctx) =>
{
@@ -431,13 +560,13 @@ app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
{
string Esc(string v) => v.Contains(',') || v.Contains('"') || v.Contains('\n')
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
// 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(",",
Esc(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
Esc(e.UserEmail), Esc(e.UserDisplay), Esc(e.UserRole.ToString()),
Esc(e.Action), Esc(e.ClientName),
Esc(string.Join("; ", e.Sites)), Esc(e.Details)));
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");
});
+82 -4
View File
@@ -19,7 +19,7 @@ A web admin toolbox for Microsoft 365 / SharePoint Online, built with Blazor Ser
## Configuration
Authentication uses Microsoft OIDC (interactive sign-in) and, for scheduled reports, app-only certificate auth.
Signing into the toolbox itself uses Microsoft OIDC (or a local account). Per-profile SharePoint/Graph access uses **certificate (app-only) auth** as the primary path — the same app identity drives both technician work and scheduled reports, so technicians never sign in per profile. Profiles without a certificate fall back to an interactive per-profile sign-in.
Set these as environment variables (or in `appsettings.json` under the `Oidc` section). .NET maps `Section__Key` to `Section:Key`.
@@ -28,17 +28,72 @@ 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` |
| `App__Domain` | Public domain the app is reached at, e.g. `sptb.example.com` or `https://sptb.example.com` (scheme defaults to `https`). Pins the OIDC sign-in redirect (`/signin-oidc`) and derives the SharePoint-connect redirect URI. |
| `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). When `App__Domain` is set, the redirect is pinned to `<domain>/signin-oidc`; otherwise it's derived from the request host (`X-Forwarded-Host`/`Host`).
→ 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 SharePoint/Graph token for a client tenant. The primary path is **certificate (app-only) auth** (see below), which needs no redirect URI. Profiles without a certificate fall back to an interactive PKCE public-client flow that uses **each connection profile's own `ClientId`/`TenantId`** — not the `Oidc__*` app. That fallback's callback is derived from `App__Domain` as `<domain>/connect/callback`; set `ClientConnect__RedirectUri` to override the full URL directly.
→ Only when using the interactive fallback: on **each client-tenant profile's** app registration, add that callback value (e.g. `https://your-host/connect/callback`) under the **Mobile and desktop / public client** platform. See [Per-profile app registration permissions](#per-profile-app-registration-permissions) below for the API permissions it needs.
### Per-profile app registration permissions
The **Register App** action in a profile provisions this registration for you (single tenant, public client) and grants org-wide admin consent automatically. The list below documents what it creates, for manual registration or auditing.
Per-profile access uses **certificate (app-only) auth** as the primary path: the app identity drives both interactive technician work and unattended features (scheduled reports, tenant-wide audits), so technicians never sign in per profile. The cert is provisioned alongside the registration and attached as a sign-in credential. The registration grants **application permissions** for that app-only auth; it also keeps **delegated scopes** for the interactive connect flow that profiles *without* a certificate fall back to.
**Application permissions** (app-only certificate auth — primary):
| API | Permission | Used for |
|-----|------------|----------|
| Microsoft Graph | `User.Read.All` | Look up users (unattended) |
| Microsoft Graph | `Group.ReadWrite.All` | Read group members; add members/owners (unattended) |
| Microsoft Graph | `Directory.Read.All` | Expand M365/AAD group membership in the user-access audit |
| Microsoft Graph | `Sites.FullControl.All` | Site operations (unattended) |
| Microsoft Graph | `Mail.Send` | Send emailed scheduled reports |
| SharePoint | `Sites.FullControl.All` | App-only CSOM |
**Delegated permissions** (interactive connect flow — only for profiles without a certificate):
| API | Permission | Used for |
|-----|------------|----------|
| Microsoft Graph | `User.Read` | Signed-in user's basic profile |
| Microsoft Graph | `User.Read.All` | Look up users by email/UPN |
| Microsoft Graph | `Group.ReadWrite.All` | Read group members; add members/owners |
| Microsoft Graph | `Sites.Read.All` | Resolve a site's `groupId` from its `siteId` |
| SharePoint | `AllSites.FullControl` | CSOM — site permissions, content, admin operations |
All permissions above require **admin consent**.
> **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.
> **Reverse-proxy host.** Set `App__Domain` so the app builds every redirect (cookie login, OIDC) against the public domain regardless of what host the proxy forwards. Without it, a proxy that doesn't forward the `Host` header makes the app 302 to the internal `IP:port` it actually received.
Persistent state (profiles, settings, templates, logs, exports, certs) lives in `DataFolder`.
## Installation — Docker
## Installation — Docker (prebuilt image)
Pulls the published image from the Gitea registry — no local build needed.
```bash
cp .env.example .env # then edit .env with your OIDC values
docker compose -f docker-compose.prebuilt.yml pull
docker compose -f docker-compose.prebuilt.yml up -d
```
The compose file reads config from `.env` (see [`.env.example`](.env.example)). Pin a
version with `SPTB_TAG`, e.g. `SPTB_TAG=v1.2.0` in `.env`. Don't quote values — the
list form embeds literal quotes and breaks OIDC discovery.
## Installation — Docker (build locally)
```bash
docker compose up -d --build
@@ -106,3 +161,26 @@ Runs in `Development` mode — OIDC off, auto-login as Admin. No Entra config ne
## Tech stack
.NET 10 · Blazor Server · Microsoft Graph SDK · PnP.Framework · Serilog · CsvHelper
## Dependencies
NuGet packages and their pinned versions (see [`SharepointToolbox.Web.csproj`](SharepointToolbox.Web.csproj)). Last reviewed 2026-06-26.
| Package | Version | Purpose |
|---------|---------|---------|
| `Azure.Identity` | 1.21.0 | Entra ID token credentials — app-only certificate and client-secret auth |
| `CsvHelper` | 33.1.0 | CSV parsing for bulk import/export (sites, members, folder structures) |
| `Microsoft.AspNetCore.Authentication.OpenIdConnect` | 10.0.9 | OIDC "Sign in with Microsoft" app sign-in |
| `Microsoft.Graph` | 6.2.0 | Microsoft Graph SDK — users, groups, sites, mail |
| `Microsoft.Kiota.Abstractions` | 2.0.0 | Graph SDK request runtime (abstractions) |
| `Microsoft.Kiota.Authentication.Azure` | 2.0.0 | Graph SDK Azure auth provider |
| `Microsoft.Kiota.Http.HttpClientLibrary` | 2.0.0 | Graph SDK HTTP transport |
| `Microsoft.Kiota.Serialization.Form` | 2.0.0 | Graph SDK form serialization |
| `Microsoft.Kiota.Serialization.Json` | 2.0.0 | Graph SDK JSON serialization |
| `Microsoft.Kiota.Serialization.Multipart` | 2.0.0 | Graph SDK multipart serialization |
| `Microsoft.Kiota.Serialization.Text` | 2.0.0 | Graph SDK text serialization |
| `PnP.Framework` | 1.19.0 | SharePoint CSOM — site permissions, content, admin operations |
| `Serilog.AspNetCore` | 10.0.0 | Structured logging integration |
| `Serilog.Sinks.File` | 7.0.0 | Rolling-file log sink |
To check for newer releases: `dotnet list package --outdated`.
+93
View File
@@ -0,0 +1,93 @@
# Security findings
Review date: 2026-06-11. Items 14 and 6 fixed the same day; #5 reviewed and accepted by design.
Second pass (OWASP Top 10), 2026-06-11: items 711 below found and fixed the same day.
---
## 1. [HIGH] ✅ FIXED — Open redirect in `/connect/initiate` leaked SharePoint session tokens
**Was:** `returnUrl` from the query string was stored verbatim in the OAuth flow state and used as the final redirect target after the code exchange — with `token_key` (a 2-minute, redeemable handle to the connected client's refresh token) appended. `…?returnUrl=https://evil.com` leaked that handle off-domain, enabling connection hijack.
**Fix:** `returnUrl` is now constrained to a site-relative path via the new `string.ToLocalReturnUrl()` helper before it is stored ([Infrastructure/OAuth/OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)). The helper rejects absolute *and* protocol-relative (`//host`, `/\host`) URLs.
---
## 2. [MEDIUM] ✅ FIXED — CSV formula injection in audit-log exports
**Was:** Both audit CSV exporters quoted per RFC 4180 but didn't neutralize a leading `=` `+` `-` `@`, so user-controlled fields (display/client/site names, details) could execute as formulas in Excel.
**Fix:** Both paths now use `CsvSanitizer.Escape` (which neutralizes formula prefixes): the `/audit/export` endpoint ([Program.cs](Program.cs)) and `AuditService.ExportCsvAsync` ([Services/Audit/AuditService.cs](Services/Audit/AuditService.cs)). The local `CsvEscape`/`Esc` duplicates were removed.
---
## 3. [LOW-MEDIUM] ✅ FIXED — Spoofable `X-Forwarded-Proto` could yield a non-Secure auth cookie
**Was:** Forwarded headers are trusted from any source (proxy IP unknown inside the container network), and the prod auth cookie used `SecurePolicy = SameAsRequest` — so a spoofed `X-Forwarded-Proto: http` or a direct plaintext hit could emit a non-Secure cookie.
**Fix:** Prod auth cookie now uses `CookieSecurePolicy.Always` ([Program.cs](Program.cs)), so the Secure flag is set regardless of the (untrusted) forwarded scheme. The forwarded-headers trust config is unchanged (still required behind the proxy); the residual XFF concern is limited — client IP is not used for auth and is not recorded in the audit log.
---
## 4. [LOW] ✅ FIXED — Open redirect in `/account/login/entra` and `/account/login/dev`
**Was:** `returnUrl` was used directly as the post-auth redirect on the entra and dev sign-in paths; only local-login validated it (and even that check allowed protocol-relative `//evil.com`).
**Fix:** All three sign-in redirects now route `returnUrl` through `ToLocalReturnUrl()` ([Program.cs](Program.cs)), which also closes the protocol-relative gap in the old local-login check.
---
## 5. [LOW] ⚪ ACCEPTED (by design) — Authenticated users can download any export / report
**Where:** `/export/download/{fileName}` and `/reports/download/{id}` ([Program.cs](Program.cs)).
**Decision:** Not changed. The app has no per-user profile authorization anywhere — any tech may select and use any shared profile (see the "standard techs use profiles without sign-in" design), and `GeneratedReport` carries only a `ProfileId`, no owner. Reports are listed and shared *per client/profile*, not per user, so per-user download scoping would contradict the product model rather than fix a boundary. Both endpoints already require authentication and strip path traversal (`Path.GetFileName`). Revisit only if a per-tech profile ACL is introduced.
---
## 6. [LOW] ✅ FIXED — Admin pages gated only by a render-time role check
**Was:** `/admin/users` and `/admin/audit` used `@attribute [Authorize]` (any authenticated user) plus an in-markup `if (Role != Admin) return;`.
**Fix:** Added an `Admin` authorization policy (`RequireClaim("app_role", "Admin")`) in [Program.cs](Program.cs) and both pages now use `[Authorize(Policy = "Admin")]` ([UserManagement.razor](Components/Pages/Admin/UserManagement.razor), [AuditLogs.razor](Components/Pages/Admin/AuditLogs.razor)). A claim-value policy (not `[Authorize(Roles=…)]`) was used deliberately: the local/dev sign-in identities don't set a `ClaimTypes.Role` claim, so a Roles check would have silently denied local admins. The in-component checks were kept as defense-in-depth.
---
## 7. [MEDIUM] ✅ FIXED — No brute-force protection on local sign-in (OWASP A07)
**Was:** `/account/local-login` and `UserService.ValidateLocalCredentialsAsync` had no rate limiting and no account lockout, leaving local accounts (incl. the bootstrap admin) open to unlimited password guessing — relevant given the plain-HTTP/LAN bootstrap deployment mode.
**Fix:** Two layers. (1) A per-account lockout on `AppUser` (`FailedLoginCount`/`LockoutEndUtc`) — 5 consecutive failures → 15-minute lock, counter cleared on success ([UserService.cs](Services/Auth/UserService.cs), [AppUser.cs](Core/Models/AppUser.cs)). A locked account is refused before the password is even checked, and failure stays generic (no enumeration). (2) An IP-partitioned fixed-window rate limiter (`"login"` policy, 10/min) on `/account/local-login` and `/account/login/entra` ([Program.cs](Program.cs)). The account lockout is the control that holds up when `X-Forwarded-For` is spoofed/rotated (forwarded headers are trusted from any source — see #3); the IP limiter is the coarse volumetric layer.
---
## 8. [MEDIUM] ✅ FIXED — Missing security response headers (OWASP A05)
**Was:** No `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, or `Referrer-Policy`. A clickjacked page could drive the victim's live Blazor Server circuit, and there was no CSP backstop against injected script.
**Fix:** A middleware emits these on every response ([Program.cs](Program.cs)). CSP is tuned to the app: all scripts are external so `script-src 'self'` (no `unsafe-*`); `style-src` keeps `'unsafe-inline'` for the login page's inline `<style>` and the components' `style=""`; `img-src 'self' data:` for base64 client logos / download links; `connect-src 'self'` for the SignalR WebSocket; `frame-ancestors 'none'` (+ `X-Frame-Options: DENY`) blocks clickjacking.
---
## 9. [LOW-MEDIUM] ✅ FIXED — Container ran as root; `/data` world-readable (OWASP A05 / A02)
**Was:** The [Dockerfile](Dockerfile) had no `USER` directive, so the process ran as root and the `/data` volume (Data Protection keys, app-only certs, user store) had default permissions. The DP keys decrypt the auth cookie, the browser-stored SharePoint refresh tokens, and the on-disk certs — a large blast radius for anyone able to read the volume.
**Fix:** Final image now creates `/data` owned by the shipped non-root `app` user with mode `0700`, then `USER app` ([Dockerfile](Dockerfile)). Docker seeds a fresh named volume from that ownership/mode, so the running user can write it while other host users can't read the keys/certs at rest. (DP keys themselves remain unencrypted at rest — inherent to file-based keys in a container; the volume permissions are the mitigation.)
---
## 10. [LOW] ✅ FIXED — Raw token-endpoint response reflected into the redirect URL (OWASP A09)
**Was:** `/connect/callback` put the raw Azure token-endpoint JSON (and the provider's verbose `error_description`) into `?connect_error=…`, landing in browser history and — behind the proxy — proxy access logs. Those bodies can carry correlation/trace ids and claim hints.
**Fix:** The token-endpoint failure body and the verbose `error_description` are now logged server-side via `ILogger`; the redirect carries only a generic notice (or the short, safe OAuth `error` code) ([OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)).
---
## 11. [LOW] ✅ FIXED — `/connect/callback` threw a 500 when no refresh_token was returned
**Was:** `root.GetProperty("refresh_token").GetString()!` threw `KeyNotFoundException` (→ 500 + stack trace in non-prod) when the tenant/app withheld `offline_access`.
**Fix:** Uses `TryGetProperty`; a missing/empty refresh token is logged and redirects with a friendly `connect_error` instead of throwing ([OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)).
+11 -15
View File
@@ -2,6 +2,7 @@ using System.Text;
using Microsoft.AspNetCore.Authentication;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Export;
using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Services.Audit;
@@ -41,23 +42,18 @@ public class AuditService : IAuditService
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
{
// CsvSanitizer adds spreadsheet formula-injection guards (= + - @) on top of
// RFC 4180 quoting; the user/display/client/site fields are user-controlled.
sb.AppendLine(string.Join(",",
CsvEscape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
CsvEscape(e.UserEmail),
CsvEscape(e.UserDisplay),
CsvEscape(e.UserRole.ToString()),
CsvEscape(e.Action),
CsvEscape(e.ClientName),
CsvEscape(string.Join("; ", e.Sites)),
CsvEscape(e.Details)));
CsvSanitizer.Escape(e.Timestamp.ToLocalTime().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 sb.ToString();
}
private static string CsvEscape(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
+3 -1
View File
@@ -11,7 +11,9 @@ public interface IUserService
Task<AppUser?> GetByEmailAsync(string email);
Task<IReadOnlyList<AppUser>> GetAllAsync();
Task UpdateRoleAsync(string userId, UserRole role);
/// <summary>Persist a new role for the user. Returns the previous role (read from the store).</summary>
/// <exception cref="KeyNotFoundException">No user matches <paramref name="userId"/>.</exception>
Task<UserRole> UpdateRoleAsync(string userId, UserRole role);
Task DeleteAsync(string userId);
/// <summary>Create a local password-based account. First user ever becomes Admin.</summary>
+54 -4
View File
@@ -1,5 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
@@ -9,11 +10,13 @@ public class UserService : IUserService
{
private readonly UserRepository _repo;
private readonly IPasswordHasher<AppUser> _hasher;
private readonly ILogger<UserService> _logger;
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher)
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher, ILogger<UserService> logger)
{
_repo = repo;
_hasher = hasher;
_logger = logger;
}
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
@@ -56,13 +59,23 @@ public class UserService : IUserService
public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync();
public async Task UpdateRoleAsync(string userId, UserRole role)
public async Task<UserRole> UpdateRoleAsync(string userId, UserRole role)
{
var users = (await _repo.LoadAsync()).ToList();
var user = users.FirstOrDefault(u => u.Id == userId)
?? throw new KeyNotFoundException($"User {userId} not found.");
?? throw new KeyNotFoundException($"User '{userId}' not found among {users.Count} stored users.");
var oldRole = user.Role;
user.Role = role;
await _repo.UpsertAsync(user);
// Verify the write landed by re-reading the row from disk.
var persisted = (await _repo.LoadAsync()).FirstOrDefault(u => u.Id == user.Id)?.Role;
_logger.LogInformation(
"UpdateRoleAsync: {Email} (id {Id}) {OldRole} → {NewRole}; persisted value now {Persisted}.",
user.Email, user.Id, oldRole, role, persisted);
return oldRole;
}
public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId);
@@ -96,21 +109,58 @@ public class UserService : IUserService
return user;
}
// Brute-force lockout: after this many consecutive failures the account is locked
// for the window below. Tuned to stop guessing while staying usable for a fat-fingered
// admin. The counter resets on any successful sign-in.
private const int LockoutThreshold = 5;
private static readonly TimeSpan LockoutWindow = TimeSpan.FromMinutes(15);
public async Task<AppUser?> ValidateLocalCredentialsAsync(string email, string password)
{
var user = await _repo.FindByEmailAsync(email);
if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash))
return null;
var now = DateTimeOffset.UtcNow;
// Account currently locked → refuse without even checking the password, so a locked
// account can't be probed. Returning null (generic failure) avoids account enumeration.
if (user.LockoutEndUtc is { } until && until > now)
{
_logger.LogWarning("Local login blocked: account {Email} is locked until {Until:o}.", user.Email, until);
return null;
}
// Lock window elapsed → clear it before re-evaluating.
if (user.LockoutEndUtc is not null)
{
user.LockoutEndUtc = null;
user.FailedLoginCount = 0;
}
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (result == PasswordVerificationResult.Failed)
{
user.FailedLoginCount++;
if (user.FailedLoginCount >= LockoutThreshold)
{
user.LockoutEndUtc = now + LockoutWindow;
_logger.LogWarning(
"Local login: account {Email} locked for {Minutes} min after {Count} failed attempts.",
user.Email, LockoutWindow.TotalMinutes, user.FailedLoginCount);
}
await _repo.UpsertAsync(user);
return null;
}
// Transparently upgrade the hash if the algorithm parameters changed
if (result == PasswordVerificationResult.SuccessRehashNeeded)
user.PasswordHash = _hasher.HashPassword(user, password);
user.LastLogin = DateTimeOffset.UtcNow;
// Success → clear the failure trail.
user.FailedLoginCount = 0;
user.LockoutEndUtc = null;
user.LastLogin = now;
await _repo.UpsertAsync(user);
return user;
}
+12 -12
View File
@@ -26,19 +26,19 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.14.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.74.0" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.22.2" />
<PackageReference Include="PnP.Framework" Version="1.18.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.9" />
<PackageReference Include="Microsoft.Graph" Version="6.2.0" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="2.0.0" />
<PackageReference Include="PnP.Framework" Version="1.19.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
+3
View File
@@ -1,5 +1,8 @@
{
"DataFolder": "/data",
"App": {
"Domain": ""
},
"Oidc": {
"TenantId": "YOUR_ENTRA_TENANT_ID",
"ClientId": "YOUR_SPTB_APP_CLIENT_ID",
+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"
+45
View File
@@ -0,0 +1,45 @@
# Runs the prebuilt image from the Gitea registry (no local build).
# docker compose -f docker-compose.prebuilt.yml pull
# docker compose -f docker-compose.prebuilt.yml up -d
#
# Pin a version by overriding the tag: SPTB_TAG=v1.2.0 docker compose ...
# Set the OIDC secrets via a .env file next to this compose file (see below).
services:
sptb-web:
image: git.azuze.fr/kawa/sptb-web:${SPTB_TAG:-latest}
container_name: sptb-web
ports:
- "8080:8080"
volumes:
- sptb-data:/data
environment:
- ASPNETCORE_ENVIRONMENT=Production
- DataFolder=/data
# Public domain the app is reached at (e.g. sptb.example.com). The SharePoint-connect
# redirect URI is derived from it as <domain>/connect/callback.
- App__Domain=${App__Domain:-}
# OIDC config — overrides the placeholder values baked into appsettings.json.
# Authority is derived from TenantId in code; do NOT set an Authority key.
# Put real values in a .env file beside this compose file (NO quotes around
# values — the list form embeds literal quotes and breaks discovery):
# Oidc__TenantId=<entra-tenant-guid>
# Oidc__ClientId=<app-client-id>
# Oidc__ClientSecret=<app-client-secret>
- Oidc__TenantId=${Oidc__TenantId:-}
- Oidc__ClientId=${Oidc__ClientId:-}
- Oidc__ClientSecret=${Oidc__ClientSecret:-}
# Optional: seed first admin while the user store is empty (local form login).
- Bootstrap__AdminEmail=${Bootstrap__AdminEmail:-}
- Bootstrap__AdminPassword=${Bootstrap__AdminPassword:-}
restart: unless-stopped
healthcheck:
# /account/login is anonymous and returns 200; -f fails on >=400.
test: ["CMD", "curl", "-fsS", "http://localhost:8080/account/login"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
sptb-data:
driver: local
+4
View File
@@ -12,6 +12,10 @@ services:
environment:
- ASPNETCORE_ENVIRONMENT=Production
- DataFolder=/data
# Public domain the app is reached at (e.g. sptb.example.com). The SharePoint-connect
# redirect URI (<domain>/connect/callback) is derived from it. Set your OIDC values
# here too, or pass an env file.
- App__Domain=${App__Domain:-}
restart: unless-stopped
healthcheck:
# /account/login is anonymous and returns 200 (the app root now 302-redirects