Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementHttpClientTokenTests.cs
T
Joseph Doherty 1b3928a0a2 fix(security): finish the bearer-token design and cache role mappings (review 2026-10-21 05/U1, 05/P1)
- ManagementAuthenticator accepts Authorization: Bearer <jwt> alongside Basic on
  /management, the audit REST endpoints and the debug-stream hub. Tokens are
  validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with
  30 s skew); roles and site scope inside a valid token are authoritative for its
  lifetime, so a bearer call costs no LDAP bind and no role re-map.
- /auth/token returns expires_in + management_bearer:true, carries username, display
  name, roles and permitted sites, and answers 404 under DisableLogin.
- CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer,
  re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are
  Basic-only; bad credentials are not retried with Basic. Debug stream uses the token
  via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out.
- StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes;
  fixed the false 'fail-fast at construction' comment. Removed the dead
  RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert
  JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime
  (validated > 0).
- RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds,
  default 30 s): one refresh loads the table plus every Deployer mapping's scope
  rules, Invalidate() drops it (generation-guarded against in-flight refreshes).
- Component-Security.md / Component-CLI.md / CLI README describe the real model
  (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT.
2026-10-22 08:25:35 -04:00

229 lines
10 KiB
C#

using System.Net;
using System.Text;
using ZB.MOM.WW.ScadaBridge.CLI;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Bearer-token exchange in <see cref="ManagementHttpClient"/> (review 2026-10-21 05/U1, 05/P1):
/// credentials are exchanged at <c>/auth/token</c> once per process and the token reused across
/// calls and client instances; a refused token is re-exchanged once, then Basic; a server
/// without bearer support falls back to Basic; bad credentials are not retried with Basic.
/// </summary>
public class ManagementHttpClientTokenTests
{
private const string BaseUrl = "http://localhost:9001";
private sealed class FakeServer : HttpMessageHandler
{
private int _tokenCounter;
public List<(string Path, string? Auth)> Requests { get; } = [];
public Func<HttpResponseMessage>? TokenResponse { get; set; }
public Func<string?, HttpResponseMessage>? ManagementResponse { get; set; }
public HttpResponseMessage IssueToken(int expiresIn = 900)
{
var token = $"tok-{++_tokenCounter}";
return Json(HttpStatusCode.OK,
$"{{\"access_token\":\"{token}\",\"token_type\":\"Bearer\",\"expires_in\":{expiresIn},\"management_bearer\":true}}");
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri!.AbsolutePath;
var auth = request.Headers.Authorization?.ToString();
Requests.Add((path, auth));
if (path == "/auth/token")
{
var form = await request.Content!.ReadAsStringAsync(cancellationToken);
Assert.Contains("username=user", form);
Assert.Null(auth); // credentials travel in the form body, never a header
return TokenResponse?.Invoke() ?? IssueToken();
}
return ManagementResponse?.Invoke(auth) ?? Json(HttpStatusCode.OK, "{\"ok\":true}");
}
}
private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private static ManagementHttpClient Client(FakeServer server, CliTokenStore store) =>
new(new HttpClient(server), BaseUrl, "user", "pass", store);
private static readonly string BasicHeader =
"Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass"));
/// <summary>One exchange serves every call, across client instances in the same process.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExchangesOnce_AndReusesTokenAcrossCallsAndClients()
{
var server = new FakeServer();
var store = new CliTokenStore();
using (var first = Client(server, store))
{
Assert.Equal(200, (await first.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode);
Assert.Equal(200, (await first.SendGetAsync("api/audit/query", TimeSpan.FromSeconds(5))).StatusCode);
}
using (var second = Client(server, store))
{
Assert.Equal(200, (await second.SendPostAsync("api/audit/x", null, TimeSpan.FromSeconds(5))).StatusCode);
}
Assert.Single(server.Requests, r => r.Path == "/auth/token");
Assert.All(server.Requests.Where(r => r.Path != "/auth/token"), r => Assert.Equal("Bearer tok-1", r.Auth));
}
/// <summary>A cached token the server now refuses is re-exchanged once and the call retried.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleCachedToken_IsRefreshedOnce()
{
var server = new FakeServer();
var store = new CliTokenStore();
using var client = Client(server, store);
await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); // mints tok-1
server.ManagementResponse = auth => auth == "Bearer tok-1"
? Json(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid or expired bearer token.\",\"code\":\"AUTH_FAILED\"}")
: Json(HttpStatusCode.OK, "{\"ok\":true}");
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
Assert.Equal(200, response.StatusCode);
Assert.Equal(2, server.Requests.Count(r => r.Path == "/auth/token"));
Assert.Equal("Bearer tok-2", server.Requests[^1].Auth);
}
/// <summary>A token minted for this call and still refused falls back to Basic, then stays on Basic.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FreshTokenRefused_FallsBackToBasic_AndStaysBasic()
{
var server = new FakeServer
{
ManagementResponse = auth => auth!.StartsWith("Bearer", StringComparison.Ordinal)
? Json(HttpStatusCode.Unauthorized, "{\"error\":\"nope\"}")
: Json(HttpStatusCode.OK, "{\"ok\":true}"),
};
using var client = Client(server, new CliTokenStore());
Assert.Equal(200, (await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode);
Assert.Equal(200, (await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode);
Assert.Single(server.Requests, r => r.Path == "/auth/token");
Assert.Equal(BasicHeader, server.Requests[^1].Auth);
Assert.Equal(BasicHeader, server.Requests[^2].Auth);
}
/// <summary>A server without /auth/token (404) is Basic for the rest of the process.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoTokenEndpoint_FallsBackToBasic_WithoutReprobing()
{
var server = new FakeServer { TokenResponse = () => Json(HttpStatusCode.NotFound, "") };
using var client = Client(server, new CliTokenStore());
await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
Assert.Single(server.Requests, r => r.Path == "/auth/token");
Assert.All(server.Requests.Where(r => r.Path == "/management"), r => Assert.Equal(BasicHeader, r.Auth));
}
/// <summary>An older server whose token response lacks management_bearer is treated as Basic-only.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OlderServerToken_WithoutManagementBearer_UsesBasic()
{
var server = new FakeServer
{
TokenResponse = () => Json(HttpStatusCode.OK, "{\"access_token\":\"legacy\",\"token_type\":\"Bearer\"}"),
};
using var client = Client(server, new CliTokenStore());
await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
Assert.Equal(BasicHeader, server.Requests.Single(r => r.Path == "/management").Auth);
}
/// <summary>Bad credentials at the exchange are reported as-is, with no second (Basic) bind.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BadCredentials_AreNotRetriedWithBasic()
{
var server = new FakeServer
{
TokenResponse = () => Json(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid username or password.\",\"code\":\"AUTH_FAILED\"}"),
};
using var client = Client(server, new CliTokenStore());
var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
Assert.Equal(401, response.StatusCode);
Assert.Equal("AUTH_FAILED", response.ErrorCode);
Assert.DoesNotContain(server.Requests, r => r.Path == "/management");
}
/// <summary>A transient exchange failure uses Basic for that call only and retries the exchange next time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TransientExchangeFailure_UsesBasicForThatCallOnly()
{
var server = new FakeServer();
server.TokenResponse = () => Json(HttpStatusCode.ServiceUnavailable, "");
using var client = Client(server, new CliTokenStore());
await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
Assert.Equal(BasicHeader, server.Requests[^1].Auth);
server.TokenResponse = null; // exchange recovers
await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
Assert.Equal(2, server.Requests.Count(r => r.Path == "/auth/token"));
Assert.Equal("Bearer tok-1", server.Requests[^1].Auth);
}
/// <summary>The debug-stream helper returns the cached token, or a failure for bad credentials.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryGetBearerToken_ReturnsTokenOrFailure()
{
var ok = new FakeServer();
using (var client = Client(ok, new CliTokenStore()))
{
var result = await client.TryGetBearerTokenAsync(CancellationToken.None);
Assert.Equal("tok-1", result.Token);
Assert.Null(result.Failure);
}
var bad = new FakeServer
{
TokenResponse = () => Json(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid username or password.\",\"code\":\"AUTH_FAILED\"}"),
};
using (var client = Client(bad, new CliTokenStore()))
{
var result = await client.TryGetBearerTokenAsync(CancellationToken.None);
Assert.Null(result.Token);
Assert.Equal(401, result.Failure!.StatusCode);
}
}
/// <summary>The internal test constructor without a store never exchanges (Basic only).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoStore_SendsBasicOnly()
{
var server = new FakeServer();
using var client = new ManagementHttpClient(new HttpClient(server), BaseUrl, "user", "pass");
await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
Assert.Equal(new[] { ("/management", (string?)BasicHeader) }, server.Requests);
}
}