Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorBearerTests.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

175 lines
7.6 KiB
C#

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('/', '_');
}