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:
@@ -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() =>
|
||||
|
||||
Reference in New Issue
Block a user