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
@@ -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: