Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CLI/CliTokenStore.cs
T
Joseph Doherty 1b3928a0a2 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.
2026-10-22 08:25:35 -04:00

63 lines
3.0 KiB
C#

using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.CLI;
/// <summary>
/// In-memory, process-lifetime store of the bearer tokens the CLI obtains from
/// <c>POST /auth/token</c> (review 2026-10-21 05/U1, 05/P1). One entry per
/// <c>(management URL, username, password)</c> so every <see cref="ManagementHttpClient"/> a
/// single CLI invocation creates shares one credential exchange — one LDAP bind per invocation
/// instead of one per management call.
/// </summary>
/// <remarks>
/// Tokens are never written to disk, logged or echoed; the store dies with the process. The key
/// includes a SHA-256 digest of the password rather than the password itself, so a different
/// password for the same user never reuses a token minted for the old one.
/// </remarks>
internal sealed class CliTokenStore
{
/// <summary>The process-wide store used by the public <see cref="ManagementHttpClient"/> constructor.</summary>
public static CliTokenStore Shared { get; } = new();
private readonly ConcurrentDictionary<string, Session> _sessions = new(StringComparer.Ordinal);
/// <summary>Returns the (shared) session for a management URL and credential pair.</summary>
/// <param name="baseUrl">The management base URL.</param>
/// <param name="username">The CLI username.</param>
/// <param name="password">The CLI password.</param>
/// <returns>The session carrying the cached token, if any.</returns>
public Session For(string baseUrl, string username, string password)
{
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
var key = $"{baseUrl.TrimEnd('/').ToLowerInvariant()}\n{username}\n{digest}";
return _sessions.GetOrAdd(key, _ => new Session());
}
/// <summary>Token state for one credential pair against one server.</summary>
internal sealed class Session
{
/// <summary>Serialises token exchanges so concurrent calls share one bind.</summary>
public SemaphoreSlim Gate { get; } = new(1, 1);
/// <summary>The current bearer token, or null when none is held.</summary>
public string? Token { get; set; }
/// <summary>When <see cref="Token"/> should be treated as stale (expiry minus a safety margin).</summary>
public DateTimeOffset RefreshAfter { get; set; }
/// <summary>
/// True once the server has shown it does not take bearer tokens on the management
/// surfaces (no <c>/auth/token</c>, an older server whose tokens nothing accepts, or a
/// login-disabled deployment): every later call goes straight to HTTP Basic.
/// </summary>
public bool BasicOnly { get; set; }
/// <summary>True while a non-stale token is held.</summary>
/// <param name="now">The current instant.</param>
/// <returns>Whether <see cref="Token"/> can be sent.</returns>
public bool HasUsableToken(DateTimeOffset now) => Token is not null && now < RefreshAfter;
}
}