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,22 @@
using System.Runtime.CompilerServices;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Assembly-wide test setup. The command/contract tests drive the CLI against single-request
/// fake servers (one accepted socket, or a stub handler that records "the" request), so they
/// pin the CLI to HTTP Basic: with the default token mode every process's first call would be
/// a <c>POST /auth/token</c> exchange those fakes do not model. The bearer exchange itself is
/// covered by <see cref="ManagementHttpClientTokenTests"/>, which opts in explicitly through an
/// isolated <see cref="CliTokenStore"/> and is unaffected by this switch.
/// </summary>
internal static class CliTestAssemblySetup
{
#pragma warning disable CA2255 // ModuleInitializer in a test assembly is intentional.
[ModuleInitializer]
internal static void ForceBasicAuthForContractTests()
#pragma warning restore CA2255
{
Environment.SetEnvironmentVariable(ManagementHttpClient.AuthModeEnvVar, "basic");
}
}
@@ -0,0 +1,228 @@
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);
}
}
@@ -0,0 +1,109 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// <c>POST /auth/token</c> (review 2026-10-21 05/U1): the minted token carries everything
/// <c>ManagementAuthenticator</c> needs (username, roles, site scope), the response advertises
/// <c>management_bearer</c> + <c>expires_in</c>, and a login-disabled deployment refuses the exchange.
/// </summary>
public class AuthTokenEndpointTests
{
private const string Key = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough";
private static HttpContext Context(bool disableLogin, RoleMappingResult mapping, out JwtTokenService jwt)
{
var ldap = Substitute.For<ILdapAuthService>();
ldap.AuthenticateAsync("alice", "pw", Arg.Any<CancellationToken>())
.Returns(LdapAuthResult.Success("alice", "Alice A", ["SiteDeployers"]));
var mapper = Substitute.For<IGroupRoleMapper<string>>();
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new GroupRoleMapping<string>(mapping.Roles, Scope: mapping));
var options = Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = 15 });
jwt = new JwtTokenService(options, NullLogger<JwtTokenService>.Instance);
var services = new ServiceCollection().AddLogging();
services.AddSingleton(Options.Create(new AuthDisableLoginOptions { DisableLogin = disableLogin }));
services.AddSingleton<IOptions<SecurityOptions>>(options);
services.AddSingleton(TimeProvider.System);
services.AddSingleton<LoginThrottle>();
services.AddSingleton(ldap);
services.AddSingleton(mapper);
services.AddSingleton(jwt);
var context = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() };
context.Request.Method = "POST";
context.Request.ContentType = "application/x-www-form-urlencoded";
context.Request.Form = new FormCollection(new()
{
["username"] = "alice",
["password"] = "pw",
});
context.Response.Body = new MemoryStream();
return context;
}
private static async Task<(int Status, JsonElement Body)> Execute(HttpContext context)
{
var result = await AuthEndpoints.HandleTokenAsync(context);
await result.ExecuteAsync(context);
context.Response.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(context.Response.Body);
return (context.Response.StatusCode, doc.RootElement.Clone());
}
/// <summary>The token round-trips the user's roles and site scope and advertises bearer support.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Token_CarriesRolesAndScope_AndAdvertisesBearer()
{
var context = Context(false, new RoleMappingResult([Roles.Deployer], ["4", "9"], false), out var jwt);
var (status, body) = await Execute(context);
Assert.Equal(200, status);
Assert.True(body.GetProperty("management_bearer").GetBoolean());
Assert.Equal(15 * 60, body.GetProperty("expires_in").GetInt32());
var principal = jwt.ValidateToken(body.GetProperty("access_token").GetString()!);
Assert.NotNull(principal);
Assert.Equal("alice", principal!.FindFirst(JwtTokenService.UsernameClaimType)!.Value);
Assert.Equal("Alice A", principal.FindFirst(JwtTokenService.DisplayNameClaimType)!.Value);
Assert.Equal([Roles.Deployer], principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
Assert.Equal(["4", "9"], principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value));
}
/// <summary>A system-wide grant carries no site-id claims (empty == system-wide downstream).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Token_SystemWide_HasNoSiteClaims()
{
var context = Context(false, new RoleMappingResult([Roles.Deployer], [], true), out var jwt);
var (_, body) = await Execute(context);
var principal = jwt.ValidateToken(body.GetProperty("access_token").GetString()!);
Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType));
}
/// <summary>A login-disabled deployment refuses the exchange so the CLI falls back to Basic.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Token_WhenLoginDisabled_Is404()
{
var context = Context(true, new RoleMappingResult([Roles.Deployer], [], true), out _);
var (status, body) = await Execute(context);
Assert.Equal(404, status);
Assert.False(body.TryGetProperty("access_token", out _));
}
}
@@ -330,6 +330,55 @@ public class StartupValidatorTests
Assert.Contains("JwtSigningKey required for Central", ex.Message);
}
/// <summary>
/// A present-but-short JWT signing key fails the boot (review 2026-10-21 05/U1): it signs the
/// CLI bearer tokens, and JwtTokenService is only resolved on the first token request.
/// </summary>
[Fact]
public void Central_ShortJwtSigningKey_FailsValidation()
{
var values = ValidCentralConfig();
values["ScadaBridge:Security:JwtSigningKey"] = new string('k', 31);
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("JwtSigningKey must be at least 32 bytes", ex.Message);
}
/// <summary>The key floor is measured in UTF-8 bytes, so a 32-byte key passes.</summary>
[Fact]
public void Central_ThirtyTwoByteJwtSigningKey_Passes()
{
var values = ValidCentralConfig();
values["ScadaBridge:Security:JwtSigningKey"] = new string('k', 32);
var config = BuildConfig(values);
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
}
/// <summary>A missing key reports only the "required" rule, not also the length rule.</summary>
[Fact]
public void Central_MissingJwtSigningKey_DoesNotAlsoReportLength()
{
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Security:JwtSigningKey");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.DoesNotContain("at least 32 bytes", ex.Message);
}
/// <summary>Site nodes carry no signing key and are not subject to the floor.</summary>
[Fact]
public void Site_ShortJwtSigningKey_IsIgnored()
{
var values = ValidSiteConfig();
values["ScadaBridge:Security:JwtSigningKey"] = "x";
var config = BuildConfig(values);
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
}
/// <summary>Verifies that a central node missing API key pepper fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
@@ -18,8 +18,7 @@ public class CentralFailoverTests
{
JwtSigningKey = signingKey,
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
});
return new JwtTokenService(options, NullLogger<JwtTokenService>.Instance);
}
@@ -124,44 +123,6 @@ public class CentralFailoverTests
Assert.NotNull(expClaim);
}
/// <summary>Verifies that an idle timeout is detected.</summary>
[Fact]
public void JwtToken_IdleTimeout_Detected()
{
var jwtService = CreateJwtService();
var token = jwtService.GenerateToken(
displayName: "Idle User",
username: "idle_user",
roles: new[] { "Viewer" },
permittedSiteIds: null);
var principal = jwtService.ValidateToken(token);
Assert.NotNull(principal);
// Token was just generated — should NOT be idle timed out
Assert.False(jwtService.IsIdleTimedOut(principal!));
}
/// <summary>Verifies that the refresh check detects a token near expiry.</summary>
[Fact]
public void JwtToken_ShouldRefresh_DetectsNearExpiry()
{
var jwtService = CreateJwtService();
var token = jwtService.GenerateToken(
displayName: "User",
username: "user",
roles: new[] { "Administrator" },
permittedSiteIds: null);
var principal = jwtService.ValidateToken(token);
Assert.NotNull(principal);
// Token was just generated with 15min expiry and 5min threshold — NOT near expiry
Assert.False(jwtService.ShouldRefresh(principal!));
}
/// <summary>Verifies that deployment status for multiple instances is tracked independently.</summary>
[Trait("Category", "Integration")]
[Fact]
@@ -17,8 +17,7 @@ public class SecurityHardeningTests
{
JwtSigningKey = signingKey,
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
});
return new JwtTokenService(options, NullLogger<JwtTokenService>.Instance);
}
@@ -118,7 +117,6 @@ public class SecurityHardeningTests
Assert.Equal(15, options.JwtExpiryMinutes);
Assert.Equal(30, options.IdleTimeoutMinutes);
Assert.Equal(5, options.JwtRefreshThresholdMinutes);
}
/// <summary>Verifies the case: jwt Token, tampered Payload, fails Validation.</summary>
@@ -151,36 +149,6 @@ public class SecurityHardeningTests
Assert.Null(principal);
}
/// <summary>Verifies the case: jwt Refresh Token, preserves Identity.</summary>
[Fact]
public void JwtRefreshToken_PreservesIdentity()
{
var jwtService = CreateJwtService();
var originalToken = jwtService.GenerateToken(
displayName: "Original User",
username: "orig_user",
roles: new[] { "Administrator", "Designer" },
permittedSiteIds: new[] { "site-1" });
var principal = jwtService.ValidateToken(originalToken);
Assert.NotNull(principal);
// Refresh the token
var refreshedToken = jwtService.RefreshToken(
principal!,
new[] { "Administrator", "Designer" },
new[] { "site-1" });
Assert.NotNull(refreshedToken);
var refreshedPrincipal = jwtService.ValidateToken(refreshedToken!);
Assert.NotNull(refreshedPrincipal);
Assert.Equal("Original User", refreshedPrincipal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value);
Assert.Equal("orig_user", refreshedPrincipal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
}
/// <summary>Verifies the case: startup Validator, rejects Insecure Ldap In Production.</summary>
[Fact]
public void StartupValidator_RejectsInsecureLdapInProduction()
@@ -0,0 +1,174 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Bearer-token branch of <see cref="ManagementAuthenticator"/> (review 2026-10-21 05/U1, 05/P1):
/// a JWT minted by <c>/auth/token</c> authenticates the management/audit/debug surfaces without
/// an LDAP bind, and every malformed, expired, tampered, foreign-key or unsigned token is a 401.
/// </summary>
public class ManagementAuthenticatorBearerTests
{
private const string Key = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough";
private sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private static JwtTokenService Jwt(string key = Key, TimeProvider? clock = null) =>
new(Options.Create(new SecurityOptions { JwtSigningKey = key, JwtExpiryMinutes = 15 }),
NullLogger<JwtTokenService>.Instance, clock);
private static (HttpContext Context, ILdapAuthService Ldap) ContextWithAuthorization(string authorization)
{
var ldap = Substitute.For<ILdapAuthService>();
var services = new ServiceCollection();
services.AddSingleton<IOptions<AuthDisableLoginOptions>>(
Options.Create(new AuthDisableLoginOptions { DisableLogin = false }));
services.AddSingleton<IOptions<SecurityOptions>>(
Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = 15 }));
services.AddLogging();
services.AddSingleton(ldap);
services.AddScoped<JwtTokenService>();
var context = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() };
context.Request.Headers.Authorization = authorization;
return (context, ldap);
}
/// <summary>A valid token yields its username, roles and site scope with no LDAP bind.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ValidBearer_AuthenticatesFromClaims_WithoutLdap()
{
var token = Jwt().GenerateToken("Dee Ployer", "deployer", [Roles.Deployer, Roles.Viewer], ["3", "7"]);
var (context, ldap) = ContextWithAuthorization($"Bearer {token}");
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.Failure);
Assert.NotNull(outcome.User);
Assert.Equal("deployer", outcome.User!.Username);
Assert.Equal("Dee Ployer", outcome.User.DisplayName);
Assert.Equal(new[] { Roles.Deployer, Roles.Viewer }, outcome.User.Roles);
Assert.Equal(new[] { "3", "7" }, outcome.User.PermittedSiteIds);
await ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default);
}
/// <summary>A token minted without site ids is system-wide (empty permitted set), like the Basic path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ValidBearer_NoSiteClaims_IsSystemWide()
{
var token = Jwt().GenerateToken("Admin", "admin", [Roles.Administrator], null);
var (context, _) = ContextWithAuthorization($"Bearer {token}");
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.NotNull(outcome.User);
Assert.Empty(outcome.User!.PermittedSiteIds);
}
/// <summary>An expired token (past the 30 s skew) is rejected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExpiredBearer_Is401()
{
var issuedLongAgo = Jwt(clock: new FixedClock(DateTimeOffset.UtcNow.AddMinutes(-20)));
var token = issuedLongAgo.GenerateToken("U", "u", [Roles.Administrator], null);
var (context, _) = ContextWithAuthorization($"Bearer {token}");
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
}
/// <summary>A token whose payload was edited (privilege escalation attempt) fails the signature check.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TamperedBearer_Is401()
{
var token = Jwt().GenerateToken("V", "viewer", [Roles.Viewer], null);
var parts = token.Split('.');
var payload = Encoding.UTF8.GetString(Base64UrlDecode(parts[1]))
.Replace(Roles.Viewer, Roles.Administrator, StringComparison.Ordinal);
var tampered = $"{parts[0]}.{Base64UrlEncode(Encoding.UTF8.GetBytes(payload))}.{parts[2]}";
var (context, _) = ContextWithAuthorization($"Bearer {tampered}");
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
}
/// <summary>A token signed with a different key (another environment) is rejected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WrongKeyBearer_Is401()
{
var token = Jwt("a-completely-different-signing-key-for-hmac-sha256-validation")
.GenerateToken("A", "admin", [Roles.Administrator], null);
var (context, _) = ContextWithAuthorization($"Bearer {token}");
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
}
/// <summary>An unsigned <c>alg=none</c> token is rejected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnsignedAlgNoneBearer_Is401()
{
var unsigned = new JwtSecurityToken(
issuer: JwtTokenService.TokenIssuer,
audience: JwtTokenService.TokenAudience,
claims: [new Claim(JwtTokenService.UsernameClaimType, "admin"),
new Claim(JwtTokenService.RoleClaimType, Roles.Administrator)],
expires: DateTime.UtcNow.AddMinutes(10));
var handler = new JwtSecurityTokenHandler();
handler.OutboundClaimTypeMap.Clear();
var token = handler.WriteToken(unsigned); // header {"alg":"none"}, empty signature
var (context, _) = ContextWithAuthorization($"Bearer {token}");
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
}
/// <summary>Garbage after <c>Bearer</c> is a 401, not an exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GarbageBearer_Is401()
{
var (context, _) = ContextWithAuthorization("Bearer not-a-jwt");
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
}
private static byte[] Base64UrlDecode(string s)
{
var padded = s.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
return Convert.FromBase64String(padded);
}
private static string Base64UrlEncode(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -82,7 +82,6 @@ public class CookieSessionValidatorTests
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5,
RoleRefreshThresholdMinutes = 15,
};
@@ -0,0 +1,249 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
/// <summary>
/// <see cref="RoleMappingCache"/> + <see cref="RoleMapper"/> (review 2026-10-21 05/P1, 07/P-3):
/// a fresh snapshot serves mappings with no repository reads, it expires after
/// <see cref="SecurityOptions.RoleMappingCacheSeconds"/>, <see cref="RoleMappingCache.Invalidate"/>
/// forces a re-read, and the scope rules are resolved once per refresh.
/// </summary>
public class RoleMappingCacheTests
{
private sealed class MutableClock : TimeProvider
{
public DateTimeOffset Now { get; set; } = new(2026, 10, 22, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => Now;
}
// Counts the two reads RoleMapper uses; every other member is unused here.
private sealed class CountingRepository : ISecurityRepository
{
public List<LdapGroupMapping> Mappings { get; } = [];
public Dictionary<int, IReadOnlyList<SiteScopeRule>> ScopeRules { get; } = [];
public int MappingReads { get; private set; }
public int ScopeReads { get; private set; }
/// <inheritdoc />
public Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
{
MappingReads++;
return Task.FromResult<IReadOnlyList<LdapGroupMapping>>(Mappings.ToList());
}
/// <inheritdoc />
public Task<IReadOnlyList<SiteScopeRule>> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default)
{
ScopeReads++;
return Task.FromResult(ScopeRules.TryGetValue(ldapGroupMappingId, out var rules)
? rules
: (IReadOnlyList<SiteScopeRule>)Array.Empty<SiteScopeRule>());
}
/// <inheritdoc />
public Task<LdapGroupMapping?> GetMappingByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<LdapGroupMapping>> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <inheritdoc />
public Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <inheritdoc />
public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <inheritdoc />
public Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <inheritdoc />
public Task<SiteScopeRule?> GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <inheritdoc />
public Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <inheritdoc />
public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <inheritdoc />
public Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class StaticMonitor(SecurityOptions value) : IOptionsMonitor<SecurityOptions>
{
public SecurityOptions CurrentValue => value;
public SecurityOptions Get(string? name) => value;
public IDisposable? OnChange(Action<SecurityOptions, string?> listener) => null;
}
private static LdapGroupMapping Mapping(int id, string group, string role) => new(group, role) { Id = id };
private static (CountingRepository Repo, MutableClock Clock, RoleMappingCache Cache, RoleMapper Mapper) Create(int ttlSeconds = 30)
{
var repo = new CountingRepository();
repo.Mappings.Add(Mapping(1, "Admins", Roles.Administrator));
repo.Mappings.Add(Mapping(2, "SiteDeployers", Roles.Deployer));
repo.ScopeRules[2] = [new SiteScopeRule { LdapGroupMappingId = 2, SiteId = 5 }];
var clock = new MutableClock();
var cache = new RoleMappingCache(clock, new StaticMonitor(new SecurityOptions { RoleMappingCacheSeconds = ttlSeconds }));
return (repo, clock, cache, new RoleMapper(repo, cache));
}
/// <summary>A second mapping within the TTL reads nothing from the repository.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SecondMapping_WithinTtl_IsServedFromCache()
{
var (repo, _, _, mapper) = Create();
var first = await mapper.MapGroupsToRolesAsync(["SiteDeployers"]);
var second = await mapper.MapGroupsToRolesAsync(["SiteDeployers", "Admins"]);
Assert.Equal(1, repo.MappingReads);
Assert.Equal(1, repo.ScopeReads); // one Deployer mapping, read once per refresh
Assert.Equal(new[] { "5" }, first.PermittedSiteIds);
Assert.Contains(Roles.Administrator, second.Roles);
Assert.Contains(Roles.Deployer, second.Roles);
}
/// <summary>The snapshot expires after the TTL and the next mapping re-reads the table.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Mapping_AfterTtl_RereadsRepository()
{
var (repo, clock, _, mapper) = Create(ttlSeconds: 30);
await mapper.MapGroupsToRolesAsync(["Admins"]);
repo.Mappings.Add(Mapping(3, "Designers", Roles.Designer));
clock.Now = clock.Now.AddSeconds(31);
var result = await mapper.MapGroupsToRolesAsync(["Designers"]);
Assert.Equal(2, repo.MappingReads);
Assert.Contains(Roles.Designer, result.Roles);
}
/// <summary>Invalidate drops the snapshot immediately, so a revocation is seen on the next mapping.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Invalidate_ForcesReread_RevocationVisibleImmediately()
{
var (repo, _, cache, mapper) = Create();
Assert.Contains(Roles.Administrator, (await mapper.MapGroupsToRolesAsync(["Admins"])).Roles);
repo.Mappings.RemoveAll(m => m.Id == 1);
cache.Invalidate();
var after = await mapper.MapGroupsToRolesAsync(["Admins"]);
Assert.Equal(2, repo.MappingReads);
Assert.Empty(after.Roles);
}
/// <summary>A zero TTL disables caching entirely.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ZeroTtl_DisablesCache()
{
var (repo, _, _, mapper) = Create(ttlSeconds: 0);
await mapper.MapGroupsToRolesAsync(["Admins"]);
await mapper.MapGroupsToRolesAsync(["Admins"]);
Assert.Equal(2, repo.MappingReads);
}
/// <summary>The cache is shared by mappers built over different (scoped) repositories.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Cache_IsSharedAcrossScopedMappers()
{
var (repo, _, cache, mapper) = Create();
await mapper.MapGroupsToRolesAsync(["Admins"]);
var otherScopeRepo = new CountingRepository();
var result = await new RoleMapper(otherScopeRepo, cache).MapGroupsToRolesAsync(["Admins"]);
Assert.Equal(0, otherScopeRepo.MappingReads);
Assert.Contains(Roles.Administrator, result.Roles);
Assert.Equal(1, repo.MappingReads);
}
}
/// <summary>
/// Bearer-token hardening of <see cref="JwtTokenService"/> (review 2026-10-21 05/U1): lifetime
/// comes from <see cref="SecurityOptions.JwtExpiryMinutes"/>, expired and unsigned tokens are
/// rejected.
/// </summary>
public class JwtBearerHardeningTests
{
private const string Key = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough";
private sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private static JwtTokenService Service(int expiryMinutes = 15, TimeProvider? clock = null) =>
new(Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = expiryMinutes }),
NullLogger<JwtTokenService>.Instance, clock);
/// <summary>The token's exp claim is JwtExpiryMinutes after issuance.</summary>
[Fact]
public void GenerateToken_ExpiryHonoursJwtExpiryMinutes()
{
var issuedAt = new DateTimeOffset(2026, 10, 22, 12, 0, 0, TimeSpan.Zero);
var token = Service(expiryMinutes: 7, clock: new FixedClock(issuedAt))
.GenerateToken("U", "u", [Roles.Viewer], null);
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
Assert.Equal(issuedAt.UtcDateTime.AddMinutes(7), jwt.ValidTo);
}
/// <summary>A token past its expiry (beyond the small skew) is rejected.</summary>
[Fact]
public void ValidateToken_Expired_ReturnsNull()
{
var token = Service(clock: new FixedClock(DateTimeOffset.UtcNow.AddMinutes(-16)))
.GenerateToken("U", "u", [Roles.Viewer], null);
Assert.Null(Service().ValidateToken(token));
}
/// <summary>An alg=none token carrying our issuer/audience is rejected.</summary>
[Fact]
public void ValidateToken_AlgNone_ReturnsNull()
{
var unsigned = new JwtSecurityToken(
issuer: JwtTokenService.TokenIssuer,
audience: JwtTokenService.TokenAudience,
claims: [new Claim(JwtTokenService.UsernameClaimType, "admin")],
expires: DateTime.UtcNow.AddMinutes(10));
Assert.Null(Service().ValidateToken(new JwtSecurityTokenHandler().WriteToken(unsigned)));
}
/// <summary>A blank token is rejected without throwing.</summary>
[Fact]
public void ValidateToken_Blank_ReturnsNull()
{
Assert.Null(Service().ValidateToken(" "));
}
/// <summary>A non-positive JwtExpiryMinutes fails options validation.</summary>
[Fact]
public void Validator_NonPositiveExpiry_Fails()
{
var result = new SecurityOptionsValidator().Validate(null, new SecurityOptions { JwtExpiryMinutes = 0 });
Assert.True(result.Failed);
}
/// <summary>A negative RoleMappingCacheSeconds fails options validation.</summary>
[Fact]
public void Validator_NegativeRoleMappingCacheSeconds_Fails()
{
var result = new SecurityOptionsValidator().Validate(null, new SecurityOptions { RoleMappingCacheSeconds = -1 });
Assert.True(result.Failed);
}
}
@@ -110,8 +110,7 @@ public class JwtTokenServiceTests
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
};
private static JwtTokenService CreateService(SecurityOptions? options = null)
@@ -145,7 +144,8 @@ public class JwtTokenServiceTests
Assert.Contains("1", siteIds);
Assert.Contains("2", siteIds);
Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType));
// Bearer tokens carry no idle anchor — the idle window is a cookie-session concept.
Assert.Null(principal.FindFirst(JwtTokenService.LastActivityClaimType));
}
/// <summary>Verifies the case: generate Token, null Site Ids, no Site Id Claims.</summary>
@@ -180,8 +180,7 @@ public class JwtTokenServiceTests
{
JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
});
var result = service2.ValidateToken(token);
Assert.Null(result);
@@ -201,81 +200,6 @@ public class JwtTokenServiceTests
Assert.Contains("HS256", headerJson);
}
/// <summary>Verifies the case: should Refresh, token Near Expiry, returns True.</summary>
[Fact]
public void ShouldRefresh_TokenNearExpiry_ReturnsTrue()
{
var options = CreateOptions();
options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min
var service = CreateService(options);
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token);
Assert.True(service.ShouldRefresh(principal!));
}
/// <summary>Verifies the case: should Refresh, token Far From Expiry, returns False.</summary>
[Fact]
public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse()
{
var service = CreateService(); // 15 min expiry, 5 min threshold
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token);
Assert.False(service.ShouldRefresh(principal!));
}
/// <summary>Verifies the case: is Idle Timed Out, recent Activity, returns False.</summary>
[Fact]
public void IsIdleTimedOut_RecentActivity_ReturnsFalse()
{
var service = CreateService();
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token);
Assert.False(service.IsIdleTimedOut(principal!));
}
/// <summary>Verifies the case: is Idle Timed Out, no Last Activity Claim, returns True.</summary>
[Fact]
public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue()
{
var service = CreateService();
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User")
}));
Assert.True(service.IsIdleTimedOut(principal));
}
/// <summary>Verifies the case: refresh Token, returns New Token With Updated Claims.</summary>
[Fact]
public void RefreshToken_ReturnsNewTokenWithUpdatedClaims()
{
var service = CreateService();
var originalToken = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(originalToken);
var newToken = service.RefreshToken(principal!, new[] { "Administrator", "Designer" }, new[] { "1" });
Assert.NotNull(newToken);
var newPrincipal = service.ValidateToken(newToken!);
var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
Assert.Contains("Designer", roles);
}
/// <summary>Verifies the case: refresh Token, missing Claims, returns Null.</summary>
[Fact]
public void RefreshToken_MissingClaims_ReturnsNull()
{
var service = CreateService();
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var result = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.Null(result);
}
}
#endregion
@@ -441,8 +365,7 @@ public class SecurityReviewRegressionTests
{
JwtSigningKey = signingKey,
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
};
/// <summary>Verifies the case: jwt Token Service, empty Signing Key, throws At Construction.</summary>
@@ -716,8 +639,7 @@ public class SecurityReviewRegressionTests2
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
};
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
@@ -768,87 +690,6 @@ public class SecurityReviewRegressionTests2
Assert.NotNull(service.ValidateToken(token));
}
// --- refresh must preserve the original LastActivity timestamp ---
/// <summary>Verifies the case: refresh Token, preserves Original Last Activity Claim.</summary>
[Fact]
public void RefreshToken_PreservesOriginalLastActivityClaim()
{
var service = CreateJwtService();
// Mint a token whose LastActivity is 20 minutes in the past (still inside the
// 30-minute idle window). Refresh must NOT move it forward to "now".
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.NotNull(refreshed);
var refreshedPrincipal = service.ValidateToken(refreshed!);
Assert.NotNull(refreshedPrincipal);
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
Assert.NotNull(claim);
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
// The carried timestamp must equal the original, not "now".
Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2,
$"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}");
}
/// <summary>Verifies the case: refresh Token, does Not Reset Idle Timeout When User Is Actually Idle.</summary>
[Fact]
public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle()
{
// A user idle for 25 of the 30-minute window: a refresh fired by some background
// request must not make IsIdleTimedOut flip back to false forever.
var service = CreateJwtService();
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
var refreshedPrincipal = service.ValidateToken(refreshed!);
// Still 25 min idle after refresh — not reset to 0.
Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20,
"Refresh wrongly reset the idle clock to ~now");
}
/// <summary>Verifies the case: record Activity, updates Last Activity To Now.</summary>
[Fact]
public void RecordActivity_UpdatesLastActivityToNow()
{
// Genuine user activity (a real request) — distinct from a token refresh —
// updates LastActivity to the current time.
var service = CreateJwtService();
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
}, "test"));
var touched = service.RecordActivity(principal, new[] { "Administrator" }, null);
Assert.NotNull(touched);
var touchedPrincipal = service.ValidateToken(touched!);
var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated));
Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5,
"RecordActivity should set LastActivity to ~now");
}
}
#endregion
@@ -917,98 +758,6 @@ public class SecurityReviewRegressionTests3
#endregion
#region Code Review Regression Tests — Security-012/013/014/015
/// <summary>
/// Regression tests for <c>RefreshToken</c> re-issuing a token without
/// checking the idle timeout, so an idle-expired session can be renewed indefinitely).
/// </summary>
/// <remarks>
/// Cutover: the former <c>ExtractFirstRdnValue</c> escaped-comma
/// parsing) and username trim before use cases tested ScadaBridge's
/// bespoke <c>LdapAuthService.ExtractFirstRdnValue</c> / <c>NormalizeUsername</c> /
/// <c>BuildFallbackUserDn</c> statics. Those behaviours are now owned by the shared
/// library and covered by its own tests — <c>LdapEscapingTests.FirstRdnValue_IsEscapeAware</c>
/// (escaped-comma RDN extraction) and <c>LdapAuthServiceFailureTests</c>
/// (whitespace-username → BadCredentials, no connection) — so they were removed here.
/// </remarks>
public class SecurityReviewRegressionTests4
{
private static SecurityOptions JwtOptions() => new()
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
};
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance);
// --- RefreshToken must reject an idle-expired principal ---
/// <summary>Verifies the case: refresh Token, idle Expired Principal, returns Null.</summary>
[Fact]
public void RefreshToken_IdleExpiredPrincipal_ReturnsNull()
{
// A user idle for 31 minutes (past the 30-minute idle window). Even though the
// DisplayName/Username claims are present, the refresh must be refused so an
// idle-expired session cannot be renewed by a background request.
var service = CreateJwtService();
var idleActivity = DateTimeOffset.UtcNow.AddMinutes(-31);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.Null(refreshed);
}
/// <summary>Verifies the case: refresh Token, active Session Within Idle Window, still Refreshes.</summary>
[Fact]
public void RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes()
{
// A user idle for 10 minutes (well inside the 30-minute window): refresh must
// still succeed — the idle-timeout guard must not break the normal sliding path.
var service = CreateJwtService();
var recentActivity = DateTimeOffset.UtcNow.AddMinutes(-10);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.NotNull(refreshed);
}
/// <summary>Verifies the case: refresh Token, missing Last Activity Claim, returns Null.</summary>
[Fact]
public void RefreshToken_MissingLastActivityClaim_ReturnsNull()
{
// No LastActivity claim — IsIdleTimedOut treats this as timed out, so the
// refresh must be refused rather than minting a fresh session out of nothing.
var service = CreateJwtService();
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user")
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.Null(refreshed);
}
}
#endregion
#region Code Review Regression Tests — Security-012 (partial LDAP outage)
/// <summary>
@@ -1580,8 +1329,7 @@ public class CanonicalClaimVocabularyTests
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
};
private static JwtTokenService CreateService() =>