fix(security): finish the bearer-token design and cache role mappings (review 2026-10-21 05/U1, 05/P1)

- ManagementAuthenticator accepts Authorization: Bearer <jwt> alongside Basic on
  /management, the audit REST endpoints and the debug-stream hub. Tokens are
  validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with
  30 s skew); roles and site scope inside a valid token are authoritative for its
  lifetime, so a bearer call costs no LDAP bind and no role re-map.
- /auth/token returns expires_in + management_bearer:true, carries username, display
  name, roles and permitted sites, and answers 404 under DisableLogin.
- CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer,
  re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are
  Basic-only; bad credentials are not retried with Basic. Debug stream uses the token
  via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out.
- StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes;
  fixed the false 'fail-fast at construction' comment. Removed the dead
  RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert
  JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime
  (validated > 0).
- RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds,
  default 30 s): one refresh loads the table plus every Deployer mapping's scope
  rules, Invalidate() drops it (generation-guarded against in-flight refreshes).
- Component-Security.md / Component-CLI.md / CLI README describe the real model
  (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT.
This commit is contained in:
Joseph Doherty
2026-10-22 06:38:44 -04:00
parent f0de11c446
commit 1b3928a0a2
24 changed files with 1728 additions and 611 deletions
@@ -6,8 +6,10 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// <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.
/// <see cref="JwtExpiryMinutes"/> govern the short-lived Bearer token issued to the CLI,
/// which <c>ManagementAuthenticator</c> accepts on <c>POST /management</c>, the audit REST
/// endpoints and the debug-stream hub. They have no effect on the Blazor cookie session
/// (and none on the Inbound API, which authenticates with API keys).
/// </para>
/// <para>
/// <b>Blazor cookie session</b>: <see cref="IdleTimeoutMinutes"/> and
@@ -28,8 +30,11 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
public class SecurityOptions
{
/// <summary>
/// Symmetric HMAC-SHA256 signing key for cookie-embedded JWTs. Must be at least
/// 32 bytes (256 bits) — validated at <see cref="JwtTokenService"/> construction.
/// Symmetric HMAC-SHA256 signing key for the bearer JWTs minted by <c>/auth/token</c>.
/// Must be at least <see cref="MinJwtSigningKeyBytes"/> UTF-8 bytes (256 bits) — enforced
/// at boot on Central nodes by the Host's <c>StartupValidator</c>, and again (defence in
/// depth) at <see cref="JwtTokenService"/> construction. Both central nodes must share
/// the same key; rotating it invalidates every outstanding bearer token.
/// </summary>
public string JwtSigningKey { get; set; } = string.Empty;
@@ -37,7 +42,11 @@ public class SecurityOptions
/// Minimum signing-key length in bytes required for HMAC-SHA256 (256 bits).
/// </summary>
public const int MinJwtSigningKeyBytes = 32;
/// <summary>Cookie-embedded JWT lifetime in minutes before it must be refreshed.</summary>
/// <summary>
/// Lifetime, in minutes, of a bearer JWT issued by <c>/auth/token</c>. The roles and site
/// scope inside the token are authoritative for this long (a role revocation reaches a
/// bearer caller only when its token expires). Default: <b>15</b>. Must be positive.
/// </summary>
public int JwtExpiryMinutes { get; set; } = 15;
/// <summary>
/// Session idle timeout in minutes for the Blazor cookie session; sessions inactive
@@ -55,9 +64,14 @@ public class SecurityOptions
public int IdleTimeoutMinutes { get; set; } = 30;
/// <summary>
/// Minutes before token expiry to trigger refresh.
/// How long, in seconds, <see cref="RoleMapper"/> may serve the LDAP-group→role mapping
/// table and its site-scope rules from memory before re-reading the configuration
/// database. Default: <b>30</b>. <b>0</b> disables the cache (every mapping reads the
/// database). A mapping change is visible on every node within this window; the node
/// that served the change sees it immediately once <see cref="RoleMappingCache.Invalidate"/>
/// is called.
/// </summary>
public int JwtRefreshThresholdMinutes { get; set; } = 5;
public int RoleMappingCacheSeconds { get; set; } = 30;
/// <summary>
/// How long a cookie session's role-mapping projection may be stale
@@ -84,7 +98,7 @@ public class SecurityOptions
/// <summary>
/// When true (default) the authentication cookie is always marked
/// <c>Secure</c> (sent only over HTTPS) — the correct production setting,
/// since the cookie carries the embedded JWT bearer credential. Set false
/// since the cookie is the session credential. Set false
/// for an HTTP-only deployment such as the local Docker dev cluster: the
/// cookie then uses <c>SameAsRequest</c>, so it is still <c>Secure</c> on
/// any HTTPS request but is usable over plain HTTP.
@@ -172,6 +186,21 @@ public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IVal
$"enforcement is defeated.");
}
// A non-positive bearer lifetime mints tokens that are already expired.
if (options.JwtExpiryMinutes <= 0)
{
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
$"{nameof(SecurityOptions.JwtExpiryMinutes)} " +
$"({options.JwtExpiryMinutes}) must be positive.");
}
if (options.RoleMappingCacheSeconds < 0)
{
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
$"{nameof(SecurityOptions.RoleMappingCacheSeconds)} " +
$"({options.RoleMappingCacheSeconds}) must be non-negative (0 disables the cache).");
}
// SECURITY: login-throttle thresholds must be non-negative. 0 for
// MaxLoginFailuresPerWindow is the documented "disable throttling" escape hatch;
// a negative value (or a negative window/lockout) is a misconfiguration that would