namespace ZB.MOM.WW.ScadaBridge.Security; /// /// 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 /// sub-section bound to the shared ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions /// 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 { /// /// Symmetric HMAC-SHA256 signing key for cookie-embedded JWTs. Must be at least /// 32 bytes (256 bits) — validated at construction. /// public string JwtSigningKey { get; set; } = string.Empty; /// /// Minimum signing-key length in bytes required for HMAC-SHA256 (256 bits). /// 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 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; /// /// Minutes before token expiry to trigger refresh. /// public int JwtRefreshThresholdMinutes { get; set; } = 5; /// /// 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 /// 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). /// /// 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; /// /// When true (default) the authentication cookie is always marked /// Secure (sent only over HTTPS) — the correct production setting, /// since the cookie carries the embedded JWT bearer credential. Set false /// for an HTTP-only deployment such as the local Docker dev cluster: the /// cookie then uses SameAsRequest, so it is still Secure on /// any HTTPS request but is usable over plain HTTP. /// public bool RequireHttpsCookie { get; set; } = true; /// The canonical default authentication-cookie name (). public const string DefaultCookieName = "ZB.MOM.WW.ScadaBridge.Auth"; /// /// Authentication cookie name. Defaults to . Override it /// (ScadaBridge:Security:CookieName) to give a distinct name to a deployment that /// shares a hostname with another ScadaBridge environment — browser cookies are scoped by /// host+path but NOT by port, so two clusters on the same host (e.g. two local Docker /// stacks on localhost:9000 and localhost:9100) would otherwise clobber each /// other's session under a shared cookie name. A blank/whitespace value falls back to /// . Changing this invalidates existing sessions on next deploy. /// 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; } }