Resolve all high-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 16:13:58 -04:00
parent 8bfe65512e
commit 146a13238f
192 changed files with 3160 additions and 753 deletions
@@ -40,7 +40,7 @@ public enum SessionValidationAction
/// <summary>The session is idle-timed-out; reject + sign out.</summary>
Reject,
/// <summary>The role mapping was refreshed; replace the principal and renew the cookie.</summary>
/// <summary>The role mapping was refreshed; replace the principal. The HTTP adapter decides whether renewal is appropriate.</summary>
Replace,
}
@@ -56,9 +56,8 @@ public enum SessionValidationAction
/// computed from the <see cref="JwtTokenService.LastActivityClaimType"/> anchor. This is
/// the explicit, deterministic counterpart to the cookie middleware's
/// <c>ExpireTimeSpan</c> + <c>SlidingExpiration</c> window — both use the SAME idle
/// timeout value, so the explicit check never contradicts the cookie window. A
/// not-timed-out session has its last-activity anchor advanced to "now" (genuine
/// request = activity), mirroring the sliding renew.
/// timeout value. Passive validation preserves the activity anchor. Explicit
/// validated browser activity is also visible to live circuits through the session tracker.
/// </para>
/// <para>
/// <b>Role refresh</b> (default <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>
@@ -69,10 +68,9 @@ public enum SessionValidationAction
/// </para>
/// <para>
/// <b>Failure policy</b>: a refresh error (e.g. the mapper throws because the DB is
/// unreachable) NEVER signs the user out and NEVER throws out of validation — it returns
/// <see cref="SessionValidationResult.Keep"/>, mirroring the documented "LDAP failure:
/// active sessions continue with current roles" stance. Only the explicit idle-timeout
/// path rejects.
/// unreachable) preserves current grants during ordinary cookie validation.
/// Authoritative circuit mutation checks request fail-closed validation and propagate
/// lookup errors, preventing a mutation from using grants that could not be refreshed.
/// </para>
/// </remarks>
public sealed class CookieSessionValidator
@@ -81,6 +79,7 @@ public sealed class CookieSessionValidator
private readonly SecurityOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<CookieSessionValidator> _logger;
private readonly SessionActivityTracker? _activity;
/// <summary>Initializes the validator.</summary>
/// <param name="roleMapper">The DB-backed group→role mapping seam (no LDAP) used for the mid-session refresh.</param>
@@ -91,23 +90,24 @@ public sealed class CookieSessionValidator
IGroupRoleMapper<string> roleMapper,
IOptions<SecurityOptions> options,
TimeProvider timeProvider,
ILogger<CookieSessionValidator> logger)
ILogger<CookieSessionValidator> logger, SessionActivityTracker? activity = null)
{
_roleMapper = roleMapper ?? throw new ArgumentNullException(nameof(roleMapper));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_activity = activity;
}
/// <summary>
/// Evaluates a cookie principal: enforces the idle timeout, then refreshes the
/// role/scope claims from the stored LDAP groups when the role-refresh interval has
/// elapsed. Never throws.
/// elapsed or a caller requires a fresh mapping. Lookup errors propagate when failClosed is set.
/// </summary>
/// <param name="principal">The current cookie principal under validation.</param>
/// <param name="ct">Cancellation token (the request-aborted token in the pipeline).</param>
/// <returns>The action the caller must take and any replacement principal.</returns>
public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default)
public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default, bool recordActivity = true, bool forceRefresh = false, bool failClosed = false)
{
// An unauthenticated / null principal is left to the rest of the pipeline.
if (principal?.Identity is not { IsAuthenticated: true })
@@ -129,16 +129,16 @@ public sealed class CookieSessionValidator
return SessionValidationResult.Reject;
}
// 2) Role-mapping refresh — best-effort. Any failure keeps the existing session.
// 2) Ordinary refresh keeps existing grants on failure; authoritative checks propagate it.
try
{
var refreshed = await TryRefreshAsync(principal, now, ct).ConfigureAwait(false);
var refreshed = await TryRefreshAsync(principal, now, ct, recordActivity, forceRefresh).ConfigureAwait(false);
if (refreshed is not null)
{
return SessionValidationResult.Replace(refreshed);
}
}
catch (Exception ex)
catch (Exception ex) when (!failClosed)
{
// SECURITY: never broaden access and never sign the user out on a transient
// refresh fault — keep the existing principal (current roles) and swallow.
@@ -168,16 +168,17 @@ public sealed class CookieSessionValidator
return true;
}
lastActivity = _activity?.LastActivity(principal) ?? lastActivity;
return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
}
// Returns a rebuilt principal when the role-refresh interval has elapsed; null when
// nothing changed. The principal is rebuilt via SessionClaimBuilder so its shape is
// identical to /auth/login.
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct)
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct, bool recordActivity, bool forceRefresh)
{
var roleRefreshDue = IsRoleRefreshDue(principal, now);
if (!roleRefreshDue)
if (!roleRefreshDue && !forceRefresh)
{
// No mapping refresh due. We deliberately do NOT mint a new principal just to
// advance LastActivity: the cookie middleware's SlidingExpiration already
@@ -207,7 +208,20 @@ public sealed class CookieSessionValidator
// Rebuild identically to /auth/login, advancing BOTH anchors: the role-refresh
// anchor (we just refreshed) and the idle anchor (this is a genuine request).
return SessionClaimBuilder.Build(username, displayName, groups, scope, now);
var rebuilt = SessionClaimBuilder.Build(username, displayName, groups, scope, now);
var identity = (ClaimsIdentity)rebuilt.Identity!;
var sessionId = principal.FindFirst(SessionActivityTracker.SessionIdClaim);
identity.RemoveClaim(identity.FindFirst(SessionActivityTracker.SessionIdClaim)!);
if (sessionId is not null) identity.AddClaim(new Claim(sessionId.Type, sessionId.Value));
if (!recordActivity)
{
identity.RemoveClaim(identity.FindFirst(JwtTokenService.LastActivityClaimType)!);
var lastActivity = _activity?.LastActivity(principal)?.ToString("o")
?? principal.FindFirst(JwtTokenService.LastActivityClaimType)!.Value;
identity.AddClaim(new Claim(JwtTokenService.LastActivityClaimType, lastActivity));
}
else _activity?.Record(rebuilt, now);
return rebuilt;
}
/// <summary>
@@ -68,6 +68,7 @@ public static class ServiceCollectionExtensions
// is the wall clock. TryAddSingleton keeps the Host free to register its own.
services.TryAddSingleton(TimeProvider.System);
services.AddScoped<CookieSessionValidator>();
services.AddSingleton<SessionActivityTracker>();
// Audit Actor wiring (Phase 3): the user-facing inbound API audit path
// sources AuditEvent.Actor from the authenticated principal via this
@@ -157,6 +158,11 @@ public static class ServiceCollectionExtensions
// throws — CookieSessionValidator.ValidateAsync swallows refresh faults and
// keeps the session (mirrors "LDAP failure: active sessions continue").
options.Events.OnValidatePrincipal = OnValidatePrincipalAsync;
options.Events.OnCheckSlidingExpiration = context =>
{
if (IsPassiveSessionRequest(context.HttpContext)) context.ShouldRenew = false;
return Task.CompletedTask;
};
});
}
@@ -218,13 +224,19 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>Requests that cannot by themselves establish user activity.</summary>
internal static bool IsPassiveSessionRequest(HttpContext context)
=> context.Request.Path.StartsWithSegments("/auth/ping")
|| context.Request.Path.StartsWithSegments("/auth/activity")
|| context.Request.Path.StartsWithSegments("/_blazor");
/// <summary>
/// The thin <see cref="CookieAuthenticationEvents.OnValidatePrincipal"/>
/// adapter. It resolves the request-scoped <see cref="CookieSessionValidator"/>,
/// asks it for a decision, and applies it to the cookie context:
/// <list type="bullet">
/// <item><see cref="SessionValidationAction.Reject"/> → <see cref="CookieValidatePrincipalContext.RejectPrincipal"/> + sign out (idle-timeout — the only sign-out path).</item>
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + <c>ShouldRenew = true</c> (role mapping refreshed).</item>
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + renewal only for non-passive requests (role mapping refreshed).</item>
/// <item><see cref="SessionValidationAction.Keep"/> → no-op (no refresh due, or a swallowed refresh fault).</item>
/// </list>
/// All logic lives in <see cref="CookieSessionValidator.ValidateAsync"/>, which never
@@ -237,7 +249,7 @@ public static class ServiceCollectionExtensions
var validator = context.HttpContext.RequestServices.GetRequiredService<CookieSessionValidator>();
var result = await validator
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted)
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted, recordActivity: !IsPassiveSessionRequest(context.HttpContext))
.ConfigureAwait(false);
await ApplyValidationResultAsync(context, result).ConfigureAwait(false);
@@ -272,7 +284,7 @@ public static class ServiceCollectionExtensions
// Role mapping refreshed from stored groups — swap in the rebuilt
// principal and re-issue the cookie so the new claims persist.
context.ReplacePrincipal(result.Principal);
context.ShouldRenew = true;
context.ShouldRenew = !IsPassiveSessionRequest(context.HttpContext);
break;
case SessionValidationAction.Keep:
@@ -0,0 +1,41 @@
using System.Security.Claims;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Bounded, process-local activity shared by an HTTP cookie and its existing Blazor
/// circuit. Only authenticated, validated interaction requests advance this clock.
/// The protected cookie remains the durable anchor on restart or node change.
/// </summary>
public sealed class SessionActivityTracker
{
/// <summary>Opaque per-login identifier carried in the protected cookie.</summary>
public const string SessionIdClaim = "zb:sessionid";
private const int Capacity = 10_000;
private readonly object _sync = new();
private readonly Dictionary<string, DateTimeOffset> _activity = new(StringComparer.Ordinal);
/// <summary>Returns the newer of the protected cookie anchor and this node's observed activity.</summary>
public DateTimeOffset? LastActivity(ClaimsPrincipal principal)
{
if (!DateTimeOffset.TryParse(principal.FindFirst(JwtTokenService.LastActivityClaimType)?.Value, out var stamp))
return null;
var id = principal.FindFirst(SessionIdClaim)?.Value;
lock (_sync)
return id is not null && _activity.TryGetValue(id, out var seen) && seen > stamp ? seen : stamp;
}
/// <summary>Records a validated interaction without changing another login's activity.</summary>
public void Record(ClaimsPrincipal principal, DateTimeOffset now)
{
var id = principal.FindFirst(SessionIdClaim)?.Value;
if (id is null || principal.Identity?.IsAuthenticated != true) return;
lock (_sync)
{
if (!_activity.ContainsKey(id) && _activity.Count >= Capacity)
_activity.Remove(_activity.MinBy(p => p.Value).Key);
if (!_activity.TryGetValue(id, out var previous) || now > previous)
_activity[id] = now;
}
}
}
@@ -11,10 +11,11 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// refresh to "rebuild claims identically to /auth/login".
/// </summary>
/// <remarks>
/// The claim shape is exactly what the login endpoint historically minted, plus the
/// two additions:
/// The login and refresh paths share the following identity and session claims:
/// <list type="bullet">
/// <item><see cref="ClaimTypes.NameIdentifier"/> — stable antiforgery identity.</item>
/// <item><see cref="ClaimTypes.Name"/> — resolves <c>Identity.Name</c>.</item>
/// <item><see cref="SessionActivityTracker.SessionIdClaim"/> — isolates activity to this login.</item>
/// <item><see cref="JwtTokenService.DisplayNameClaimType"/> — human display name.</item>
/// <item><see cref="JwtTokenService.UsernameClaimType"/> — canonical username.</item>
/// <item><see cref="JwtTokenService.RoleClaimType"/> — one per mapped role.</item>
@@ -64,13 +65,15 @@ public static class SessionClaimBuilder
var claims = new List<Claim>
{
// Antiforgery binds tokens to this stable identity, not mutable role/activity claims.
new(ClaimTypes.NameIdentifier, username),
new(ClaimTypes.Name, username),
new(SessionActivityTracker.SessionIdClaim, Guid.NewGuid().ToString("N")),
new(JwtTokenService.DisplayNameClaimType, displayName),
new(JwtTokenService.UsernameClaimType, username),
// Role-refresh anchor AND idle anchor are seeded from the same instant at
// build time. They then diverge: OnValidatePrincipal advances LastActivity
// on every request but only advances LastRoleRefresh when it actually
// re-runs the mapping.
// build time. Passive requests preserve LastActivity; accepted activity
// advances it. LastRoleRefresh advances when the mapping is re-run.
new(JwtTokenService.LastRoleRefreshClaimType, refreshStamp),
new(JwtTokenService.LastActivityClaimType, refreshStamp),
};