1b3928a0a2
- 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.
110 lines
5.1 KiB
C#
110 lines
5.1 KiB
C#
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 _));
|
|
}
|
|
}
|