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:
@@ -34,7 +34,6 @@ public static class AuthEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
|
||||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||||
|
|
||||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||||
|
|||||||
@@ -171,9 +171,9 @@ public sealed class CookieSessionValidator
|
|||||||
return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns a rebuilt principal when a refresh occurred (role-refresh interval elapsed,
|
// Returns a rebuilt principal when the role-refresh interval has elapsed; null when
|
||||||
// OR last-activity needs advancing); null when nothing changed. The principal is
|
// nothing changed. The principal is rebuilt via SessionClaimBuilder so its shape is
|
||||||
// rebuilt via SessionClaimBuilder so its shape is identical to /auth/login.
|
// 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)
|
||||||
{
|
{
|
||||||
var roleRefreshDue = IsRoleRefreshDue(principal, now);
|
var roleRefreshDue = IsRoleRefreshDue(principal, now);
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Non-LDAP security configuration: the cookie-embedded JWT signing/lifetime
|
/// Non-LDAP security configuration for the ScadaBridge Central UI.
|
||||||
/// settings and the session idle-timeout / cookie-security policy.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <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
|
/// 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,
|
/// flat <c>Ldap*</c> keys (server, port, transport, search base, service account,
|
||||||
/// attributes, timeout) moved into a nested <c>ScadaBridge:Security:Ldap</c>
|
/// 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 —
|
/// 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
|
/// see CHANGELOG. The non-LDAP fields below are unchanged and still bound from
|
||||||
/// <c>ScadaBridge:Security</c>.
|
/// <c>ScadaBridge:Security</c>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public class SecurityOptions
|
public class SecurityOptions
|
||||||
{
|
{
|
||||||
@@ -27,7 +39,19 @@ public class SecurityOptions
|
|||||||
public const int MinJwtSigningKeyBytes = 32;
|
public const int MinJwtSigningKeyBytes = 32;
|
||||||
/// <summary>Cookie-embedded JWT lifetime in minutes before it must be refreshed.</summary>
|
/// <summary>Cookie-embedded JWT lifetime in minutes before it must be refreshed.</summary>
|
||||||
public int JwtExpiryMinutes { get; set; } = 15;
|
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;
|
public int IdleTimeoutMinutes { get; set; } = 30;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -38,15 +62,23 @@ public class SecurityOptions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// M2.19 (#15): how long a cookie session's role-mapping projection may be stale
|
/// 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
|
/// 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
|
/// 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
|
/// <b>15 minutes</b>, matching the documented sliding-refresh cadence.
|
||||||
/// purely central (database) refresh — it picks up LDAP-group→role mapping changes
|
/// </summary>
|
||||||
/// and scope-rule changes WITHOUT contacting LDAP, so revoked roles take effect
|
/// <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
|
/// 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
|
/// shared LDAP library exposes no passwordless group-search; that remains a
|
||||||
/// next-login refresh — see Component-Security.md). Kept <= the cookie idle
|
/// next-login refresh — see Component-Security.md).
|
||||||
/// window so a refresh never outlives the session it refreshes.
|
/// <para>
|
||||||
/// </summary>
|
/// 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;
|
public int RoleRefreshThresholdMinutes { get; set; } = 15;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -73,3 +105,38 @@ public class SecurityOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string CookieName { get; set; } = DefaultCookieName;
|
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.
|
// to consume this seam in a later task.
|
||||||
services.AddScoped<IGroupRoleMapper<string>, ScadaBridgeGroupRoleMapper>();
|
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 +
|
// Note: the old SecurityOptionsValidator (which fail-fast-validated LdapServer +
|
||||||
// LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose
|
// LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose
|
||||||
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above)
|
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above)
|
||||||
@@ -195,6 +205,23 @@ public static class ServiceCollectionExtensions
|
|||||||
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted)
|
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted)
|
||||||
.ConfigureAwait(false);
|
.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)
|
switch (result.Action)
|
||||||
{
|
{
|
||||||
case SessionValidationAction.Reject:
|
case SessionValidationAction.Reject:
|
||||||
|
|||||||
@@ -35,4 +35,10 @@
|
|||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
@@ -269,6 +272,81 @@ public class CookieSessionValidatorTests
|
|||||||
Assert.False(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15)));
|
Assert.False(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15)));
|
||||||
Assert.True(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15).AddSeconds(1)));
|
Assert.True(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15).AddSeconds(1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Missing/unparsable zb:lastrolerefresh anchor treated as refresh-due ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAsync_MissingLastRoleRefreshClaim_TreatedAsRefreshDue_RefreshesAndRestamps()
|
||||||
|
{
|
||||||
|
// Mirrors the existing MissingLastActivityClaim_TreatedAsIdleTimedOut test but for
|
||||||
|
// the role-refresh anchor. A principal whose zb:lastrolerefresh claim is absent or
|
||||||
|
// unparsable must be treated as "refresh due" (refresh now + re-stamp the anchor),
|
||||||
|
// NOT as "never refresh". This prevents a stale principal from coasting forever
|
||||||
|
// without a role re-check just because the claim was missing.
|
||||||
|
var clock = new TestTimeProvider(Start);
|
||||||
|
// The mapper returns a different mapping than the one encoded in the session, so we
|
||||||
|
// can confirm the refresh actually ran.
|
||||||
|
var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Viewer], [], false));
|
||||||
|
var sut = CreateValidator(mapper, clock);
|
||||||
|
|
||||||
|
// Build a principal that has a valid LastActivity (not timed out) but NO lastrolerefresh.
|
||||||
|
var identity = new ClaimsIdentity([
|
||||||
|
new Claim(ClaimTypes.Name, "alice"),
|
||||||
|
new Claim(JwtTokenService.UsernameClaimType, "alice"),
|
||||||
|
new Claim(JwtTokenService.DisplayNameClaimType, "Alice"),
|
||||||
|
new Claim(JwtTokenService.RoleClaimType, Roles.Administrator),
|
||||||
|
new Claim(JwtTokenService.LastActivityClaimType, Start.ToString("o")),
|
||||||
|
// Deliberately NO zb:lastrolerefresh claim.
|
||||||
|
], CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
nameType: ClaimTypes.Name,
|
||||||
|
roleType: JwtTokenService.RoleClaimType);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
var result = await sut.ValidateAsync(principal);
|
||||||
|
|
||||||
|
// Must have triggered a refresh (Replace), NOT Keep.
|
||||||
|
Assert.Equal(SessionValidationAction.Replace, result.Action);
|
||||||
|
Assert.Equal(1, mapper.CallCount);
|
||||||
|
|
||||||
|
// The rebuilt principal carries the new mapping (Viewer, not Administrator).
|
||||||
|
var newRoles = result.Principal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
||||||
|
Assert.Contains(Roles.Viewer, newRoles);
|
||||||
|
Assert.DoesNotContain(Roles.Administrator, newRoles);
|
||||||
|
|
||||||
|
// A new lastrolerefresh anchor was stamped.
|
||||||
|
var refreshClaim = result.Principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType);
|
||||||
|
Assert.NotNull(refreshClaim);
|
||||||
|
Assert.True(DateTimeOffset.TryParse(refreshClaim!.Value, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsRoleRefreshDue_MissingClaim_ReturnsDue()
|
||||||
|
{
|
||||||
|
// Direct helper test: a principal with no zb:lastrolerefresh claim returns true.
|
||||||
|
var clock = new TestTimeProvider(Start);
|
||||||
|
var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock);
|
||||||
|
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "alice")],
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
Assert.True(sut.IsRoleRefreshDue(principal, Start));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsRoleRefreshDue_UnparsableClaim_ReturnsDue()
|
||||||
|
{
|
||||||
|
// A present but unparsable zb:lastrolerefresh claim is treated as due, not as a
|
||||||
|
// parse error that keeps the session stale forever.
|
||||||
|
var clock = new TestTimeProvider(Start);
|
||||||
|
var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock);
|
||||||
|
var identity = new ClaimsIdentity([
|
||||||
|
new Claim(ClaimTypes.Name, "alice"),
|
||||||
|
new Claim(JwtTokenService.LastRoleRefreshClaimType, "not-a-date"),
|
||||||
|
], CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
Assert.True(sut.IsRoleRefreshDue(principal, Start));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -360,3 +438,249 @@ public class SessionClaimBuilderParityTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region M2.19 (#15): OnValidatePrincipalAsync adapter — translation from SessionValidationResult to cookie context calls
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <c>ApplyValidationResultAsync</c> helper extracted from
|
||||||
|
/// <see cref="ServiceCollectionExtensions.OnValidatePrincipalAsync"/>: verifies that each
|
||||||
|
/// <see cref="SessionValidationResult"/> case is correctly translated into the
|
||||||
|
/// corresponding <see cref="CookieValidatePrincipalContext"/> mutations.
|
||||||
|
/// The pure decision-application step is tested in isolation — the DI-resolution of
|
||||||
|
/// <see cref="CookieSessionValidator"/> is a separate concern covered by the integration
|
||||||
|
/// wiring tests in <see cref="SecurityReviewRegressionTests"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class OnValidatePrincipalAdapterTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset AdapterStart = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
// A minimal IAuthenticationService stub that records SignOut calls so we can assert
|
||||||
|
// the Reject path actually invokes SignOutAsync.
|
||||||
|
private sealed class StubAuthenticationService : IAuthenticationService
|
||||||
|
{
|
||||||
|
public int SignOutCallCount { get; private set; }
|
||||||
|
|
||||||
|
public Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme) =>
|
||||||
|
Task.FromResult(AuthenticateResult.NoResult());
|
||||||
|
|
||||||
|
public Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
|
||||||
|
{
|
||||||
|
SignOutCallCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (CookieValidatePrincipalContext Context, StubAuthenticationService AuthService)
|
||||||
|
BuildContext(ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
var authService = new StubAuthenticationService();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton<IAuthenticationService>(authService);
|
||||||
|
services.AddLogging();
|
||||||
|
var serviceProvider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
var httpContext = new DefaultHttpContext { RequestServices = serviceProvider };
|
||||||
|
var ticket = new AuthenticationTicket(
|
||||||
|
principal,
|
||||||
|
new AuthenticationProperties(),
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var scheme = new AuthenticationScheme(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
displayName: null,
|
||||||
|
handlerType: typeof(CookieAuthenticationHandler));
|
||||||
|
|
||||||
|
var context = new CookieValidatePrincipalContext(
|
||||||
|
httpContext,
|
||||||
|
scheme,
|
||||||
|
new CookieAuthenticationOptions(),
|
||||||
|
ticket);
|
||||||
|
|
||||||
|
return (context, authService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal AuthenticatedPrincipal() =>
|
||||||
|
SessionClaimBuilder.Build(
|
||||||
|
"alice", "Alice",
|
||||||
|
["SCADA-Admins"],
|
||||||
|
new RoleMappingResult([Roles.Administrator], [], true),
|
||||||
|
AdapterStart);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyResult_Reject_CallsRejectPrincipalAndSignsOut()
|
||||||
|
{
|
||||||
|
// The Reject result is the idle-timeout path: the adapter must call RejectPrincipal
|
||||||
|
// (sets Principal = null) AND SignOutAsync so the cookie is cleared for the next request.
|
||||||
|
var principal = AuthenticatedPrincipal();
|
||||||
|
var (ctx, authService) = BuildContext(principal);
|
||||||
|
|
||||||
|
await ServiceCollectionExtensions.ApplyValidationResultAsync(ctx, SessionValidationResult.Reject);
|
||||||
|
|
||||||
|
// RejectPrincipal() sets Principal to null.
|
||||||
|
Assert.Null(ctx.Principal);
|
||||||
|
// SignOutAsync must have been invoked exactly once.
|
||||||
|
Assert.Equal(1, authService.SignOutCallCount);
|
||||||
|
// ShouldRenew must NOT be set — we are expiring, not renewing.
|
||||||
|
Assert.False(ctx.ShouldRenew);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyResult_Replace_ReplacesAndSetsRenew()
|
||||||
|
{
|
||||||
|
// The Replace result is the role-refresh path: the adapter must swap in the new
|
||||||
|
// principal and set ShouldRenew = true so the cookie is re-issued with the new claims.
|
||||||
|
var newPrincipal = SessionClaimBuilder.Build(
|
||||||
|
"alice", "Alice",
|
||||||
|
["SCADA-Admins"],
|
||||||
|
new RoleMappingResult([Roles.Viewer], [], false),
|
||||||
|
AdapterStart.AddMinutes(16));
|
||||||
|
var originalPrincipal = AuthenticatedPrincipal();
|
||||||
|
var (ctx, authService) = BuildContext(originalPrincipal);
|
||||||
|
|
||||||
|
await ServiceCollectionExtensions.ApplyValidationResultAsync(ctx, SessionValidationResult.Replace(newPrincipal));
|
||||||
|
|
||||||
|
Assert.Same(newPrincipal, ctx.Principal);
|
||||||
|
Assert.True(ctx.ShouldRenew);
|
||||||
|
Assert.Equal(0, authService.SignOutCallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyResult_Keep_LeavesContextUnchanged()
|
||||||
|
{
|
||||||
|
// The Keep result is the no-op path (no refresh due, or a swallowed refresh fault):
|
||||||
|
// the adapter must leave the principal and ShouldRenew completely untouched.
|
||||||
|
var principal = AuthenticatedPrincipal();
|
||||||
|
var (ctx, authService) = BuildContext(principal);
|
||||||
|
|
||||||
|
await ServiceCollectionExtensions.ApplyValidationResultAsync(ctx, SessionValidationResult.Keep);
|
||||||
|
|
||||||
|
Assert.Same(principal, ctx.Principal);
|
||||||
|
Assert.False(ctx.ShouldRenew);
|
||||||
|
Assert.Equal(0, authService.SignOutCallCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region M2.19 (#15): SecurityOptionsValidator — config-guard fails startup on misconfiguration
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="SecurityOptionsValidator"/>: the startup validator that prevents
|
||||||
|
/// idle-timeout enforcement from being silently defeated by a misconfigured
|
||||||
|
/// <c>RoleRefreshThresholdMinutes >= IdleTimeoutMinutes</c>.
|
||||||
|
/// </summary>
|
||||||
|
public class SecurityOptionsValidatorConfigGuardTests
|
||||||
|
{
|
||||||
|
private static SecurityOptions ValidOptions() => new()
|
||||||
|
{
|
||||||
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
||||||
|
IdleTimeoutMinutes = 30,
|
||||||
|
RoleRefreshThresholdMinutes = 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_DefaultOptions_Succeeds()
|
||||||
|
{
|
||||||
|
var result = new SecurityOptionsValidator().Validate(name: null, ValidOptions());
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RefreshEqualsIdle_Fails()
|
||||||
|
{
|
||||||
|
// Equal is invalid: a refresh cycle at t=30 would advance LastActivity to t=30 which
|
||||||
|
// equals the idle timeout — enforcement is defeated.
|
||||||
|
var options = ValidOptions();
|
||||||
|
options.RoleRefreshThresholdMinutes = options.IdleTimeoutMinutes; // 30 == 30
|
||||||
|
|
||||||
|
var result = new SecurityOptionsValidator().Validate(name: null, options);
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage);
|
||||||
|
Assert.Contains(nameof(SecurityOptions.IdleTimeoutMinutes), result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RefreshGreaterThanIdle_Fails()
|
||||||
|
{
|
||||||
|
// Inverted: refresh threshold LARGER than the idle window is clearly wrong and
|
||||||
|
// must fail loudly at startup.
|
||||||
|
var options = ValidOptions();
|
||||||
|
options.RoleRefreshThresholdMinutes = 60; // 60 > 30
|
||||||
|
|
||||||
|
var result = new SecurityOptionsValidator().Validate(name: null, options);
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RefreshOneLessThanIdle_Succeeds()
|
||||||
|
{
|
||||||
|
// Boundary: threshold = idle - 1 is the tightest VALID configuration.
|
||||||
|
var options = ValidOptions();
|
||||||
|
options.IdleTimeoutMinutes = 30;
|
||||||
|
options.RoleRefreshThresholdMinutes = 29;
|
||||||
|
|
||||||
|
var result = new SecurityOptionsValidator().Validate(name: null, options);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddSecurity_RegistersSecurityOptionsValidator_WithValidateOnStart()
|
||||||
|
{
|
||||||
|
// End-to-end wiring: AddSecurity registers SecurityOptionsValidator as
|
||||||
|
// IValidateOptions<SecurityOptions>. ValidateOnStart is what makes the DI container
|
||||||
|
// call Validate on startup. We confirm the validator is present in the container.
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddDataProtection();
|
||||||
|
services.AddSecurity();
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
var validators = provider.GetServices<IValidateOptions<SecurityOptions>>().ToList();
|
||||||
|
Assert.Contains(validators, v => v is SecurityOptionsValidator);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddSecurity_RegisteredValidator_DetectsMisconfiguration_WhenCalledDirectly()
|
||||||
|
{
|
||||||
|
// Confirm that the SecurityOptionsValidator registered by AddSecurity actually
|
||||||
|
// catches a bad configuration. We resolve the validator from the container and
|
||||||
|
// call it directly — the ValidateOnStart pipeline would fire the same validator
|
||||||
|
// during IHost.StartAsync in a real deployment.
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddDataProtection();
|
||||||
|
services.AddSecurity();
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
var validator = provider.GetServices<IValidateOptions<SecurityOptions>>()
|
||||||
|
.OfType<SecurityOptionsValidator>()
|
||||||
|
.Single();
|
||||||
|
|
||||||
|
// A configuration where RoleRefreshThreshold == IdleTimeout defeats idle enforcement.
|
||||||
|
var badOptions = new SecurityOptions
|
||||||
|
{
|
||||||
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
||||||
|
IdleTimeoutMinutes = 30,
|
||||||
|
RoleRefreshThresholdMinutes = 30, // == IdleTimeoutMinutes — invalid
|
||||||
|
};
|
||||||
|
var result = validator.Validate(name: null, badOptions);
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|||||||
Reference in New Issue
Block a user