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>