8fe7f46df6
Spike outcome: the shared ILdapAuthService (ZB.MOM.WW.Auth.Abstractions, an external
NuGet package) exposes ONLY AuthenticateAsync(username, password, ct) — no passwordless
service-account group-search. A live LDAP group re-query for an active session therefore
requires a new lib method and is OUT OF SCOPE (cannot modify the external package).
Implemented the always-achievable layers (cookie-only; no embedded JWT for cookie principals):
- /auth/login now stores the user's raw LDAP groups (one zb:group claim each) plus a
zb:lastrolerefresh anchor (login time, UTC), seeding the LastActivity idle anchor too.
- SessionClaimBuilder: single shared DRY claim-builder used by BOTH /auth/login AND the
refresh path, so the two claim shapes cannot drift (canonical identity/role/scope claims
with nameType/roleType pinned, plus the M2.19 group + refresh-anchor additions).
- CookieSessionValidator (TimeProvider-injected, unit-testable) + a thin
CookieAuthenticationEvents.OnValidatePrincipal adapter:
* idle-timeout: a session past IdleTimeoutMinutes (default 30) is RejectPrincipal+SignOut;
consistent with the cookie ExpireTimeSpan+SlidingExpiration window (same value).
* role refresh WITHOUT LDAP: when older than RoleRefreshThresholdMinutes (new option,
default 15) the DB-backed RoleMapper re-runs on the STORED groups, claims are rebuilt
via the shared builder, the anchor advances, principal is replaced + cookie renewed.
Revoked DB mappings drop the user's roles mid-session.
* fail-soft: any refresh error KEEPS the existing principal (no sign-out, never throws)
— mirrors the documented "LDAP failure: active sessions continue with current roles".
- Documented residual limitation in Component-Security.md: central role-mapping/scope
changes apply within ~15 min without LDAP; live directory group-membership changes are
picked up only at next login (needs a passwordless group-search on the external
ZB.MOM.WW.Auth.Ldap lib — tracked follow-up).
Tests (Security.Tests, all green): CookieSessionValidatorTests + SessionClaimBuilderParityTests
— idle reject/keep, LDAP-free remap-from-stored-groups, revoked-roles loss, sub-threshold
no-refresh, refresh-throws-keeps-session, and login/refresh claim-parity.
363 lines
17 KiB
C#
363 lines
17 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
|
|
|
|
#region M2.19 (#15): cookie session idle-timeout + LDAP-free role-mapping refresh
|
|
|
|
/// <summary>
|
|
/// Tests for the cookie <c>OnValidatePrincipal</c> core (<see cref="CookieSessionValidator"/>)
|
|
/// and the shared <see cref="SessionClaimBuilder"/>. These pin the always-achievable M2.19
|
|
/// layers: idle-timeout enforcement, mid-session role-mapping refresh from STORED groups
|
|
/// WITHOUT any LDAP call, the fail-soft refresh policy (errors keep the session), and claim
|
|
/// parity between the login path and the refresh path.
|
|
/// </summary>
|
|
public class CookieSessionValidatorTests
|
|
{
|
|
private static readonly DateTimeOffset Start = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
// A controllable clock (the central package list has no FakeTimeProvider, mirroring
|
|
// the Transport tests' hand-rolled TestTimeProvider).
|
|
private sealed class TestTimeProvider : TimeProvider
|
|
{
|
|
private DateTimeOffset _now;
|
|
public TestTimeProvider(DateTimeOffset start) => _now = start;
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
|
|
// A fake IGroupRoleMapper<string> that returns a programmable RoleMappingResult and
|
|
// counts how many times it was invoked — so a test can assert NO LDAP call happens
|
|
// (this mapper is DB-backed only; if it is hit, the result came from the DB seam, not
|
|
// LDAP) and that the refresh actually re-ran the mapping.
|
|
private sealed class FakeGroupRoleMapper : IGroupRoleMapper<string>
|
|
{
|
|
private RoleMappingResult _result;
|
|
private readonly Exception? _throw;
|
|
public int CallCount { get; private set; }
|
|
public IReadOnlyList<string>? LastGroups { get; private set; }
|
|
|
|
public FakeGroupRoleMapper(RoleMappingResult result) => _result = result;
|
|
public FakeGroupRoleMapper(Exception toThrow) => (_result, _throw) =
|
|
(new RoleMappingResult([], [], false), toThrow);
|
|
|
|
public void SetResult(RoleMappingResult result) => _result = result;
|
|
|
|
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
|
|
{
|
|
CallCount++;
|
|
LastGroups = groups;
|
|
if (_throw is not null) throw _throw;
|
|
return Task.FromResult(new GroupRoleMapping<string>(_result.Roles, Scope: _result));
|
|
}
|
|
}
|
|
|
|
private static SecurityOptions Options() => new()
|
|
{
|
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5,
|
|
RoleRefreshThresholdMinutes = 15,
|
|
};
|
|
|
|
private static CookieSessionValidator CreateValidator(
|
|
IGroupRoleMapper<string> mapper, TimeProvider clock, SecurityOptions? options = null) =>
|
|
new(mapper, Microsoft.Extensions.Options.Options.Create(options ?? Options()), clock,
|
|
NullLogger<CookieSessionValidator>.Instance);
|
|
|
|
// Build a session the way login does (via SessionClaimBuilder), anchored at `at`.
|
|
private static ClaimsPrincipal Session(
|
|
DateTimeOffset at,
|
|
IReadOnlyList<string> groups,
|
|
RoleMappingResult mapping) =>
|
|
SessionClaimBuilder.Build("alice", "Alice", groups, mapping, at);
|
|
|
|
// --- Idle timeout ---
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_PastIdleTimeout_Rejects()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Administrator], [], true));
|
|
var sut = CreateValidator(mapper, clock);
|
|
var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true));
|
|
|
|
// 31 minutes idle (> 30-minute IdleTimeoutMinutes).
|
|
clock.Advance(TimeSpan.FromMinutes(31));
|
|
var result = await sut.ValidateAsync(principal);
|
|
|
|
Assert.Equal(SessionValidationAction.Reject, result.Action);
|
|
// An idle-timed-out session must never be refreshed instead of rejected.
|
|
Assert.Equal(0, mapper.CallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_WithinIdleWindow_KeptAndNotRejected()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Administrator], [], true));
|
|
var sut = CreateValidator(mapper, clock);
|
|
var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true));
|
|
|
|
// 10 minutes idle — well inside the 30-minute window, and BEFORE the 15-minute
|
|
// role-refresh threshold, so the session is simply kept.
|
|
clock.Advance(TimeSpan.FromMinutes(10));
|
|
var result = await sut.ValidateAsync(principal);
|
|
|
|
Assert.Equal(SessionValidationAction.Keep, result.Action);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_MissingLastActivityClaim_TreatedAsIdleTimedOut()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Administrator], [], true));
|
|
var sut = CreateValidator(mapper, clock);
|
|
|
|
// A principal with no LastActivity anchor: fail-closed to rejected.
|
|
var identity = new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(ClaimTypes.Name, "alice"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "alice"),
|
|
new Claim(JwtTokenService.RoleClaimType, Roles.Administrator),
|
|
}, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
|
|
var result = await sut.ValidateAsync(principal);
|
|
|
|
Assert.Equal(SessionValidationAction.Reject, result.Action);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_UnauthenticatedPrincipal_Kept()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
var mapper = new FakeGroupRoleMapper(new RoleMappingResult([], [], false));
|
|
var sut = CreateValidator(mapper, clock);
|
|
|
|
Assert.Equal(SessionValidationAction.Keep, (await sut.ValidateAsync(null)).Action);
|
|
Assert.Equal(SessionValidationAction.Keep,
|
|
(await sut.ValidateAsync(new ClaimsPrincipal(new ClaimsIdentity()))).Action);
|
|
}
|
|
|
|
// --- Role-mapping refresh WITHOUT LDAP ---
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_AfterRoleRefreshThreshold_RemapsFromStoredGroups_NoLdap()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
// The user logged in as a system-wide Administrator.
|
|
var principal = Session(Start, ["SCADA-Admins"],
|
|
new RoleMappingResult([Roles.Administrator], [], true));
|
|
|
|
// The DB mapping CHANGED mid-session: the same groups now map to Viewer + site-scoped.
|
|
var mapper = new FakeGroupRoleMapper(
|
|
new RoleMappingResult([Roles.Viewer], ["7"], IsSystemWideDeployment: false));
|
|
var sut = CreateValidator(mapper, clock);
|
|
|
|
// Past the 15-minute role-refresh threshold (still inside the 30-minute idle window).
|
|
clock.Advance(TimeSpan.FromMinutes(16));
|
|
var result = await sut.ValidateAsync(principal);
|
|
|
|
Assert.Equal(SessionValidationAction.Replace, result.Action);
|
|
Assert.NotNull(result.Principal);
|
|
|
|
// The mapper was invoked with EXACTLY the STORED groups — no LDAP re-query.
|
|
Assert.Equal(1, mapper.CallCount);
|
|
Assert.Equal(new[] { "SCADA-Admins" }, mapper.LastGroups);
|
|
|
|
// The rebuilt principal reflects the NEW DB mapping.
|
|
var newRoles = result.Principal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
|
Assert.Contains(Roles.Viewer, newRoles);
|
|
Assert.DoesNotContain(Roles.Administrator, newRoles);
|
|
Assert.Equal("7", result.Principal.FindFirst(JwtTokenService.SiteIdClaimType)?.Value);
|
|
|
|
// The role-refresh anchor was advanced to now.
|
|
var refreshClaim = result.Principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType);
|
|
Assert.NotNull(refreshClaim);
|
|
Assert.True(DateTimeOffset.TryParse(refreshClaim!.Value, out var refreshedAt));
|
|
Assert.Equal(clock.GetUtcNow(), refreshedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_RevokedGroupUser_LosesRolesOnRefresh()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
var principal = Session(Start, ["SCADA-Admins"],
|
|
new RoleMappingResult([Roles.Administrator], [], true));
|
|
|
|
// The group's mapping was deleted in the DB: the same groups now map to NOTHING.
|
|
var mapper = new FakeGroupRoleMapper(new RoleMappingResult([], [], IsSystemWideDeployment: false));
|
|
var sut = CreateValidator(mapper, clock);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(16));
|
|
var result = await sut.ValidateAsync(principal);
|
|
|
|
Assert.Equal(SessionValidationAction.Replace, result.Action);
|
|
// The user has lost ALL roles mid-session — every authorization policy will now deny.
|
|
Assert.Empty(result.Principal!.FindAll(JwtTokenService.RoleClaimType));
|
|
Assert.Empty(result.Principal.FindAll(JwtTokenService.SiteIdClaimType));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_BeforeRoleRefreshThreshold_DoesNotRefresh()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
var principal = Session(Start, ["SCADA-Admins"],
|
|
new RoleMappingResult([Roles.Administrator], [], true));
|
|
|
|
// Even though the DB mapping would now return Viewer, a refresh BEFORE the
|
|
// 15-minute threshold must not happen — claims stay as they were at login.
|
|
var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Viewer], [], false));
|
|
var sut = CreateValidator(mapper, clock);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(14)); // < 15-minute threshold
|
|
var result = await sut.ValidateAsync(principal);
|
|
|
|
Assert.Equal(SessionValidationAction.Keep, result.Action);
|
|
Assert.Equal(0, mapper.CallCount); // no re-map at all
|
|
}
|
|
|
|
// --- Failure policy: a refresh exception keeps the existing principal ---
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_RefreshThrows_KeepsExistingPrincipal_NoSignOut_NoThrow()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
var principal = Session(Start, ["SCADA-Admins"],
|
|
new RoleMappingResult([Roles.Administrator], [], true));
|
|
|
|
// The DB-backed mapper faults (e.g. SQL unreachable) DURING a due refresh.
|
|
var mapper = new FakeGroupRoleMapper(new InvalidOperationException("db down"));
|
|
var sut = CreateValidator(mapper, clock);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(16)); // refresh IS due
|
|
var result = await sut.ValidateAsync(principal); // must NOT throw
|
|
|
|
// The session is KEPT (current roles preserved) — never rejected, never broadened.
|
|
Assert.Equal(SessionValidationAction.Keep, result.Action);
|
|
Assert.Equal(1, mapper.CallCount);
|
|
}
|
|
|
|
// --- IsIdleTimedOut / IsRoleRefreshDue boundaries ---
|
|
|
|
[Fact]
|
|
public void IsIdleTimedOut_AtBoundary_NotTimedOut()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock);
|
|
var principal = Session(Start, [], new RoleMappingResult([], [], false));
|
|
|
|
// Exactly 30 minutes — NOT strictly greater, so not timed out.
|
|
Assert.False(sut.IsIdleTimedOut(principal, Start.AddMinutes(30)));
|
|
Assert.True(sut.IsIdleTimedOut(principal, Start.AddMinutes(30).AddSeconds(1)));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsRoleRefreshDue_AtBoundary_NotDue()
|
|
{
|
|
var clock = new TestTimeProvider(Start);
|
|
var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock);
|
|
var principal = Session(Start, [], new RoleMappingResult([], [], false));
|
|
|
|
Assert.False(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15)));
|
|
Assert.True(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15).AddSeconds(1)));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Claim-parity tests: the shared <see cref="SessionClaimBuilder"/> must produce EXACTLY
|
|
/// the claim shape the login endpoint historically minted (plus the M2.19 group +
|
|
/// refresh-anchor additions), and the refresh path — which also goes through the builder —
|
|
/// must therefore be identical. This is the load-bearing anti-drift guarantee.
|
|
/// </summary>
|
|
public class SessionClaimBuilderParityTests
|
|
{
|
|
private static readonly DateTimeOffset At = new(2026, 6, 15, 9, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public void Build_ProducesCanonicalIdentityRoleAndScopeClaims_LikeLogin()
|
|
{
|
|
var mapping = new RoleMappingResult(
|
|
[Roles.Administrator, Roles.Designer], ["3", "4"], IsSystemWideDeployment: false);
|
|
|
|
var principal = SessionClaimBuilder.Build("janer", "Jane Roe", ["G1", "G2"], mapping, At);
|
|
|
|
// Identity claims — identical to the historical /auth/login set.
|
|
Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name
|
|
Assert.Equal("Jane Roe", principal.FindFirst(ZbClaimTypes.DisplayName)?.Value);
|
|
Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value);
|
|
|
|
// Roles — one per mapped role, canonical type, and IsInRole resolves.
|
|
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
|
Assert.Equal(new[] { Roles.Administrator, Roles.Designer }.OrderBy(r => r), roles.OrderBy(r => r));
|
|
Assert.True(principal.IsInRole(Roles.Administrator));
|
|
|
|
// Scope — one SiteId per permitted site (non-system-wide).
|
|
var sites = principal.FindAll(ZbClaimTypes.ScopeId).Select(c => c.Value).ToList();
|
|
Assert.Equal(new[] { "3", "4" }.OrderBy(s => s), sites.OrderBy(s => s));
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_SystemWideMapping_OmitsSiteIdClaims()
|
|
{
|
|
var mapping = new RoleMappingResult([Roles.Deployer], [], IsSystemWideDeployment: true);
|
|
|
|
var principal = SessionClaimBuilder.Build("op", "Operator", ["GlobalDeployers"], mapping, At);
|
|
|
|
// Deny-by-omission preserved: a system-wide mapping stamps NO SiteId claims.
|
|
Assert.Empty(principal.FindAll(ZbClaimTypes.ScopeId));
|
|
Assert.Contains(Roles.Deployer, principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_StoresGroupsAndRefreshAnchor_M219Additions()
|
|
{
|
|
var mapping = new RoleMappingResult([Roles.Administrator], [], true);
|
|
|
|
var principal = SessionClaimBuilder.Build("janer", "Jane Roe", ["G1", "G2"], mapping, At);
|
|
|
|
// M2.19: one zb:group claim per LDAP group, round-trippable via ReadGroups.
|
|
Assert.Equal(new[] { "G1", "G2" }, SessionClaimBuilder.ReadGroups(principal));
|
|
|
|
// M2.19: refresh anchor AND idle anchor seeded from the same instant.
|
|
Assert.Equal(At.ToString("o"), principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value);
|
|
Assert.Equal(At.ToString("o"), principal.FindFirst(JwtTokenService.LastActivityClaimType)?.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_LoginThenRefresh_ProduceIdenticalClaimShape()
|
|
{
|
|
// The anti-drift guarantee: login and the refresh path both call Build with the
|
|
// same inputs, so the resulting role/scope/group claim SETS are identical (only
|
|
// the timestamp anchors differ by design).
|
|
var mapping = new RoleMappingResult([Roles.Designer], ["9"], IsSystemWideDeployment: false);
|
|
|
|
var login = SessionClaimBuilder.Build("u", "User", ["G"], mapping, At);
|
|
var refresh = SessionClaimBuilder.Build("u", "User", ["G"], mapping, At.AddMinutes(16));
|
|
|
|
static IEnumerable<string> Pairs(ClaimsPrincipal p, string type) =>
|
|
p.FindAll(type).Select(c => c.Value).OrderBy(v => v);
|
|
|
|
Assert.Equal(Pairs(login, ClaimTypes.Name), Pairs(refresh, ClaimTypes.Name));
|
|
Assert.Equal(Pairs(login, JwtTokenService.DisplayNameClaimType), Pairs(refresh, JwtTokenService.DisplayNameClaimType));
|
|
Assert.Equal(Pairs(login, JwtTokenService.UsernameClaimType), Pairs(refresh, JwtTokenService.UsernameClaimType));
|
|
Assert.Equal(Pairs(login, JwtTokenService.RoleClaimType), Pairs(refresh, JwtTokenService.RoleClaimType));
|
|
Assert.Equal(Pairs(login, JwtTokenService.SiteIdClaimType), Pairs(refresh, JwtTokenService.SiteIdClaimType));
|
|
Assert.Equal(Pairs(login, JwtTokenService.GroupClaimType), Pairs(refresh, JwtTokenService.GroupClaimType));
|
|
|
|
// The refresh anchors moved forward (that is the ONLY intended difference).
|
|
Assert.NotEqual(
|
|
login.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value,
|
|
refresh.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value);
|
|
}
|
|
}
|
|
|
|
#endregion
|