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
@@ -0,0 +1,62 @@
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;
}
}
@@ -1,7 +1,6 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
@@ -90,12 +89,44 @@ public static class DebugCommands
private static async Task<int> StreamDebugAsync(string baseUrl, string username, string password, int instanceId, string format)
{
var hubUrl = baseUrl.TrimEnd('/') + "/hubs/debug-stream";
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
// Exchange the credentials for a bearer token once (review 2026-10-21 05/U1, 05/P1) so
// the hub handshake — and every automatic reconnect — costs no LDAP bind. The hub
// authenticates through ManagementAuthenticator, which accepts the token in the
// Authorization header; the SignalR .NET client sends the AccessTokenProvider value
// there on negotiate and on the WebSocket upgrade, and re-invokes the provider on each
// reconnect, so a token that expired mid-stream is re-exchanged. A server without
// bearer support (or SCADABRIDGE_AUTH_MODE=basic) keeps the Basic header.
using var authClient = new ManagementHttpClient(baseUrl, username, password);
ManagementHttpClient.BearerResult bearer;
try
{
bearer = await authClient.TryGetBearerTokenAsync(CancellationToken.None);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
return 1;
}
if (bearer.Failure is not null)
{
OutputFormatter.WriteError(bearer.Failure.Error ?? "Authentication failed.", bearer.Failure.ErrorCode ?? "AUTH_FAILED");
return 1;
}
var connection = new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.Headers.Add("Authorization", $"Basic {credentials}");
if (bearer.Token is not null)
{
options.AccessTokenProvider = async () =>
(await authClient.TryGetBearerTokenAsync(CancellationToken.None)).Token;
}
else
{
options.Headers.Add("Authorization", $"Basic {authClient.BasicCredentials}");
}
})
.WithAutomaticReconnect()
.Build();
@@ -1,12 +1,52 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI;
/// <summary>
/// HTTP client for the central management surfaces (<c>POST /management</c>, the audit REST
/// endpoints). Authenticates with a bearer token exchanged once per process at
/// <c>POST /auth/token</c>, falling back to HTTP Basic when the server does not offer bearer
/// tokens (review 2026-10-21 05/U1, 05/P1).
/// </summary>
/// <remarks>
/// <para>
/// <b>Bearer flow.</b> The first call a process makes for a given URL + credential pair posts
/// the credentials to <c>/auth/token</c> (one LDAP bind) and keeps the returned JWT in memory
/// (<see cref="CliTokenStore"/>; never persisted or logged). Every later call — from this or any
/// other client instance in the same process — sends <c>Authorization: Bearer</c>, which the
/// server validates without LDAP. A token nearing expiry is re-exchanged. A bearer call
/// answered <c>401</c> re-exchanges once (unless the token was minted for that very call) and,
/// if still refused, retries with Basic.
/// </para>
/// <para>
/// <b>Basic fallback.</b> A server whose <c>/auth/token</c> is absent (404/405), disabled
/// (login-disabled deployment, 404) or does not advertise <c>management_bearer: true</c> (an
/// older server, whose tokens no management surface accepts) is remembered as Basic-only for
/// the rest of the process. A transient exchange failure (5xx) uses Basic for that call only.
/// Bad credentials (<c>401</c>) and a throttle lockout (<c>429</c>) from the exchange are
/// returned as-is — retrying them with Basic would only add a second failed bind against the
/// login throttle.
/// </para>
/// <para>
/// <c>SCADABRIDGE_AUTH_MODE=basic</c> disables the exchange (Basic on every call).
/// </para>
/// </remarks>
public class ManagementHttpClient : IDisposable
{
/// <summary>Environment variable selecting the auth mode: <c>token</c> (default) or <c>basic</c>.</summary>
public const string AuthModeEnvVar = "SCADABRIDGE_AUTH_MODE";
/// <summary>Re-exchange a token this long before its stated expiry.</summary>
internal static readonly TimeSpan TokenRefreshMargin = TimeSpan.FromSeconds(60);
private readonly HttpClient _httpClient;
private readonly AuthenticationHeaderValue _basic;
private readonly string _username;
private readonly string _password;
private readonly CliTokenStore.Session? _session;
/// <summary>
/// Default bound on the CONNECT phase only (30 s) — how long a TCP/TLS
@@ -81,10 +121,19 @@ public class ManagementHttpClient : IDisposable
{
Timeout = Timeout.InfiniteTimeSpan
},
baseUrl, username, password)
baseUrl, username, password,
IsBasicAuthModeForced() ? null : CliTokenStore.Shared)
{
}
/// <summary>
/// True when <see cref="AuthModeEnvVar"/> is <c>basic</c>, which disables the token
/// exchange for the process.
/// </summary>
/// <returns>Whether HTTP Basic is forced on every call.</returns>
internal static bool IsBasicAuthModeForced() =>
string.Equals(Environment.GetEnvironmentVariable(AuthModeEnvVar)?.Trim(), "basic", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Test-only constructor that accepts a pre-built <see cref="HttpClient"/> (typically
/// over a stub <see cref="HttpMessageHandler"/>) so the request/response handling can
@@ -94,16 +143,217 @@ public class ManagementHttpClient : IDisposable
/// <param name="baseUrl">The base URL for the management API.</param>
/// <param name="username">The username for HTTP Basic authentication.</param>
/// <param name="password">The password for HTTP Basic authentication.</param>
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
/// <param name="tokenStore">
/// Bearer-token store to exchange credentials through; <see langword="null"/> (the default)
/// sends HTTP Basic on every call, which keeps stub-handler tests to a single request.
/// </param>
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password, CliTokenStore? tokenStore = null)
{
_httpClient = httpClient;
// Test seam: exposes the constructed HttpClient's effective Timeout
// without requiring reflection.
EffectiveTimeout = httpClient.Timeout;
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
_username = username;
_password = password;
_basic = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
_session = tokenStore?.For(baseUrl, username, password);
}
/// <summary>The HTTP Basic credential value (base64 <c>user:password</c>), for the debug-stream hub fallback.</summary>
internal string BasicCredentials => _basic.Parameter!;
/// <summary>Outcome of <see cref="TryGetBearerTokenAsync"/>.</summary>
/// <param name="Token">The bearer token, or <see langword="null"/> when the caller should use Basic.</param>
/// <param name="Failure">A credential/throttle failure from the exchange that must be reported, not retried with Basic.</param>
internal readonly record struct BearerResult(string? Token, ManagementResponse? Failure);
/// <summary>
/// Returns a usable bearer token for this client's credentials, exchanging at
/// <c>/auth/token</c> if none is cached — used by the debug-stream hub, which cannot replay a
/// rejected handshake. <see cref="BearerResult.Token"/> is null when the caller should fall
/// back to Basic; <see cref="BearerResult.Failure"/> is set for bad credentials or a lockout.
/// </summary>
/// <param name="cancellationToken">Cancels the exchange.</param>
/// <returns>The token, a fall-back-to-Basic signal, or a failure to report.</returns>
internal async Task<BearerResult> TryGetBearerTokenAsync(CancellationToken cancellationToken)
{
var auth = await AcquireAuthAsync(forceExchange: false, cancellationToken).ConfigureAwait(false);
if (auth.Failure is not null)
{
var failure = await ToManagementResponseAsync(auth.Failure, cancellationToken).ConfigureAwait(false);
auth.Failure.Dispose();
return new BearerResult(null, failure);
}
return new BearerResult(auth.Header.Scheme == "Bearer" ? auth.Header.Parameter : null, null);
}
private readonly record struct AuthChoice(AuthenticationHeaderValue Header, bool FreshlyMinted, HttpResponseMessage? Failure);
/// <summary>
/// Picks the Authorization header for the next request: the cached bearer token, a freshly
/// exchanged one, or Basic. A non-null <see cref="AuthChoice.Failure"/> is the exchange's own
/// 401/429 response, to be surfaced to the caller as-is.
/// </summary>
private async Task<AuthChoice> AcquireAuthAsync(bool forceExchange, CancellationToken ct)
{
var session = _session;
if (session is null || session.BasicOnly)
return new AuthChoice(_basic, false, null);
try
{
await session.Gate.WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (ex is not TaskCanceledException)
{
// Callers map TaskCanceledException to their TIMEOUT result, as for the send itself.
throw new TaskCanceledException(ex.Message, ex, ct);
}
try
{
if (session.BasicOnly)
return new AuthChoice(_basic, false, null);
if (!forceExchange && session.HasUsableToken(DateTimeOffset.UtcNow))
return new AuthChoice(new AuthenticationHeaderValue("Bearer", session.Token), false, null);
session.Token = null;
using var form = new FormUrlEncodedContent(
[
new KeyValuePair<string, string>("username", _username),
new KeyValuePair<string, string>("password", _password),
]);
var response = await _httpClient.PostAsync("auth/token", form, ct).ConfigureAwait(false);
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.TooManyRequests)
return new AuthChoice(_basic, false, response);
using (response)
{
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed)
{
session.BasicOnly = true;
return new AuthChoice(_basic, false, null);
}
if (!response.IsSuccessStatusCode)
return new AuthChoice(_basic, false, null); // transient: Basic for this call only
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!TryParseToken(body, out var token, out var lifetime))
{
// An older server: /auth/token exists but no management surface takes its tokens.
session.BasicOnly = true;
return new AuthChoice(_basic, false, null);
}
session.Token = token;
var margin = lifetime > TokenRefreshMargin * 2 ? TokenRefreshMargin : TimeSpan.Zero;
session.RefreshAfter = DateTimeOffset.UtcNow + lifetime - margin;
return new AuthChoice(new AuthenticationHeaderValue("Bearer", token), true, null);
}
}
finally
{
session.Gate.Release();
}
}
private static bool TryParseToken(string body, out string token, out TimeSpan lifetime)
{
token = string.Empty;
lifetime = TimeSpan.FromMinutes(15);
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("management_bearer", out var bearer) || bearer.ValueKind != JsonValueKind.True
|| !root.TryGetProperty("access_token", out var accessToken) || accessToken.ValueKind != JsonValueKind.String
|| string.IsNullOrWhiteSpace(accessToken.GetString()))
{
return false;
}
token = accessToken.GetString()!;
if (root.TryGetProperty("expires_in", out var expiresIn) && expiresIn.TryGetInt32(out var seconds) && seconds > 0)
lifetime = TimeSpan.FromSeconds(seconds);
return true;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
/// Sends a request built by <paramref name="buildRequest"/> with the chosen credentials,
/// applying the bearer refresh-once-then-Basic policy on a 401.
/// </summary>
private async Task<HttpResponseMessage> SendAuthenticatedAsync(
Func<HttpRequestMessage> buildRequest, HttpCompletionOption completion, CancellationToken ct)
{
var auth = await AcquireAuthAsync(forceExchange: false, ct).ConfigureAwait(false);
if (auth.Failure is not null)
return auth.Failure;
var response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer")
return response;
// The server refused our bearer token. A token cached from an earlier call may simply be
// stale (expired, signing key rotated): re-exchange once and retry.
if (!auth.FreshlyMinted)
{
response.Dispose();
auth = await AcquireAuthAsync(forceExchange: true, ct).ConfigureAwait(false);
if (auth.Failure is not null)
return auth.Failure;
response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer")
return response;
}
// A token minted moments ago is still refused (e.g. the node that answered does not share
// the signing key): fall back to Basic and stop exchanging for this process.
response.Dispose();
response = await SendWithAsync(buildRequest, _basic, completion, ct).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.Unauthorized && _session is not null)
_session.BasicOnly = true;
return response;
}
private Task<HttpResponseMessage> SendWithAsync(
Func<HttpRequestMessage> buildRequest, AuthenticationHeaderValue header, HttpCompletionOption completion, CancellationToken ct)
{
var request = buildRequest();
request.Headers.Authorization = header;
return _httpClient.SendAsync(request, completion, ct);
}
private static async Task<ManagementResponse> ToManagementResponseAsync(HttpResponseMessage response, CancellationToken ct)
{
var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
return new ManagementResponse((int)response.StatusCode, responseBody, null, null);
string? error;
string? code = null;
try
{
using var doc = JsonDocument.Parse(responseBody);
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
}
catch
{
error = responseBody;
}
return new ManagementResponse((int)response.StatusCode, null, error, code);
}
/// <summary>
@@ -120,12 +370,15 @@ public class ManagementHttpClient : IDisposable
var body = JsonSerializer.Serialize(new { command = commandName, payload },
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var content = new StringContent(body, Encoding.UTF8, "application/json");
HttpResponseMessage httpResponse;
try
{
httpResponse = await _httpClient.PostAsync("management", content, cts.Token);
httpResponse = await SendAuthenticatedAsync(
() => new HttpRequestMessage(HttpMethod.Post, "management")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
},
HttpCompletionOption.ResponseContentRead, cts.Token);
}
catch (TaskCanceledException)
{
@@ -165,7 +418,7 @@ public class ManagementHttpClient : IDisposable
/// <c>/api/audit/query</c> endpoint) and returns the
/// response body. Unlike <see cref="SendCommandAsync"/>, this does not wrap the call
/// in the <c>POST /management</c> command envelope — the audit endpoints are plain
/// REST resources. Authentication (HTTP Basic) and the base address are shared.
/// REST resources. Authentication (bearer token or HTTP Basic) and the base address are shared.
/// </summary>
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
/// <param name="timeout">The request timeout.</param>
@@ -177,7 +430,9 @@ public class ManagementHttpClient : IDisposable
HttpResponseMessage httpResponse;
try
{
httpResponse = await _httpClient.GetAsync(relativePath, cts.Token);
httpResponse = await SendAuthenticatedAsync(
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
HttpCompletionOption.ResponseContentRead, cts.Token);
}
catch (TaskCanceledException)
{
@@ -216,7 +471,7 @@ public class ManagementHttpClient : IDisposable
/// maintenance endpoints) with a JSON body and returns the response. Unlike
/// <see cref="SendCommandAsync"/>, this does not wrap the call in the
/// <c>POST /management</c> command envelope — these are plain REST resources.
/// Authentication (HTTP Basic) and the base address are shared.
/// Authentication (bearer token or HTTP Basic) and the base address are shared.
/// </summary>
/// <param name="relativePath">Path relative to the base URL.</param>
/// <param name="body">The JSON body to send, or <c>null</c> for an empty body.</param>
@@ -226,12 +481,15 @@ public class ManagementHttpClient : IDisposable
{
using var cts = new CancellationTokenSource(timeout);
var content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json");
HttpResponseMessage httpResponse;
try
{
httpResponse = await _httpClient.PostAsync(relativePath, content, cts.Token);
httpResponse = await SendAuthenticatedAsync(
() => new HttpRequestMessage(HttpMethod.Post, relativePath)
{
Content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json"),
},
HttpCompletionOption.ResponseContentRead, cts.Token);
}
catch (TaskCanceledException)
{
@@ -276,7 +534,9 @@ public class ManagementHttpClient : IDisposable
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The raw HTTP response message for streaming.</returns>
public async Task<HttpResponseMessage> SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
=> await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
=> await SendAuthenticatedAsync(
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
HttpCompletionOption.ResponseHeadersRead, cancellationToken);
/// <summary>
/// Disposes the underlying HTTP client.
+14 -3
View File
@@ -28,7 +28,7 @@ scadabridge --url http://central-host:5000 <command>
scadabridge --url http://localhost:9000 <command>
```
The management API is stateless (Basic Auth per request), so sticky sessions are unnecessary. Direct node URLs (`9001`/`9002`) are useful for diagnostics; use Traefik for active-node routing and failover.
The management API is stateless (a self-contained bearer token, or Basic Auth per request), so sticky sessions are unnecessary. Direct node URLs (`9001`/`9002`) are useful for diagnostics; use Traefik for active-node routing and failover.
## Global Options
@@ -61,6 +61,7 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi
| `SCADABRIDGE_FORMAT` | Default output format (overrides config file) |
| `SCADABRIDGE_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
| `SCADABRIDGE_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
| `SCADABRIDGE_AUTH_MODE` | `token` (default) exchanges credentials for a bearer token once per process; `basic` sends HTTP Basic on every call (see *Authentication* below). |
## Output
@@ -2672,13 +2673,23 @@ client deadline; explicit bundle/Flow Debugger timeouts retain their own policie
## Architecture Notes
The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It carries HTTP **Basic** credentials from `--username`/`--password`. Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`.
The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It authenticates with a bearer token (see *Authentication*). Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`.
There is no Akka dependency in the CLI at all: it does not join the cluster, does not use `ClusterClient`, and does not contact a `ClusterClientReceptionist`. (Earlier revisions of this document described a ClusterClient transport that was never built.)
An `HttpClient` is created per command invocation and disposed when the command completes.
Role enforcement is applied by the ManagementActor on the server side. The CLI sends `--username` / `--password` as HTTP Basic credentials. The server performs LDAP authentication, resolves group memberships, and maps them to roles such as Administrator, Designer, and Deployer. Operations require the appropriate role — for example, creating templates requires `Designer`, deploying requires `Deployer`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles.
Role enforcement is applied by the ManagementActor on the server side. The server performs LDAP authentication (once, at the token exchange — or per call on the Basic fallback), resolves group memberships, and maps them to roles such as Administrator, Designer, and Deployer. Operations require the appropriate role — for example, creating templates requires `Designer`, deploying requires `Deployer`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles.
## Authentication
The CLI resolves `--username` / `--password` (or `SCADABRIDGE_USERNAME` / `SCADABRIDGE_PASSWORD`) and, on the first management call of the process, exchanges them at `POST /auth/token` for a short-lived bearer JWT (default 15 minutes) carrying the username, roles and permitted sites. That exchange is the invocation's only LDAP bind: every later call — `/management`, the audit REST endpoints, and `debug stream` (including its reconnects) — sends `Authorization: Bearer <token>`, which the server validates without contacting LDAP, so a short LDAP outage does not interrupt a run that already holds a token.
- The token is kept **in memory only** (never written to disk, logged or printed) and dies with the process.
- A call refused with `401` re-exchanges once, then retries with HTTP Basic.
- A server without bearer support — no `/auth/token`, a login-disabled deployment, or an older server whose token response lacks `management_bearer: true` — is used with HTTP Basic for the rest of the process (an LDAP bind per call, as before).
- Bad credentials or a login-throttle lockout at the exchange are reported directly (exit 1) and not retried with Basic.
- Set `SCADABRIDGE_AUTH_MODE=basic` to skip the exchange entirely.
## Issues & Missing Features
@@ -6,9 +6,11 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
@@ -18,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// </summary>
public static class AuthEndpoints
{
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/logout</c>, and <c>/auth/ping</c> endpoints on the given route builder.</summary>
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/token</c>, <c>/auth/logout</c>, <c>/auth/ping</c> and <c>/auth/activity</c> endpoints on the given route builder.</summary>
/// <param name="endpoints">The route builder to add the endpoints to.</param>
/// <returns>The same <paramref name="endpoints"/> instance, for call chaining.</returns>
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
@@ -118,70 +120,9 @@ public static class AuthEndpoints
context.Response.Redirect("/");
});
endpoints.MapPost("/auth/token", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
// Login throttle: refuse a bind while this username+IP key is locked out
// (fixed-window password-spray guard, arch-review P1/UA5).
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp!))
{
return Results.Json(
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
statusCode: 429);
}
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
throttle.RecordFailure(username, remoteIp!);
return Results.Json(
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
statusCode: 401);
}
throttle.RecordSuccess(username, remoteIp!);
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
// Guard the opaque-Scope unwrap (review I4); see the matching note on
// /auth/login. Fall back to no site-scope rather than throwing if a future
// mapper leaves Scope null or sets a different type.
var scope = roleMapping.Scope is RoleMappingResult mapped
? mapped
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
var token = jwtService.GenerateToken(
displayName,
resolvedUsername,
roleMapping.Roles,
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
return Results.Json(new
{
access_token = token,
token_type = "Bearer",
username = resolvedUsername,
display_name = displayName,
roles = roleMapping.Roles,
});
}).DisableAntiforgery();
// Credential → bearer-token exchange for the CLI (review 2026-10-21 05/U1, 05/P1).
// Antiforgery is disabled: this is a non-cookie, credential-in-body endpoint.
endpoints.MapPost("/auth/token", (Delegate)HandleTokenAsync).DisableAntiforgery();
// Logout is a state-changing authenticated action: it
// keeps antiforgery validation enabled so it cannot be triggered
@@ -212,6 +153,107 @@ public static class AuthEndpoints
return endpoints;
}
/// <summary>
/// Handler for <c>POST /auth/token</c>: binds the form-posted <c>username</c>/<c>password</c>
/// against LDAP (login-throttled), maps the groups to roles and site scope, and returns a
/// short-lived HS256 bearer JWT (<see cref="SecurityOptions.JwtExpiryMinutes"/>) that
/// <c>ManagementAuthenticator</c> accepts on <c>POST /management</c>, the audit REST
/// endpoints and the debug-stream hub. The token carries the username, display name, roles
/// and permitted site ids — everything those surfaces need, so a bearer request costs no
/// LDAP bind. The response's <c>management_bearer: true</c> marks a server whose management
/// surfaces accept the token; a client that does not see it (an older server, whose tokens
/// nothing accepted) falls back to HTTP Basic.
/// </summary>
/// <remarks>
/// Under the dev/test <c>DisableLogin</c> flag the exchange answers <c>404</c>: the
/// management surfaces already authenticate every request as the dev user, so the CLI falls
/// back to Basic, and no credential-free token is ever minted that another deployment
/// sharing the signing key might honour.
/// </remarks>
/// <param name="context">The current HTTP context.</param>
/// <returns>The token JSON on success; 400/401/404/429 otherwise.</returns>
public static async Task<IResult> HandleTokenAsync(HttpContext context)
{
var disableLogin = context.RequestServices.GetService<IOptions<AuthDisableLoginOptions>>()?.Value;
if (disableLogin?.DisableLogin == true)
{
return Results.Json(
new { error = "Token exchange is unavailable while login is disabled.", code = "TOKEN_EXCHANGE_DISABLED" },
statusCode: StatusCodes.Status404NotFound);
}
if (!context.Request.HasFormContentType)
{
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
var form = await context.Request.ReadFormAsync(context.RequestAborted);
var username = form["username"].ToString();
var password = form["password"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
// Login throttle: refuse a bind while this username+IP key is locked out
// (fixed-window password-spray guard, arch-review P1/UA5).
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp!))
{
return Results.Json(
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
statusCode: 429);
}
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
throttle.RecordFailure(username, remoteIp!);
return Results.Json(
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" },
statusCode: 401);
}
throttle.RecordSuccess(username, remoteIp!);
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
// Guard the opaque-Scope unwrap (review I4); see the matching note on
// /auth/login. Fall back to no site-scope rather than throwing if a future
// mapper leaves Scope null or sets a different type.
var scope = roleMapping.Scope is RoleMappingResult mapped
? mapped
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
// Site ids are stamped exactly as ManagementAuthenticator's Basic path projects them:
// none for a system-wide grant, the permitted set otherwise.
var token = jwtService.GenerateToken(
displayName,
resolvedUsername,
roleMapping.Roles,
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
return Results.Json(new
{
access_token = token,
token_type = "Bearer",
expires_in = (int)jwtService.TokenLifetime.TotalSeconds,
management_bearer = true,
username = resolvedUsername,
display_name = displayName,
roles = roleMapping.Roles,
});
}
/// <summary>
/// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's
/// cookie session is still valid and <c>401</c> once it has lapsed
@@ -85,6 +85,17 @@ public static class StartupValidator
.Require("ScadaBridge:Security:JwtSigningKey",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["JwtSigningKey"]),
"required for Central")
// The key signs the CLI bearer JWT (/auth/token → ManagementAuthenticator).
// JwtTokenService is resolved lazily (first token request), so its own
// constructor check cannot fail the boot — a 1-byte key used to boot clean
// and mint trivially forgeable tokens (review 2026-10-21 05/U1). HMAC-SHA256
// needs >= 256 bits; measured in UTF-8 bytes, exactly as the service keys the
// HMAC. A missing key is reported by the rule above, not twice.
.Require("ScadaBridge:Security:JwtSigningKey",
value => string.IsNullOrEmpty(value)
|| System.Text.Encoding.UTF8.GetByteCount(value) >= ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes,
$"must be at least {ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes} bytes "
+ "(256 bits) for HMAC-SHA256 — it signs the CLI bearer tokens, and a short key makes them forgeable")
// The inbound API-key pepper
// backs the peppered-HMAC secret compare in the shared
// ZB.MOM.WW.Auth.ApiKeys verifier (wired by AddZbApiKeyAuth at the
@@ -10,13 +10,24 @@ using ZB.MOM.WW.ScadaBridge.Security.Auth;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
/// <summary>
/// Shared HTTP-Basic → LDAP authentication for the management/CLI surfaces
/// (<see cref="ManagementEndpoints"/>, <see cref="AuditEndpoints"/>, and the debug-stream hub).
/// Centralising the flow here means the dev/test <c>DisableLogin</c> bypass is applied
/// <em>identically</em> on every CLI-facing surface.
/// Shared authentication for the management/CLI surfaces (<see cref="ManagementEndpoints"/>,
/// <see cref="AuditEndpoints"/>, and the debug-stream hub). Accepts either
/// <c>Authorization: Bearer &lt;jwt&gt;</c> (a token minted by <c>POST /auth/token</c>) or
/// HTTP Basic (an LDAP bind per request). Centralising the flow here means the dev/test
/// <c>DisableLogin</c> bypass and both schemes are applied <em>identically</em> on every
/// CLI-facing surface.
/// </summary>
/// <remarks>
/// <para>
/// <b>Bearer</b> (review 2026-10-21 05/U1, 05/P1): the token is validated by
/// <see cref="JwtTokenService.ValidateToken"/> — HS256 signature with the shared
/// <c>JwtSigningKey</c>, issuer, audience and lifetime (small clock skew). The username, roles
/// and permitted site ids inside a valid token are authoritative for its lifetime: a bearer
/// request performs no LDAP bind and no role re-map, mirroring "LDAP failure: active sessions
/// continue with current roles". An invalid token is a plain 401 — there is no LDAP bind to
/// throttle, so it does not feed <see cref="LoginThrottle"/>.
/// </para>
/// <para>
/// <c>ScadaBridge:Security:Auth:DisableLogin</c> swaps the <em>cookie</em> authentication
/// scheme for <c>AutoLoginAuthenticationHandler</c>, which lets the interactive browser UI
/// in without credentials. The CLI does not use cookies — it authenticates the
@@ -61,9 +72,10 @@ public static class ManagementAuthenticator
/// <summary>
/// Authenticates a management/CLI request. Honours the dev/test <c>DisableLogin</c> bypass
/// first (<see cref="TryDisableLoginUser"/>); otherwise decodes HTTP Basic Auth, binds
/// against LDAP, and resolves roles. Returns a populated <see cref="AuthenticatedUser"/> on
/// success, or an <see cref="IResult"/> carrying the 401 response on any failure.
/// first (<see cref="TryDisableLoginUser"/>); otherwise accepts a bearer JWT (validated
/// locally, no LDAP) or decodes HTTP Basic Auth, binds against LDAP, and resolves roles.
/// Returns a populated <see cref="AuthenticatedUser"/> on success, or an
/// <see cref="IResult"/> carrying the 401 (or 429 throttle) response on any failure.
/// </summary>
/// <param name="context">The HTTP request to authenticate.</param>
/// <returns>An <see cref="AuthOutcome"/> carrying either the user or the 401 failure result.</returns>
@@ -75,12 +87,17 @@ public static class ManagementAuthenticator
if (bypass is not null)
return new AuthOutcome(bypass, null);
// 1. Decode Basic Auth
var authHeader = context.Request.Headers.Authorization.ToString();
// 0. Bearer token minted by POST /auth/token — validated locally, no LDAP bind.
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return AuthenticateBearer(context, authHeader["Bearer ".Length..].Trim());
// 1. Decode Basic Auth
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
return new AuthOutcome(null, Results.Json(
new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401));
new { error = "Authorization header required (Basic or Bearer scheme).", code = "AUTH_FAILED" }, statusCode: 401));
}
string username, password;
@@ -144,4 +161,39 @@ public static class ManagementAuthenticator
return new AuthOutcome(resolvedUser, null);
}
/// <summary>
/// Validates a bearer JWT and projects its claims onto an <see cref="AuthenticatedUser"/>.
/// The token's roles and site scope are taken as-is (authoritative until expiry); an
/// empty site-id set is the same system-wide signal the Basic path produces.
/// </summary>
private static AuthOutcome AuthenticateBearer(HttpContext context, string token)
{
var jwt = context.RequestServices.GetService<JwtTokenService>();
var principal = jwt?.ValidateToken(token);
var username = principal?.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
if (principal is null || string.IsNullOrWhiteSpace(username))
{
return new AuthOutcome(null, Results.Json(
new { error = "Invalid or expired bearer token.", code = "AUTH_FAILED" }, statusCode: 401));
}
var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
var roles = principal.FindAll(JwtTokenService.RoleClaimType)
.Select(c => c.Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType)
.Select(c => c.Value)
.Distinct(StringComparer.Ordinal)
.ToArray();
return new AuthOutcome(
new AuthenticatedUser(
username,
string.IsNullOrWhiteSpace(displayName) ? username : displayName,
roles,
permittedSiteIds),
null);
}
}
@@ -8,6 +8,12 @@ using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Mints and validates the short-lived HMAC-SHA256 bearer JWT that <c>POST /auth/token</c>
/// issues and <c>ManagementAuthenticator</c> accepts on the management, audit REST and
/// debug-stream surfaces. Also owns the claim-type constants shared with the cookie session.
/// The Blazor cookie session does NOT embed a JWT.
/// </summary>
public class JwtTokenService
{
private readonly SecurityOptions _options;
@@ -56,16 +62,30 @@ public class JwtTokenService
/// <summary>Fixed audience bound into every token and required on validation.</summary>
public const string TokenAudience = "scadabridge-central";
/// <summary>
/// Clock skew tolerated when validating <c>exp</c>/<c>nbf</c>. A bearer token minted on
/// one central node is routinely presented to the other, so a few seconds of drift
/// between the pair must not reject a valid token; the window is kept small so an
/// expired token is not honoured for long.
/// </summary>
public static readonly TimeSpan ValidationClockSkew = TimeSpan.FromSeconds(30);
private readonly TimeProvider _timeProvider;
/// <summary>Initializes the service and validates the JWT signing key meets the minimum length requirement.</summary>
/// <param name="options">Security options containing the JWT signing key, expiry, and timeout settings.</param>
/// <param name="options">Security options containing the JWT signing key and token lifetime.</param>
/// <param name="logger">Logger instance.</param>
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
/// <param name="timeProvider">Clock used to stamp <c>iat</c>/<c>nbf</c>/<c>exp</c>; defaults to the system clock.</param>
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger, TimeProvider? timeProvider = null)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
// Fail fast: a missing or short signing key produces trivially forgeable tokens.
// HMAC-SHA256 requires a key of at least 256 bits (32 bytes).
// Defence in depth: a missing or short signing key produces trivially forgeable
// tokens. HMAC-SHA256 requires a key of at least 256 bits (32 bytes). The boot-time
// guard is StartupValidator (Central role), which fails the node before the host is
// built; this check only catches a host that bypassed it (tests, tooling).
var keyByteLength = string.IsNullOrEmpty(_options.JwtSigningKey)
? 0
: Encoding.UTF8.GetByteCount(_options.JwtSigningKey);
@@ -78,25 +98,26 @@ public class JwtTokenService
}
}
/// <summary>Lifetime of an issued bearer token (<see cref="SecurityOptions.JwtExpiryMinutes"/>).</summary>
public TimeSpan TokenLifetime => TimeSpan.FromMinutes(_options.JwtExpiryMinutes);
/// <summary>
/// Issues a fresh JWT. <paramref name="lastActivity"/> sets the idle-timeout
/// anchor; when omitted (a brand-new login) it defaults to now. On a token
/// refresh the caller MUST pass the existing anchor forward so the idle window
/// continues to be measured from the user's last genuine activity rather than
/// from token issuance time.
/// Issues a short-lived bearer JWT for the management / audit / debug-stream surfaces
/// (<c>POST /auth/token</c>). The token carries everything <c>ManagementAuthenticator</c>
/// needs — username, display name, roles and the permitted site ids — and those claims
/// are authoritative until the token expires (<see cref="SecurityOptions.JwtExpiryMinutes"/>):
/// a bearer request performs no LDAP bind and no role re-map.
/// </summary>
/// <param name="displayName">Human-readable display name embedded as a claim.</param>
/// <param name="username">Authenticated username embedded as a claim.</param>
/// <param name="roles">Role names to embed as <see cref="RoleClaimType"/> claims.</param>
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
/// <param name="lastActivity">Idle-timeout anchor; defaults to now when null (fresh login).</param>
/// <returns>The signed JWT string, ready to embed in a cookie or bearer header.</returns>
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null or empty for system-wide access.</param>
/// <returns>The signed JWT string, ready to send as an <c>Authorization: Bearer</c> header.</returns>
public string GenerateToken(
string displayName,
string username,
IReadOnlyList<string> roles,
IReadOnlyList<string>? permittedSiteIds,
DateTimeOffset? lastActivity = null)
IReadOnlyList<string>? permittedSiteIds)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
@@ -105,7 +126,6 @@ public class JwtTokenService
{
new(DisplayNameClaimType, displayName),
new(UsernameClaimType, username),
new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
};
foreach (var role in roles)
@@ -121,11 +141,13 @@ public class JwtTokenService
}
}
var now = _timeProvider.GetUtcNow().UtcDateTime;
var token = new JwtSecurityToken(
issuer: TokenIssuer,
audience: TokenAudience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
notBefore: now,
expires: now.Add(TokenLifetime),
signingCredentials: credentials);
// MapOutboundClaims=false: write the claim TYPE strings into the JWT
@@ -135,19 +157,26 @@ public class JwtTokenService
// MapInboundClaims=false on validation (see ValidateToken), this makes the
// claim type the policy checks — RequireClaim(RoleClaimType, …) — byte-for-byte
// the same string both in the token and after it is read back, with no
// mapping round-trip surprises. The "zb:" scope/display/username claims are
// not in any map and were unaffected either way; pinning both directions
// makes the role/site migration to canonical types deterministic.
// mapping round-trip surprises.
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
handler.OutboundClaimTypeMap.Clear();
return handler.WriteToken(token);
}
/// <summary>Validates a JWT string and returns the decoded <see cref="ClaimsPrincipal"/>, or null if the token is invalid or expired.</summary>
/// <summary>
/// Validates a bearer JWT and returns the decoded <see cref="ClaimsPrincipal"/>, or
/// <c>null</c> if the token is malformed, unsigned (<c>alg=none</c>), signed with any
/// algorithm other than HS256, signed with a different key, expired, not yet valid, or
/// minted for a different issuer/audience. Never throws for a bad token and never logs
/// the token itself.
/// </summary>
/// <param name="token">The JWT string to validate.</param>
/// <returns>The decoded principal, or <c>null</c> if validation fails.</returns>
public ClaimsPrincipal? ValidateToken(string token)
{
if (string.IsNullOrWhiteSpace(token))
return null;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
var validationParameters = new TokenValidationParameters
{
@@ -156,9 +185,14 @@ public class JwtTokenService
ValidateAudience = true,
ValidAudience = TokenAudience,
ValidateLifetime = true,
RequireExpirationTime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ClockSkew = TimeSpan.Zero,
// Signed tokens only, and only the one algorithm we mint: an alg=none or an
// algorithm-confusion token is rejected before its claims are read.
RequireSignedTokens = true,
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
ClockSkew = ValidationClockSkew,
// The token was minted carrying the canonical claim TYPE strings
// verbatim (RoleClaimType = ClaimTypes.Role URI, SiteIdClaimType =
@@ -181,114 +215,11 @@ public class JwtTokenService
var principal = handler.ValidateToken(token, validationParameters, out _);
return principal;
}
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Token validation failed");
// Only the exception TYPE is logged — the message can echo token segments.
_logger.LogDebug("Bearer token validation failed ({Reason})", ex.GetType().Name);
return null;
}
}
/// <summary>Returns true if the token carried by <paramref name="principal"/> is within the sliding-refresh threshold and should be reissued.</summary>
/// <param name="principal">The current authenticated principal.</param>
/// <returns><c>true</c> when the remaining token lifetime falls below <see cref="SecurityOptions.JwtRefreshThresholdMinutes"/>.</returns>
public bool ShouldRefresh(ClaimsPrincipal principal)
{
var expClaim = principal.FindFirst("exp");
if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix))
return false;
var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix);
var remaining = expiry - DateTimeOffset.UtcNow;
return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes;
}
/// <summary>Returns true if the session has exceeded the idle timeout based on the <see cref="LastActivityClaimType"/> claim.</summary>
/// <param name="principal">The current authenticated principal.</param>
/// <returns><c>true</c> when the elapsed time since last activity exceeds <see cref="SecurityOptions.IdleTimeoutMinutes"/>.</returns>
public bool IsIdleTimedOut(ClaimsPrincipal principal)
{
var lastActivityClaim = principal.FindFirst(LastActivityClaimType);
if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity))
return true;
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
}
/// <summary>
/// Issues a fresh token (new expiry, re-queried roles) while <b>preserving</b> the
/// existing <see cref="LastActivityClaimType"/> anchor. A refresh is itself
/// triggered by a request, but it must not be treated as user activity — the
/// idle window must keep being measured from the user's last genuine interaction,
/// otherwise the documented 30-minute idle timeout could never fire for a client
/// that polls in the background. Call <see cref="RecordActivity"/> to advance the
/// anchor when handling a genuine user request.
/// <para>
/// A principal that is already past the idle timeout cannot be refreshed: this
/// method returns <c>null</c> (the same "cannot refresh" signal it uses for missing
/// claims). Enforcing the idle check here — rather than relying on the caller to
/// invoke <see cref="IsIdleTimedOut"/> first — guarantees the documented 30-minute
/// idle policy holds regardless of caller discipline; otherwise an idle-expired
/// session could be kept alive indefinitely by background refreshes.
/// </para>
/// </summary>
/// <param name="currentPrincipal">The current authenticated principal whose claims carry identity and the last-activity anchor.</param>
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
/// <returns>A new token string, or <c>null</c> if the session is idle-expired or claims are missing.</returns>
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
{
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
if (displayName == null || username == null)
{
_logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims");
return null;
}
// An idle-expired session must not be renewed — the user must re-login.
if (IsIdleTimedOut(currentPrincipal))
{
_logger.LogInformation(
"Cannot refresh token for {Username}: session is past the idle timeout", username);
return null;
}
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
ReadLastActivity(currentPrincipal));
}
/// <summary>
/// Issues a fresh token whose <see cref="LastActivityClaimType"/> anchor is
/// advanced to now. This is the explicit "user did something" path — distinct
/// from <see cref="RefreshToken"/> — to be called by the request pipeline when
/// handling a genuine user interaction.
/// </summary>
/// <param name="currentPrincipal">The current authenticated principal.</param>
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
/// <returns>A new token string with an updated last-activity anchor, or <c>null</c> if claims are missing.</returns>
public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
{
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
if (displayName == null || username == null)
{
_logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
return null;
}
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
DateTimeOffset.UtcNow);
}
private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
{
var claim = principal.FindFirst(LastActivityClaimType);
return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
? value
: null;
}
}
@@ -2,15 +2,31 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Maps a user's LDAP groups to ScadaBridge roles and the Deployer site scope, from the
/// DB-backed LDAP-group-mapping table. Reads through the process-wide
/// <see cref="RoleMappingCache"/> when one is supplied (production DI always does), so a
/// mapping costs no database round-trip while the snapshot is fresh.
/// </summary>
public class RoleMapper
{
private readonly ISecurityRepository _securityRepository;
private readonly RoleMappingCache? _cache;
/// <summary>Initializes the mapper with the security repository.</summary>
/// <summary>Initializes an uncached mapper that reads the repository on every call.</summary>
/// <param name="securityRepository">Repository used to retrieve LDAP group-to-role mappings and scope rules.</param>
public RoleMapper(ISecurityRepository securityRepository)
: this(securityRepository, null)
{
}
/// <summary>Initializes the mapper with the security repository and the process-wide mapping cache.</summary>
/// <param name="securityRepository">Repository used to retrieve LDAP group-to-role mappings and scope rules.</param>
/// <param name="cache">Process-wide mapping snapshot; null reads the repository on every call.</param>
public RoleMapper(ISecurityRepository securityRepository, RoleMappingCache? cache)
{
_securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository));
_cache = cache;
}
// virtual: a test seam so HTTP-pipeline tests (e.g. the audit
@@ -23,7 +39,9 @@ public class RoleMapper
IReadOnlyList<string> ldapGroups,
CancellationToken ct = default)
{
var allMappings = await _securityRepository.GetAllMappingsAsync(ct);
IReadOnlyList<RoleMappingCache.MappingRow> rows = _cache is not null
? await _cache.GetAsync(_securityRepository, ct)
: await LoadMatchedRowsAsync(ldapGroups, ct);
var matchedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var permittedSiteIds = new HashSet<string>();
@@ -31,7 +49,7 @@ public class RoleMapper
var hasScopedDeploymentMapping = false;
var hasUnscopedDeploymentMapping = false;
foreach (var mapping in allMappings)
foreach (var mapping in rows)
{
// Match LDAP group names (case-insensitive)
if (!ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase)))
@@ -43,13 +61,12 @@ public class RoleMapper
{
hasDeploymentRole = true;
var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
if (scopeRules.Count > 0)
if (mapping.ScopeSiteIds.Count > 0)
{
hasScopedDeploymentMapping = true;
foreach (var rule in scopeRules)
foreach (var siteId in mapping.ScopeSiteIds)
{
permittedSiteIds.Add(rule.SiteId.ToString());
permittedSiteIds.Add(siteId);
}
}
else
@@ -80,4 +97,27 @@ public class RoleMapper
permittedSiteIds.ToList(),
isSystemWide);
}
// Uncached path (no RoleMappingCache supplied): reads the table and the scope rules of the
// MATCHED Deployer mappings only, exactly as the mapper always did.
private async Task<IReadOnlyList<RoleMappingCache.MappingRow>> LoadMatchedRowsAsync(
IReadOnlyList<string> ldapGroups, CancellationToken ct)
{
var allMappings = await _securityRepository.GetAllMappingsAsync(ct);
var rows = new List<RoleMappingCache.MappingRow>(allMappings.Count);
foreach (var mapping in allMappings)
{
IReadOnlyList<string> scope = [];
if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase)
&& ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase)))
{
var rules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
scope = rules.Select(r => r.SiteId.ToString()).ToArray();
}
rows.Add(new RoleMappingCache.MappingRow(mapping.Id, mapping.LdapGroupName, mapping.Role, scope));
}
return rows;
}
}
@@ -0,0 +1,129 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Process-wide, short-TTL snapshot of the LDAP-group→role mapping table and the site-scope
/// rules of every Deployer mapping (review 2026-10-21 05/P1, 07/P-3). Before it, every
/// <see cref="RoleMapper.MapGroupsToRolesAsync"/> call — one per Basic-authenticated
/// management request and one per Blazor auth-state read — issued
/// <c>GetAllMappingsAsync</c> plus one <c>GetScopeRulesForMappingAsync</c> per matched Deployer
/// mapping. The table changes only through Administrator commands, so a
/// <see cref="SecurityOptions.RoleMappingCacheSeconds"/> window (default 30 s) of staleness is
/// an acceptable price: a role revocation reaches every node within that window, and the node
/// that served the change sees it at once when the mutation path calls <see cref="Invalidate"/>.
/// </summary>
/// <remarks>
/// <para>
/// The snapshot holds immutable copies (never the EF entities of whichever scope loaded it), and
/// loads the scope rules of ALL Deployer mappings in one refresh, so a cache hit performs no
/// database work at all.
/// </para>
/// <para>
/// A refresh that started before an <see cref="Invalidate"/> call is not published: a
/// generation counter is captured before the load and compared on publish, so an in-flight
/// read of the pre-change table can never overwrite the invalidation.
/// </para>
/// </remarks>
public sealed class RoleMappingCache
{
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor<SecurityOptions>? _options;
private readonly object _lock = new();
private Entry? _entry;
private long _generation;
/// <summary>One cached mapping row.</summary>
/// <param name="Id">The mapping's primary key.</param>
/// <param name="LdapGroupName">The LDAP group the mapping matches.</param>
/// <param name="Role">The ScadaBridge role the group grants.</param>
/// <param name="ScopeSiteIds">The permitted site ids (Deployer mappings only; empty = unscoped).</param>
public sealed record MappingRow(int Id, string LdapGroupName, string Role, IReadOnlyList<string> ScopeSiteIds);
private sealed record Entry(IReadOnlyList<MappingRow> Rows, DateTimeOffset LoadedAt);
/// <summary>Initializes the cache.</summary>
/// <param name="timeProvider">Clock used to age the snapshot.</param>
/// <param name="options">Security options supplying <see cref="SecurityOptions.RoleMappingCacheSeconds"/>; null uses the 30 s default.</param>
public RoleMappingCache(TimeProvider timeProvider, IOptionsMonitor<SecurityOptions>? options = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options;
}
private TimeSpan Ttl => TimeSpan.FromSeconds(Math.Max(0, _options?.CurrentValue.RoleMappingCacheSeconds ?? 30));
/// <summary>
/// Discards the cached snapshot so the next mapping re-reads the database. Call after any
/// LDAP-group-mapping or site-scope-rule create/update/delete.
/// </summary>
public void Invalidate()
{
lock (_lock)
{
_generation++;
_entry = null;
}
}
/// <summary>
/// Returns the current mapping rows, loading them through <paramref name="repository"/> when
/// the snapshot is missing, older than the TTL, or the cache is disabled.
/// </summary>
/// <param name="repository">The caller's (scoped) security repository, used only on a miss.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The mapping rows with their scope rules resolved.</returns>
public async Task<IReadOnlyList<MappingRow>> GetAsync(ISecurityRepository repository, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(repository);
var ttl = Ttl;
long generation;
lock (_lock)
{
if (ttl > TimeSpan.Zero && _entry is { } cached
&& _timeProvider.GetUtcNow() - cached.LoadedAt < ttl)
{
return cached.Rows;
}
generation = _generation;
}
var rows = await LoadAsync(repository, ct).ConfigureAwait(false);
if (ttl > TimeSpan.Zero)
{
lock (_lock)
{
// Publish only if no Invalidate() raced the load.
if (generation == _generation)
{
_entry = new Entry(rows, _timeProvider.GetUtcNow());
}
}
}
return rows;
}
private static async Task<IReadOnlyList<MappingRow>> LoadAsync(ISecurityRepository repository, CancellationToken ct)
{
var mappings = await repository.GetAllMappingsAsync(ct).ConfigureAwait(false);
var rows = new List<MappingRow>(mappings.Count);
foreach (var mapping in mappings)
{
IReadOnlyList<string> scope = [];
if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase))
{
var rules = await repository.GetScopeRulesForMappingAsync(mapping.Id, ct).ConfigureAwait(false);
scope = rules.Select(r => r.SiteId.ToString()).ToArray();
}
rows.Add(new MappingRow(mapping.Id, mapping.LdapGroupName, mapping.Role, scope));
}
return rows;
}
}
@@ -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
@@ -61,6 +61,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<JwtTokenService>();
services.AddScoped<RoleMapper>();
// Process-wide short-TTL cache of the LDAP-group→role mapping table + scope rules
// (review 2026-10-21 05/P1). Singleton so every scoped RoleMapper shares it; the
// repository it reads through is still the caller's scoped one.
services.AddSingleton<RoleMappingCache>();
// The cookie OnValidatePrincipal core. Scoped to match the
// IGroupRoleMapper<string> it depends on (which depends on the Scoped
// ISecurityRepository). The clock is injected (TimeProvider) so the idle/refresh
@@ -110,7 +115,10 @@ public static class ServiceCollectionExtensions
// LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above)
// now enforces Server + SearchBase + ServiceAccountDn + transport at startup. The
// JWT signing key continues to fail-fast at JwtTokenService construction.
// JWT signing key (presence AND the >=32-byte HMAC-SHA256 floor) is enforced
// pre-host by the Host's StartupValidator on Central nodes; JwtTokenService's
// constructor repeats the length check only as defence in depth, since it is
// resolved lazily (first /auth/token or bearer request), not at boot.
// Register ASP.NET Core authentication with cookie scheme. Only the static
// SecurityOptions-independent settings (login/logout paths) are set here; the
@@ -176,9 +184,9 @@ public static class ServiceCollectionExtensions
// renews it on activity (the middleware re-issues the cookie once past
// the halfway mark of the window). An active user is therefore kept
// signed in; an idle user is signed out after the idle timeout. The
// 15-minute JwtExpiryMinutes governs the lifetime of the embedded JWT
// itself (see JwtTokenService) — a separate layer from the cookie
// session window. Bound here via PostConfigure so SecurityOptions
// cookie carries a claims ticket, not a JWT — JwtExpiryMinutes governs
// only the CLI bearer token minted by /auth/token (see JwtTokenService).
// Bound here via PostConfigure so SecurityOptions
// (configured by the Host after AddSecurity) is honoured.
//
// The cookie hardening (HttpOnly=true, SameSite=Strict,
@@ -207,15 +215,15 @@ public static class ServiceCollectionExtensions
// When the operator opts out of HTTPS-only cookies,
// log a Warning so an HTTP-only deployment is at least audible in
// the startup log. The cookie carries the embedded JWT bearer
// credential — over plain HTTP that travels in cleartext on every
// the startup log. The cookie is the session credential — over
// plain HTTP that travels in cleartext on every
// request. The default is true; this branch fires only on an
// explicit opt-out (typically the dev Docker cluster). Apply sets
// SecurePolicy=SameAsRequest in that case.
if (!securityOptions.Value.RequireHttpsCookie)
{
loggerFactory.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The session cookie will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
}
});