ea172edebc
- JwtTokenService back-dates nbf by the 30 s validation skew so a peer central whose clock lags the issuer accepts a fresh token after failover. - RoleMappingCacheSeconds = 0 routes RoleMapper to the matched-rows loader (old cost) instead of loading every Deployer mapping's scope rules per call. - RoleMappingCache / SecurityOptions / Component-Security.md describe Invalidate() by its contract plus the TTL, not by a call site. - Tests: HS512 with the same key, issuer/audience mismatch, lagging-peer nbf, TTL-0 matched-only reads, refresh-vs-Invalidate generation race.
259 lines
12 KiB
C#
259 lines
12 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, string key = Key)
|
|
{
|
|
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 string HandMinted(string key, string algorithm, string issuer, string audience)
|
|
{
|
|
var credentials = new Microsoft.IdentityModel.Tokens.SigningCredentials(
|
|
new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), algorithm);
|
|
var jwt = new JwtSecurityToken(
|
|
issuer: issuer,
|
|
audience: audience,
|
|
claims: [new Claim(JwtTokenService.UsernameClaimType, "admin"),
|
|
new Claim(JwtTokenService.RoleClaimType, Roles.Administrator)],
|
|
expires: DateTime.UtcNow.AddMinutes(10),
|
|
signingCredentials: credentials);
|
|
var handler = new JwtSecurityTokenHandler();
|
|
handler.OutboundClaimTypeMap.Clear();
|
|
return handler.WriteToken(jwt);
|
|
}
|
|
|
|
/// <summary>A token signed with the right key but HS512 is rejected: only HS256 is accepted.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Hs512WithSameKey_Is401()
|
|
{
|
|
// 64+ bytes so HS512 itself is satisfied; only the algorithm allow-list can reject it.
|
|
var longKey = new string('k', 80);
|
|
var token = HandMinted(longKey, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha512,
|
|
JwtTokenService.TokenIssuer, JwtTokenService.TokenAudience);
|
|
var (context, _) = ContextWithAuthorization($"Bearer {token}", longKey);
|
|
|
|
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
|
|
|
|
Assert.Null(outcome.User);
|
|
Assert.NotNull(outcome.Failure);
|
|
}
|
|
|
|
/// <summary>The same key and algorithm with a hand-minted HS256 token is accepted (control for the cases below).</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task HandMintedHs256_OwnIssuerAndAudience_IsAccepted()
|
|
{
|
|
var token = HandMinted(Key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256,
|
|
JwtTokenService.TokenIssuer, JwtTokenService.TokenAudience);
|
|
var (context, _) = ContextWithAuthorization($"Bearer {token}");
|
|
|
|
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
|
|
|
|
Assert.NotNull(outcome.User);
|
|
}
|
|
|
|
/// <summary>A correctly signed token for another issuer or audience is rejected.</summary>
|
|
/// <param name="issuer">The token's issuer.</param>
|
|
/// <param name="audience">The token's audience.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData("some-other-system", JwtTokenService.TokenAudience)]
|
|
[InlineData(JwtTokenService.TokenIssuer, "some-other-audience")]
|
|
public async Task IssuerOrAudienceMismatch_Is401(string issuer, string audience)
|
|
{
|
|
var token = HandMinted(Key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256, issuer, audience);
|
|
var (context, _) = ContextWithAuthorization($"Bearer {token}");
|
|
|
|
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
|
|
|
|
Assert.Null(outcome.User);
|
|
Assert.NotNull(outcome.Failure);
|
|
}
|
|
|
|
/// <summary>
|
|
/// nbf is back-dated by the validation skew, so a peer node whose clock lags the issuer by
|
|
/// up to twice the skew accepts a fresh token (review L1).
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FreshToken_FromIssuerAhead_IsAcceptedByLaggingPeer()
|
|
{
|
|
// Issuer clock 45 s ahead of this (validating) node — beyond the 30 s skew alone, within
|
|
// skew + the nbf back-dating.
|
|
var token = Jwt(clock: new FixedClock(DateTimeOffset.UtcNow.AddSeconds(45)))
|
|
.GenerateToken("A", "admin", [Roles.Administrator], null);
|
|
var (context, _) = ContextWithAuthorization($"Bearer {token}");
|
|
|
|
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
|
|
|
|
Assert.NotNull(outcome.User);
|
|
}
|
|
|
|
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('/', '_');
|
|
}
|