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; /// /// Bearer-token branch of (review 2026-10-21 05/U1, 05/P1): /// a JWT minted by /auth/token authenticates the management/audit/debug surfaces without /// an LDAP bind, and every malformed, expired, tampered, foreign-key or unsigned token is a 401. /// 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.Instance, clock); private static (HttpContext Context, ILdapAuthService Ldap) ContextWithAuthorization(string authorization, string key = Key) { var ldap = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton>( Options.Create(new AuthDisableLoginOptions { DisableLogin = false })); services.AddSingleton>( Options.Create(new SecurityOptions { JwtSigningKey = key, JwtExpiryMinutes = 15 })); services.AddLogging(); services.AddSingleton(ldap); services.AddScoped(); var context = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; context.Request.Headers.Authorization = authorization; return (context, ldap); } /// A valid token yields its username, roles and site scope with no LDAP bind. /// A task that represents the asynchronous operation. [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); } /// A token minted without site ids is system-wide (empty permitted set), like the Basic path. /// A task that represents the asynchronous operation. [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); } /// An expired token (past the 30 s skew) is rejected. /// A task that represents the asynchronous operation. [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); } /// A token whose payload was edited (privilege escalation attempt) fails the signature check. /// A task that represents the asynchronous operation. [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); } /// A token signed with a different key (another environment) is rejected. /// A task that represents the asynchronous operation. [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); } /// An unsigned alg=none token is rejected. /// A task that represents the asynchronous operation. [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); } /// Garbage after Bearer is a 401, not an exception. /// A task that represents the asynchronous operation. [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); } /// A token signed with the right key but HS512 is rejected: only HS256 is accepted. /// A task that represents the asynchronous operation. [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); } /// The same key and algorithm with a hand-minted HS256 token is accepted (control for the cases below). /// A task that represents the asynchronous operation. [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); } /// A correctly signed token for another issuer or audience is rejected. /// The token's issuer. /// The token's audience. /// A task that represents the asynchronous operation. [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); } /// /// 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). /// /// A task that represents the asynchronous operation. [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('/', '_'); }