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;
}
}