using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI;
///
/// HTTP client for the central management surfaces (POST /management, the audit REST
/// endpoints). Authenticates with a bearer token exchanged once per process at
/// POST /auth/token, falling back to HTTP Basic when the server does not offer bearer
/// tokens (review 2026-10-21 05/U1, 05/P1).
///
///
///
/// Bearer flow. The first call a process makes for a given URL + credential pair posts
/// the credentials to /auth/token (one LDAP bind) and keeps the returned JWT in memory
/// (; never persisted or logged). Every later call — from this or any
/// other client instance in the same process — sends Authorization: Bearer, which the
/// server validates without LDAP. A token nearing expiry is re-exchanged. A bearer call
/// answered 401 re-exchanges once (unless the token was minted for that very call) and,
/// if still refused, retries with Basic.
///
///
/// Basic fallback. A server whose /auth/token is absent (404/405), disabled
/// (login-disabled deployment, 404) or does not advertise management_bearer: true (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 (401) and a throttle lockout (429) from the exchange are
/// returned as-is — retrying them with Basic would only add a second failed bind against the
/// login throttle.
///
///
/// SCADABRIDGE_AUTH_MODE=basic disables the exchange (Basic on every call).
///
///
public class ManagementHttpClient : IDisposable
{
/// Environment variable selecting the auth mode: token (default) or basic.
public const string AuthModeEnvVar = "SCADABRIDGE_AUTH_MODE";
///
/// Environment variable that, when 1/true, makes the client note on stderr
/// when it abandons a refused bearer token for HTTP Basic (e.g. central nodes configured
/// with different signing keys). The CLI has no other verbose switch.
///
public const string VerboseEnvVar = "SCADABRIDGE_VERBOSE";
/// Re-exchange a token this long before its stated expiry.
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;
///
/// Default bound on the CONNECT phase only (30 s) — how long a TCP/TLS
/// connection attempt to a black-holed management endpoint may hang before the
/// call fails.
///
///
/// It is deliberately NOT an overall request timeout.
/// caps the whole request/response, so setting
/// it to any fixed value silently truncates every caller whose own per-call
/// TimeSpan timeout argument is longer: the effective deadline becomes
/// min(HttpClient.Timeout, caller timeout). That is exactly what a 30 s
/// client timeout did to deploy site's 5-minute bulk deploy and to the
/// five-minute bundle export/preview/import calls — each printed a fake
/// 504 Request timed out at 30 s while the server carried on working.
/// is therefore
/// and the per-call
/// in
/// //
/// is the SINGLE overall deadline — it bounds connect too, since the token is
/// passed into the send itself.
///
///
///
/// The connect bound lives on
/// instead, which is connect-scoped and so cannot truncate a long-running
/// request that has already reached the server. Overridable via the
/// SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS environment variable,
/// consistent with how every other CLI setting is overridden (see
/// ) — kept self-contained here (no
/// /command-file plumbing) since CLI commands are owned
/// by a separate work package this phase.
///
///
public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(30);
/// Test seam — the effective this instance was constructed with.
internal TimeSpan EffectiveTimeout { get; }
///
/// Resolves the effective connect timeout: the
/// SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS environment variable when set
/// to a positive integer, otherwise .
///
/// The connect timeout to apply to the socket handler.
internal static TimeSpan ResolveConnectTimeout()
{
var env = Environment.GetEnvironmentVariable("SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS");
if (!string.IsNullOrWhiteSpace(env)
&& int.TryParse(env, out var seconds)
&& seconds > 0)
{
return TimeSpan.FromSeconds(seconds);
}
return DefaultConnectTimeout;
}
///
/// Initializes a new instance of the class with
/// an INFINITE (each call supplies its own
/// deadline) over a whose
/// is
/// .
///
/// The base URL for the management API.
/// The username for HTTP Basic authentication.
/// The password for HTTP Basic authentication.
public ManagementHttpClient(string baseUrl, string username, string password)
: this(
CreateDefaultHttpClient(),
baseUrl, username, password,
IsBasicAuthModeForced() ? null : CliTokenStore.Shared)
{
}
///
/// Builds the production : infinite overall timeout (each call
/// carries its own deadline), connect bounded by , and
/// redirects NOT followed — a 307/308 from a proxy would otherwise re-POST the
/// /auth/token form, i.e. the credentials, to wherever the redirect points.
///
/// The configured client.
internal static HttpClient CreateDefaultHttpClient() =>
new(new SocketsHttpHandler { ConnectTimeout = ResolveConnectTimeout(), AllowAutoRedirect = false })
{
Timeout = Timeout.InfiniteTimeSpan
};
///
/// True when is basic, which disables the token
/// exchange for the process.
///
/// Whether HTTP Basic is forced on every call.
internal static bool IsBasicAuthModeForced() =>
string.Equals(Environment.GetEnvironmentVariable(AuthModeEnvVar)?.Trim(), "basic", StringComparison.OrdinalIgnoreCase);
///
/// Test-only constructor that accepts a pre-built (typically
/// over a stub ) so the request/response handling can
/// be exercised without a live server.
///
/// The HTTP client to use for requests.
/// The base URL for the management API.
/// The username for HTTP Basic authentication.
/// The password for HTTP Basic authentication.
///
/// Bearer-token store to exchange credentials through; (the default)
/// sends HTTP Basic on every call, which keeps stub-handler tests to a single request.
///
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('/') + "/");
_username = username;
_password = password;
_basic = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
_session = tokenStore?.For(baseUrl, username, password);
}
/// The HTTP Basic credential value (base64 user:password), for the debug-stream hub fallback.
internal string BasicCredentials => _basic.Parameter!;
/// Outcome of .
/// The bearer token, or when the caller should use Basic.
/// A credential/throttle failure from the exchange that must be reported, not retried with Basic.
internal readonly record struct BearerResult(string? Token, ManagementResponse? Failure);
///
/// Returns a usable bearer token for this client's credentials, exchanging at
/// /auth/token if none is cached — used by the debug-stream hub, which cannot replay a
/// rejected handshake. is null when the caller should fall
/// back to Basic; is set for bad credentials or a lockout.
///
/// Cancels the exchange.
/// The token, a fall-back-to-Basic signal, or a failure to report.
internal async Task TryGetBearerTokenAsync(CancellationToken cancellationToken)
{
var auth = await AcquireAuthAsync(forceExchange: false, refusedToken: null, 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);
///
/// Picks the Authorization header for the next request: the cached bearer token, a freshly
/// exchanged one, or Basic. A non-null is the exchange's own
/// 401/429 response, to be surfaced to the caller as-is.
///
/// Exchange even though a cached token looks usable (the server refused it).
///
/// The token the server just refused. If another caller has already replaced it by the time
/// the gate is acquired, that replacement is returned instead of exchanging again.
///
/// Cancellation token.
private async Task AcquireAuthAsync(bool forceExchange, string? refusedToken, 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);
if (forceExchange && session.HasUsableToken(DateTimeOffset.UtcNow)
&& !string.Equals(session.Token, refusedToken, StringComparison.Ordinal))
{
// A concurrent caller already re-exchanged: use its token (as freshly minted, so a
// second refusal falls back to Basic rather than exchanging yet again).
return new AuthChoice(new AuthenticationHeaderValue("Bearer", session.Token), true, null);
}
session.Token = null;
using var form = new FormUrlEncodedContent(
[
new KeyValuePair("username", _username),
new KeyValuePair("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;
}
}
///
/// Sends a request built by with the chosen credentials,
/// applying the bearer refresh-once-then-Basic policy on a 401.
///
private async Task SendAuthenticatedAsync(
Func buildRequest, HttpCompletionOption completion, CancellationToken ct)
{
var auth = await AcquireAuthAsync(forceExchange: false, refusedToken: null, 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, refusedToken: auth.Header.Parameter, 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)
{
await MarkBasicOnlyAsync(_session, ct).ConfigureAwait(false);
if (IsVerbose())
{
Console.Error.WriteLine(
"note: the server refused a freshly issued bearer token; using HTTP Basic for the rest of this run "
+ "(check that both central nodes share ScadaBridge:Security:JwtSigningKey).");
}
}
return response;
}
// Written under the session gate so it cannot interleave with an in-flight exchange.
private static async Task MarkBasicOnlyAsync(CliTokenStore.Session session, CancellationToken ct)
{
await session.Gate.WaitAsync(ct).ConfigureAwait(false);
try
{
session.BasicOnly = true;
session.Token = null;
}
finally
{
session.Gate.Release();
}
}
private static bool IsVerbose()
{
var value = Environment.GetEnvironmentVariable(VerboseEnvVar)?.Trim();
return value == "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private Task SendWithAsync(
Func buildRequest, AuthenticationHeaderValue header, HttpCompletionOption completion, CancellationToken ct)
{
var request = buildRequest();
request.Headers.Authorization = header;
return _httpClient.SendAsync(request, completion, ct);
}
private static async Task 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);
}
///
/// Sends a management command to the management API.
///
/// The command name to execute.
/// The command payload.
/// The request timeout.
/// A management response containing status and data.
public async Task SendCommandAsync(string commandName, object payload, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
var body = JsonSerializer.Serialize(new { command = commandName, payload },
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
HttpResponseMessage httpResponse;
try
{
httpResponse = await SendAuthenticatedAsync(
() => new HttpRequestMessage(HttpMethod.Post, "management")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
},
HttpCompletionOption.ResponseContentRead, cts.Token);
}
catch (TaskCanceledException)
{
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
}
catch (HttpRequestException ex)
{
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
}
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
if (httpResponse.IsSuccessStatusCode)
{
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
}
// Parse error response
string? error = null;
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)httpResponse.StatusCode, null, error, code);
}
///
/// Issues a plain HTTP GET against a REST endpoint (e.g. the audit
/// /api/audit/query endpoint) and returns the
/// response body. Unlike , this does not wrap the call
/// in the POST /management command envelope — the audit endpoints are plain
/// REST resources. Authentication (bearer token or HTTP Basic) and the base address are shared.
///
/// Path relative to the base URL, with query string.
/// The request timeout.
/// A management response containing status and data.
public async Task SendGetAsync(string relativePath, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
HttpResponseMessage httpResponse;
try
{
httpResponse = await SendAuthenticatedAsync(
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
HttpCompletionOption.ResponseContentRead, cts.Token);
}
catch (TaskCanceledException)
{
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
}
catch (HttpRequestException ex)
{
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
}
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
if (httpResponse.IsSuccessStatusCode)
{
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
}
string? error = null;
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)httpResponse.StatusCode, null, error, code);
}
///
/// Issues a plain HTTP POST against a REST endpoint (e.g. the audit
/// maintenance endpoints) with a JSON body and returns the response. Unlike
/// , this does not wrap the call in the
/// POST /management command envelope — these are plain REST resources.
/// Authentication (bearer token or HTTP Basic) and the base address are shared.
///
/// Path relative to the base URL.
/// The JSON body to send, or null for an empty body.
/// The request timeout.
/// A management response containing status and data.
public async Task SendPostAsync(string relativePath, string? body, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
HttpResponseMessage httpResponse;
try
{
httpResponse = await SendAuthenticatedAsync(
() => new HttpRequestMessage(HttpMethod.Post, relativePath)
{
Content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json"),
},
HttpCompletionOption.ResponseContentRead, cts.Token);
}
catch (TaskCanceledException)
{
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
}
catch (HttpRequestException ex)
{
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
}
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
if (httpResponse.IsSuccessStatusCode)
{
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
}
string? error = null;
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)httpResponse.StatusCode, null, error, code);
}
///
/// Issues a plain HTTP GET and returns the raw
/// so the caller can stream the response body without buffering it in memory — used
/// by audit export, where the response can be many megabytes. The caller owns
/// disposing the returned message. The
/// option ensures the body is not pre-buffered.
///
/// Path relative to the base URL, with query string.
/// A cancellation token that can be used to cancel the operation.
/// The raw HTTP response message for streaming.
public async Task SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
=> await SendAuthenticatedAsync(
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
HttpCompletionOption.ResponseHeadersRead, cancellationToken);
///
/// Disposes the underlying HTTP client.
///
public void Dispose() => _httpClient.Dispose();
}
public record ManagementResponse(int StatusCode, string? JsonData, string? Error, string? ErrorCode);