diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs index a66d8f45..81017590 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs @@ -34,7 +34,6 @@ public static class AuthEndpoints } var ldapAuth = context.RequestServices.GetRequiredService(); - var jwtService = context.RequestServices.GetRequiredService(); var roleMapper = context.RequestServices.GetRequiredService>(); var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted); diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs index e6a57e10..491ebcbe 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs @@ -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 TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct) { var roleRefreshDue = IsRoleRefreshDue(principal, now); diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs index 6f693a10..77e17138 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs @@ -1,10 +1,21 @@ namespace ZB.MOM.WW.ScadaBridge.Security; /// -/// 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. /// /// +/// +/// JWT Bearer path (/auth/token): and +/// govern the short-lived Bearer token issued to +/// the CLI / Inbound API. They have no effect on the Blazor cookie session. +/// +/// +/// Blazor cookie session: and +/// 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. +/// +/// /// Task 1.2/1.4 cutover: the LDAP connection settings that used to live here as /// flat Ldap* keys (server, port, transport, search base, service account, /// attributes, timeout) moved into a nested ScadaBridge:Security:Ldap @@ -12,6 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Security; /// and registered via AddZbLdapAuth. This is a BREAKING config-key change — /// see CHANGELOG. The non-LDAP fields below are unchanged and still bound from /// ScadaBridge:Security. +/// /// public class SecurityOptions { @@ -27,7 +39,19 @@ public class SecurityOptions public const int MinJwtSigningKeyBytes = 32; /// Cookie-embedded JWT lifetime in minutes before it must be refreshed. public int JwtExpiryMinutes { get; set; } = 15; - /// Session idle timeout in minutes; sessions inactive beyond this are expired. + /// + /// Session idle timeout in minutes for the Blazor cookie session; sessions inactive + /// beyond this are expired and the user is redirected to /login. Default: 30. + /// + /// + /// Because is the only operation that advances + /// the LastActivity anchor, the effective maximum idle window before a session is + /// guaranteed to be rejected is approximately + /// IdleTimeoutMinutes + RoleRefreshThresholdMinutes (~45 minutes with defaults). + /// This is intentional and mirrors the cookie middleware's own SlidingExpiration + /// fuzziness. Must be strictly greater than + /// (enforced at startup by ). + /// public int IdleTimeoutMinutes { get; set; } = 30; /// @@ -38,15 +62,23 @@ public class SecurityOptions /// /// M2.19 (#15): how long a cookie session's role-mapping projection may be stale /// before OnValidatePrincipal re-runs the DB-backed RoleMapper on the - /// session's stored LDAP group claims and rebuilds the role/scope claims (default - /// 15 minutes, 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: + /// 15 minutes, 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 /// 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 <= the cookie idle - /// window so a refresh never outlives the session it refreshes. - /// + /// next-login refresh — see Component-Security.md). + /// + /// Because a role-refresh is also the only operation that advances the + /// LastActivity anchor, the effective maximum idle window is approximately + /// + RoleRefreshThresholdMinutes (~45 minutes + /// with defaults). Must be strictly less than + /// (enforced at startup by ). + /// + /// public int RoleRefreshThresholdMinutes { get; set; } = 15; /// @@ -73,3 +105,38 @@ public class SecurityOptions /// public string CookieName { get; set; } = DefaultCookieName; } + +/// +/// M2.19 (#15): startup validator for . Fails fast at boot +/// on any configuration that would defeat idle-timeout enforcement. +/// +/// +/// Registered with ValidateOnStart() by +/// so a misconfigured appsettings +/// section is caught at application startup rather than silently misapplied at runtime. +/// +public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IValidateOptions +{ + /// + 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; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs index cef6f32f..9230efe9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs @@ -82,6 +82,16 @@ public static class ServiceCollectionExtensions // to consume this seam in a later task. services.AddScoped, 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().ValidateOnStart(); + services.AddSingleton, 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); + } + + /// + /// Applies a to a + /// : the pure decision-application + /// step extracted from so it can be + /// exercised in unit tests without a live DI container resolving + /// . + /// + /// The cookie validation context to mutate. + /// The decision produced by . + /// A task that completes when the result has been applied. + internal static async Task ApplyValidationResultAsync( + CookieValidatePrincipalContext context, + SessionValidationResult result) + { switch (result.Action) { case SessionValidationAction.Reject: diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj b/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj index c6e75280..c4a2d59c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj @@ -35,4 +35,10 @@ + + + + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs index d7e35a30..783f6d35 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs @@ -1,5 +1,8 @@ using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Roles; @@ -269,6 +272,81 @@ public class CookieSessionValidatorTests Assert.False(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15))); 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)); + } } /// @@ -360,3 +438,249 @@ public class SessionClaimBuilderParityTests } #endregion + +#region M2.19 (#15): OnValidatePrincipalAsync adapter — translation from SessionValidationResult to cookie context calls + +/// +/// Tests for the ApplyValidationResultAsync helper extracted from +/// : verifies that each +/// case is correctly translated into the +/// corresponding mutations. +/// The pure decision-application step is tested in isolation — the DI-resolution of +/// is a separate concern covered by the integration +/// wiring tests in . +/// +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 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(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 + +/// +/// Tests for : the startup validator that prevents +/// idle-timeout enforcement from being silently defeated by a misconfigured +/// RoleRefreshThresholdMinutes >= IdleTimeoutMinutes. +/// +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. 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>().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>() + .OfType() + .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