fix(security): M2.19 review nits — idle/refresh config guard + adapter tests + dead-var/doc cleanup (#15)

- Add SecurityOptionsValidator (IValidateOptions<SecurityOptions>) enforcing
  RoleRefreshThresholdMinutes < IdleTimeoutMinutes; registered with ValidateOnStart in
  AddSecurity — startup FAILS if threshold >= idle, so the invariant cannot be silently
  misconfigured away.
- Update SecurityOptions XML-docs: class-level summary distinguishes JWT Bearer path
  (JwtSigningKey/JwtExpiryMinutes) from Blazor cookie session path (IdleTimeoutMinutes/
  RoleRefreshThresholdMinutes); both time fields document the ~45-min effective idle window
  and the new cross-field constraint.
- Remove dead jwtService variable from /auth/login lambda in AuthEndpoints.cs (resolved
  but never used since login moved to SessionClaimBuilder).
- Extract ApplyValidationResultAsync helper from OnValidatePrincipalAsync (pure
  decision-application step); add 3 adapter tests covering Reject → RejectPrincipal +
  SignOutAsync; Replace → ReplacePrincipal + ShouldRenew; Keep → no-op.
- Fix inaccurate TryRefreshAsync comment (dropped "OR last-activity needs advancing" —
  the code only returns non-null when roleRefreshDue).
- Add InternalsVisibleTo for Security.Tests in Security.csproj.
- Add IsRoleRefreshDue tests: missing claim → due; unparsable claim → due; plus integration
  test covering the full ValidateAsync path for a principal missing zb:lastrolerefresh
  (triggers refresh + re-stamps anchor rather than keeping stale principal forever).
- Add SecurityOptionsValidatorConfigGuardTests: default succeeds; equal fails; greater fails;
  boundary (idle-1) succeeds; wiring confirmed via AddSecurity container.
This commit is contained in:
Joseph Doherty
2026-06-16 08:12:11 -04:00
parent c7916d79a8
commit fddc69545f
6 changed files with 437 additions and 14 deletions
@@ -171,9 +171,9 @@ public sealed class CookieSessionValidator
return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
}
// Returns a rebuilt principal when a refresh occurred (role-refresh interval elapsed,
// OR last-activity needs advancing); null when nothing changed. The principal is
// rebuilt via SessionClaimBuilder so its shape is identical to /auth/login.
// 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)
{
var roleRefreshDue = IsRoleRefreshDue(principal, now);
@@ -1,10 +1,21 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Non-LDAP security configuration: the cookie-embedded JWT signing/lifetime
/// settings and the session idle-timeout / cookie-security policy.
/// Non-LDAP security configuration for the ScadaBridge Central UI.
/// </summary>
/// <remarks>
/// <para>
/// <b>JWT Bearer path (<c>/auth/token</c>)</b>: <see cref="JwtSigningKey"/> and
/// <see cref="JwtExpiryMinutes"/> govern the short-lived Bearer token issued to
/// the CLI / Inbound API. They have no effect on the Blazor cookie session.
/// </para>
/// <para>
/// <b>Blazor cookie session</b>: <see cref="IdleTimeoutMinutes"/> and
/// <see cref="RoleRefreshThresholdMinutes"/> govern the cookie-only session used by
/// the Blazor Server UI. There is no embedded JWT in this path — the cookie is
/// HttpOnly/Secure and managed entirely by ASP.NET Core cookie authentication.
/// </para>
/// <para>
/// Task 1.2/1.4 cutover: the LDAP connection settings that used to live here as
/// flat <c>Ldap*</c> keys (server, port, transport, search base, service account,
/// attributes, timeout) moved into a nested <c>ScadaBridge:Security:Ldap</c>
@@ -12,6 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// and registered via <c>AddZbLdapAuth</c>. This is a BREAKING config-key change —
/// see CHANGELOG. The non-LDAP fields below are unchanged and still bound from
/// <c>ScadaBridge:Security</c>.
/// </para>
/// </remarks>
public class SecurityOptions
{
@@ -27,7 +39,19 @@ public class SecurityOptions
public const int MinJwtSigningKeyBytes = 32;
/// <summary>Cookie-embedded JWT lifetime in minutes before it must be refreshed.</summary>
public int JwtExpiryMinutes { get; set; } = 15;
/// <summary>Session idle timeout in minutes; sessions inactive beyond this are expired.</summary>
/// <summary>
/// Session idle timeout in minutes for the Blazor cookie session; sessions inactive
/// beyond this are expired and the user is redirected to <c>/login</c>. Default: <b>30</b>.
/// </summary>
/// <remarks>
/// Because <see cref="RoleRefreshThresholdMinutes"/> is the only operation that advances
/// the <c>LastActivity</c> anchor, the effective maximum idle window before a session is
/// guaranteed to be rejected is approximately
/// <c>IdleTimeoutMinutes + RoleRefreshThresholdMinutes</c> (~45 minutes with defaults).
/// This is intentional and mirrors the cookie middleware's own <c>SlidingExpiration</c>
/// fuzziness. Must be strictly greater than <see cref="RoleRefreshThresholdMinutes"/>
/// (enforced at startup by <see cref="SecurityOptionsValidator"/>).
/// </remarks>
public int IdleTimeoutMinutes { get; set; } = 30;
/// <summary>
@@ -38,15 +62,23 @@ public class SecurityOptions
/// <summary>
/// M2.19 (#15): how long a cookie session's role-mapping projection may be stale
/// before <c>OnValidatePrincipal</c> re-runs the DB-backed <c>RoleMapper</c> on the
/// session's stored LDAP group claims and rebuilds the role/scope claims (default
/// <b>15 minutes</b>, matching the documented sliding-refresh cadence). This is a
/// purely central (database) refresh — it picks up LDAP-group→role mapping changes
/// and scope-rule changes WITHOUT contacting LDAP, so revoked roles take effect
/// session's stored LDAP group claims and rebuilds the role/scope claims. Default:
/// <b>15 minutes</b>, matching the documented sliding-refresh cadence.
/// </summary>
/// <remarks>
/// This is a purely central (database) refresh — it picks up LDAP-group→role mapping
/// changes and scope-rule changes WITHOUT contacting LDAP, so revoked roles take effect
/// within this window. It does NOT pick up live LDAP group-membership changes (the
/// shared LDAP library exposes no passwordless group-search; that remains a
/// next-login refresh — see Component-Security.md). Kept &lt;= the cookie idle
/// window so a refresh never outlives the session it refreshes.
/// </summary>
/// next-login refresh — see Component-Security.md).
/// <para>
/// Because a role-refresh is also the only operation that advances the
/// <c>LastActivity</c> anchor, the effective maximum idle window is approximately
/// <c><see cref="IdleTimeoutMinutes"/> + RoleRefreshThresholdMinutes</c> (~45 minutes
/// with defaults). Must be strictly less than <see cref="IdleTimeoutMinutes"/>
/// (enforced at startup by <see cref="SecurityOptionsValidator"/>).
/// </para>
/// </remarks>
public int RoleRefreshThresholdMinutes { get; set; } = 15;
/// <summary>
@@ -73,3 +105,38 @@ public class SecurityOptions
/// </summary>
public string CookieName { get; set; } = DefaultCookieName;
}
/// <summary>
/// M2.19 (#15): startup validator for <see cref="SecurityOptions"/>. Fails fast at boot
/// on any configuration that would defeat idle-timeout enforcement.
/// </summary>
/// <remarks>
/// Registered with <c>ValidateOnStart()</c> by
/// <see cref="ServiceCollectionExtensions.AddSecurity"/> so a misconfigured appsettings
/// section is caught at application startup rather than silently misapplied at runtime.
/// </remarks>
public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IValidateOptions<SecurityOptions>
{
/// <inheritdoc/>
public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, SecurityOptions options)
{
// SECURITY: RoleRefreshThresholdMinutes must be strictly less than IdleTimeoutMinutes.
// The role-refresh cycle is the ONLY operation that advances the LastActivity anchor,
// so a single un-refreshed cycle must not be able to exhaust the entire idle window.
// If threshold >= idle, a user who triggers exactly one refresh at t=0 would have
// their anchor advanced to t=threshold while the idle check only fires at t>idle —
// meaning t=threshold >= t=idle is already past (or at) the expiry, defeating enforcement.
if (options.RoleRefreshThresholdMinutes >= options.IdleTimeoutMinutes)
{
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
$"{nameof(SecurityOptions.RoleRefreshThresholdMinutes)} " +
$"({options.RoleRefreshThresholdMinutes}) must be strictly less than " +
$"{nameof(SecurityOptions.IdleTimeoutMinutes)} " +
$"({options.IdleTimeoutMinutes}). " +
$"A single refresh cycle must not equal or exceed the idle window or idle " +
$"enforcement is defeated.");
}
return Microsoft.Extensions.Options.ValidateOptionsResult.Success;
}
}
@@ -82,6 +82,16 @@ public static class ServiceCollectionExtensions
// to consume this seam in a later task.
services.AddScoped<IGroupRoleMapper<string>, ScadaBridgeGroupRoleMapper>();
// M2.19 (#15): fail-fast config guard — RoleRefreshThresholdMinutes must be strictly
// less than IdleTimeoutMinutes. If they are equal or inverted, a single un-refreshed
// cycle can exhaust the entire idle window and idle enforcement is silently defeated.
// SecurityOptionsValidator is registered with ValidateOnStart so a misconfigured
// appsettings section fails at boot with a clear message rather than behaving subtly
// incorrectly at runtime. Config-binding stays with the Host (component library must
// not take IConfiguration), so we only register the validator + ValidateOnStart here.
services.AddOptions<SecurityOptions>().ValidateOnStart();
services.AddSingleton<IValidateOptions<SecurityOptions>, SecurityOptionsValidator>();
// Note: the old SecurityOptionsValidator (which fail-fast-validated LdapServer +
// LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above)
@@ -195,6 +205,23 @@ public static class ServiceCollectionExtensions
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted)
.ConfigureAwait(false);
await ApplyValidationResultAsync(context, result).ConfigureAwait(false);
}
/// <summary>
/// Applies a <see cref="SessionValidationResult"/> to a
/// <see cref="CookieValidatePrincipalContext"/>: the pure decision-application
/// step extracted from <see cref="OnValidatePrincipalAsync"/> so it can be
/// exercised in unit tests without a live DI container resolving
/// <see cref="CookieSessionValidator"/>.
/// </summary>
/// <param name="context">The cookie validation context to mutate.</param>
/// <param name="result">The decision produced by <see cref="CookieSessionValidator.ValidateAsync"/>.</param>
/// <returns>A task that completes when the result has been applied.</returns>
internal static async Task ApplyValidationResultAsync(
CookieValidatePrincipalContext context,
SessionValidationResult result)
{
switch (result.Action)
{
case SessionValidationAction.Reject:
@@ -35,4 +35,10 @@
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
</ItemGroup>
<ItemGroup>
<!-- M2.19 (#15): expose internal members (OnValidatePrincipalAsync adapter) to the
Security test project so the adapter translation can be exercised in isolation. -->
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.Security.Tests" />
</ItemGroup>
</Project>