feat(security): cookie session idle-timeout + LDAP-free role-mapping refresh (#15, M2.19)
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.
This commit is contained in:
@@ -19,8 +19,8 @@
|
||||
{"id": 47, "ref": "M2.15", "subject": "M2.15 #29: register site active-node purge gate (DI)", "class": "small", "status": "completed", "commits": ["e1ee37e"]},
|
||||
{"id": 48, "ref": "M2.16", "subject": "M2.16 #30: Health Monitoring consumes FailedWriteCount", "class": "small", "status": "completed", "commits": ["d81f747", "c9244d8"]},
|
||||
{"id": 49, "ref": "M2.17", "subject": "M2.17 #31: reconcile StateTransitionValidator delete-from-NotDeployed", "class": "small", "status": "completed", "commits": ["c104356"]},
|
||||
{"id": 50, "ref": "M2.18", "subject": "M2.18 #26: debug-stream stream-first ordering + replay/dedup", "class": "high-risk", "status": "completed", "commits": ["d8519cb"]},
|
||||
{"id": 51, "ref": "M2.19", "subject": "M2.19 #15: LDAP periodic re-query for interactive sessions (spike+impl)", "class": "high-risk", "status": "pending"}
|
||||
{"id": 50, "ref": "M2.18", "subject": "M2.18 #26: debug-stream stream-first ordering + replay/dedup", "class": "high-risk", "status": "completed", "commits": ["d8519cb", "a0d9379"]},
|
||||
{"id": 51, "ref": "M2.19", "subject": "M2.19 #15: LDAP periodic re-query for interactive sessions (spike+impl)", "class": "high-risk", "status": "completed", "note": "Spike outcome: shared ILdapAuthService exposes only AuthenticateAsync (no passwordless group-search) -> live LDAP group re-query out of scope (external pkg, tracked follow-up). Implemented always-achievable layers: stored zb:group + zb:lastrolerefresh claims at login, shared SessionClaimBuilder (DRY login+refresh), CookieSessionValidator + OnValidatePrincipal (idle-timeout reject@30m, DB-only role-mapping refresh@15m, fail-soft keep-session on refresh error). Residual limitation documented in Component-Security.md.", "commits": ["9cfa660"]}
|
||||
],
|
||||
"deferred": [
|
||||
{"ref": "#16", "subject": "Transport stale-instance enumeration", "to": "M8 (Transport)"},
|
||||
|
||||
@@ -32,9 +32,31 @@ Central cluster. Sites do not have user-facing interfaces and do not perform ind
|
||||
- **JWT claims**: User display name, username, list of roles (Admin, Design, Deployment), and for site-scoped Deployment, the list of permitted site IDs.
|
||||
|
||||
### Token Lifecycle
|
||||
- **JWT expiry**: 15 minutes. On each request, if the cookie-embedded JWT is near expiry, the app re-queries LDAP for current group memberships and issues a fresh JWT, writing an updated cookie. Roles are never more than 15 minutes stale.
|
||||
- **Idle timeout**: Configurable, default **30 minutes**. If no requests are made within the idle window, the token is not refreshed and the user must re-login. Tracked via a last-activity timestamp in the token.
|
||||
- **Sliding refresh**: Active users stay logged in indefinitely — the token refreshes every 15 minutes as long as requests are made within the 30-minute idle window.
|
||||
|
||||
> **Implementation note (M2.19, #15).** The interactive Central UI login path signs in
|
||||
> with **bare cookie claims**, not a cookie-embedded JWT. The session lifecycle below is
|
||||
> therefore enforced by the cookie middleware (`ExpireTimeSpan` + `SlidingExpiration`) plus
|
||||
> a `CookieAuthenticationEvents.OnValidatePrincipal` handler — see **Session Validation
|
||||
> (`OnValidatePrincipal`)** below. The embedded-JWT model remains the documented design
|
||||
> intent and is the mechanism for any non-cookie bearer surface (e.g. `/auth/token`), but
|
||||
> it is **not** the transport for the cookie principal.
|
||||
|
||||
- **Idle timeout**: Configurable, default **30 minutes**. If no requests are made within the idle window, the session is rejected and the user must re-login. Tracked via a `LastActivity` last-activity timestamp claim. The cookie's `ExpireTimeSpan` is set to the idle timeout and `SlidingExpiration` renews it on activity, so the cookie window and the explicit `OnValidatePrincipal` idle check use the **same** value and cannot contradict each other.
|
||||
- **Role-mapping refresh (LDAP-free)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the stored groups — **with no LDAP call** — rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Roles derived from central mappings are never more than ~15 minutes stale.
|
||||
|
||||
#### Session Validation (`OnValidatePrincipal`)
|
||||
- The cookie principal is built at login by a **single shared claim-builder** (`SessionClaimBuilder`). The `OnValidatePrincipal` role-refresh path rebuilds the principal through the **same** builder, so the login and refresh claim shapes cannot drift.
|
||||
- **Failure policy**: the refresh is best-effort. Any error during the refresh (e.g. the configuration database is unreachable) **keeps the existing principal with its current roles** — it never signs the user out and never throws out of the request pipeline. This mirrors the **Active sessions** stance under *LDAP Connection Failure* below. Only the explicit idle-timeout path rejects the principal.
|
||||
|
||||
> **Residual limitation — live LDAP group-membership changes (follow-up).** The
|
||||
> mid-session refresh re-maps the **stored** groups against the central database; it does
|
||||
> **not** re-query LDAP, so a change to the user's actual **group membership** in the
|
||||
> directory is picked up only at **next login**. A live group re-query for an active
|
||||
> session would require a new passwordless service-account group-search method on the
|
||||
> shared `ZB.MOM.WW.Auth.Ldap` library, which is an **external NuGet package** and exposes
|
||||
> only `AuthenticateAsync(username, password, ct)` (no standalone group search). Adding
|
||||
> that method is tracked as a follow-up. Until then: central role-mapping/scope changes are
|
||||
> reflected within ~15 minutes; directory group-membership changes require re-login.
|
||||
|
||||
### Load Balancer Compatibility
|
||||
- The authentication cookie carries a self-contained JWT — no server-side session state. A load balancer in front of the central cluster can route requests to either node without sticky sessions or a shared session store.
|
||||
@@ -43,8 +65,8 @@ Central cluster. Sites do not have user-facing interfaces and do not perform ind
|
||||
## LDAP Connection Failure
|
||||
|
||||
- **New logins**: If the LDAP/AD server is unreachable, login attempts **fail**. Users cannot be authenticated without LDAP.
|
||||
- **Active sessions**: Users with valid (not-yet-expired) JWTs can **continue operating** with their current roles. The token refresh is skipped until LDAP is available again. This avoids disrupting engineers mid-work during a brief LDAP outage.
|
||||
- **Recovery**: When LDAP becomes reachable again, the next token refresh cycle re-queries group memberships and issues a fresh token with current roles.
|
||||
- **Active sessions**: Users with a valid (not-idle-timed-out) session can **continue operating** with their current roles during an LDAP outage. Interactive cookie sessions never re-query LDAP mid-session (the mid-session role-mapping refresh is DB-only — see *Session Validation* above), so a brief LDAP outage does not disrupt engineers mid-work; central role-mapping changes still apply within the refresh window regardless of LDAP availability.
|
||||
- **Recovery (group-membership changes)**: Because the mid-session refresh is LDAP-free, a change to a user's **directory group membership** is picked up at the user's **next login** (when LDAP is queried again), not mid-session — see the *Residual limitation* note above.
|
||||
|
||||
## Roles
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
@@ -72,39 +71,23 @@ public static class AuthEndpoints
|
||||
// the documented sliding-refresh policy.
|
||||
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
|
||||
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, resolvedUsername),
|
||||
new(JwtTokenService.DisplayNameClaimType, displayName),
|
||||
new(JwtTokenService.UsernameClaimType, resolvedUsername),
|
||||
};
|
||||
|
||||
foreach (var role in roleMapping.Roles)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
||||
}
|
||||
|
||||
if (!scope.IsSystemWideDeployment)
|
||||
{
|
||||
foreach (var siteId in scope.PermittedSiteIds)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
||||
}
|
||||
}
|
||||
|
||||
// Task 1.5: name the role/name claim types explicitly so the cookie
|
||||
// principal's IsInRole / [Authorize(Roles=…)] resolve against the same
|
||||
// canonical types we mint (JwtTokenService.RoleClaimType = ZbClaimTypes.Role,
|
||||
// ClaimTypes.Name = ZbClaimTypes.Name). The policies use
|
||||
// RequireClaim(RoleClaimType, …) which checks type+value directly, but
|
||||
// pinning roleType keeps IsInRole-style checks consistent and survives the
|
||||
// cookie serialize/round-trip.
|
||||
var identity = new ClaimsIdentity(
|
||||
claims,
|
||||
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: JwtTokenService.RoleClaimType);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
// M2.19 (#15): build the cookie principal through the shared
|
||||
// SessionClaimBuilder — the SINGLE source of truth that the mid-session
|
||||
// OnValidatePrincipal role-refresh path ALSO uses, so login and refresh can
|
||||
// never drift. It stamps the canonical identity/role/scope claims (with
|
||||
// roleType/nameType pinned for IsInRole), PLUS the M2.19 additions: one
|
||||
// zb:group claim per raw LDAP group (the durable input the mid-session
|
||||
// RoleMapper re-run consumes) and a zb:lastrolerefresh anchor (login time,
|
||||
// UTC) that also seeds the LastActivity idle anchor. The refresh timestamp is
|
||||
// the login instant, so the first role refresh is due RoleRefreshThresholdMinutes
|
||||
// later — not immediately.
|
||||
var principal = SessionClaimBuilder.Build(
|
||||
resolvedUsername,
|
||||
displayName,
|
||||
authResult.Groups,
|
||||
scope,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
await context.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a single cookie <c>OnValidatePrincipal</c> evaluation. The thin
|
||||
/// <c>OnValidatePrincipal</c> lambda translates this into the matching
|
||||
/// <c>CookieValidatePrincipalContext</c> calls (<c>RejectPrincipal</c> /
|
||||
/// <c>ReplacePrincipal</c> + <c>ShouldRenew</c>); the decision itself is computed by
|
||||
/// <see cref="CookieSessionValidator"/> so it is unit-testable in isolation.
|
||||
/// </summary>
|
||||
/// <param name="Action">What the caller must do with the principal.</param>
|
||||
/// <param name="Principal">The replacement principal when <paramref name="Action"/> is <see cref="SessionValidationAction.Replace"/>; otherwise <c>null</c>.</param>
|
||||
public readonly record struct SessionValidationResult(
|
||||
SessionValidationAction Action,
|
||||
ClaimsPrincipal? Principal)
|
||||
{
|
||||
/// <summary>Keep the existing principal unchanged.</summary>
|
||||
public static SessionValidationResult Keep { get; } = new(SessionValidationAction.Keep, null);
|
||||
|
||||
/// <summary>Reject the principal (idle-timed-out) — the caller signs the user out.</summary>
|
||||
public static SessionValidationResult Reject { get; } = new(SessionValidationAction.Reject, null);
|
||||
|
||||
/// <summary>Replace the principal with a refreshed one and renew the cookie.</summary>
|
||||
/// <param name="principal">The rebuilt principal.</param>
|
||||
/// <returns>A replace result carrying <paramref name="principal"/>.</returns>
|
||||
public static SessionValidationResult Replace(ClaimsPrincipal principal) =>
|
||||
new(SessionValidationAction.Replace, principal);
|
||||
}
|
||||
|
||||
/// <summary>The action a cookie session validation requires of the caller.</summary>
|
||||
public enum SessionValidationAction
|
||||
{
|
||||
/// <summary>Leave the principal as-is (no idle timeout, no refresh due, or a refresh error we swallow).</summary>
|
||||
Keep,
|
||||
|
||||
/// <summary>The session is idle-timed-out; reject + sign out.</summary>
|
||||
Reject,
|
||||
|
||||
/// <summary>The role mapping was refreshed; replace the principal and renew the cookie.</summary>
|
||||
Replace,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M2.19 (#15): the unit-testable core of the cookie <c>OnValidatePrincipal</c> event.
|
||||
/// Enforces the idle timeout and refreshes the session's role/scope claims from the
|
||||
/// STORED LDAP group claims via the DB-backed <see cref="RoleMapper"/> — <b>without any
|
||||
/// LDAP call</b> — picking up central role-mapping (and scope-rule) changes mid-session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Idle timeout</b> (default <see cref="SecurityOptions.IdleTimeoutMinutes"/> = 30):
|
||||
/// computed from the <see cref="JwtTokenService.LastActivityClaimType"/> anchor. This is
|
||||
/// the explicit, deterministic counterpart to the cookie middleware's
|
||||
/// <c>ExpireTimeSpan</c> + <c>SlidingExpiration</c> window — both use the SAME idle
|
||||
/// timeout value, so the explicit check never contradicts the cookie window. A
|
||||
/// not-timed-out session has its last-activity anchor advanced to "now" (genuine
|
||||
/// request = activity), mirroring the sliding renew.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Role refresh</b> (default <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>
|
||||
/// = 15): when the elapsed time since <see cref="JwtTokenService.LastRoleRefreshClaimType"/>
|
||||
/// exceeds the threshold, the stored groups are re-mapped and the principal is rebuilt via
|
||||
/// <see cref="SessionClaimBuilder"/> (identical shape to <c>/auth/login</c>). If the DB
|
||||
/// mapping revoked the user's roles, the rebuilt principal reflects the loss.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure policy</b>: a refresh error (e.g. the mapper throws because the DB is
|
||||
/// unreachable) NEVER signs the user out and NEVER throws out of validation — it returns
|
||||
/// <see cref="SessionValidationResult.Keep"/>, mirroring the documented "LDAP failure:
|
||||
/// active sessions continue with current roles" stance. Only the explicit idle-timeout
|
||||
/// path rejects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CookieSessionValidator
|
||||
{
|
||||
private readonly IGroupRoleMapper<string> _roleMapper;
|
||||
private readonly SecurityOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CookieSessionValidator> _logger;
|
||||
|
||||
/// <summary>Initializes the validator.</summary>
|
||||
/// <param name="roleMapper">The DB-backed group→role mapping seam (no LDAP) used for the mid-session refresh.</param>
|
||||
/// <param name="options">Security options carrying the idle and role-refresh thresholds.</param>
|
||||
/// <param name="timeProvider">Clock source; injected so tests can advance time deterministically.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public CookieSessionValidator(
|
||||
IGroupRoleMapper<string> roleMapper,
|
||||
IOptions<SecurityOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CookieSessionValidator> logger)
|
||||
{
|
||||
_roleMapper = roleMapper ?? throw new ArgumentNullException(nameof(roleMapper));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a cookie principal: enforces the idle timeout, then refreshes the
|
||||
/// role/scope claims from the stored LDAP groups when the role-refresh interval has
|
||||
/// elapsed. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="principal">The current cookie principal under validation.</param>
|
||||
/// <param name="ct">Cancellation token (the request-aborted token in the pipeline).</param>
|
||||
/// <returns>The action the caller must take and any replacement principal.</returns>
|
||||
public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default)
|
||||
{
|
||||
// An unauthenticated / null principal is left to the rest of the pipeline.
|
||||
if (principal?.Identity is not { IsAuthenticated: true })
|
||||
{
|
||||
return SessionValidationResult.Keep;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// 1) Idle-timeout enforcement — the only path that rejects. A missing/unparsable
|
||||
// last-activity anchor is treated as timed-out (fail-closed): a session we
|
||||
// cannot age must not be kept alive forever.
|
||||
if (IsIdleTimedOut(principal, now))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cookie session for {Username} rejected: past the {IdleTimeout}-minute idle timeout.",
|
||||
principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "(unknown)",
|
||||
_options.IdleTimeoutMinutes);
|
||||
return SessionValidationResult.Reject;
|
||||
}
|
||||
|
||||
// 2) Role-mapping refresh — best-effort. Any failure keeps the existing session.
|
||||
try
|
||||
{
|
||||
var refreshed = await TryRefreshAsync(principal, now, ct).ConfigureAwait(false);
|
||||
if (refreshed is not null)
|
||||
{
|
||||
return SessionValidationResult.Replace(refreshed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// SECURITY: never broaden access and never sign the user out on a transient
|
||||
// refresh fault — keep the existing principal (current roles) and swallow.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Mid-session role refresh failed for {Username}; keeping existing session and roles.",
|
||||
principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "(unknown)");
|
||||
return SessionValidationResult.Keep;
|
||||
}
|
||||
|
||||
return SessionValidationResult.Keep;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the session's last-activity anchor is older than
|
||||
/// <see cref="SecurityOptions.IdleTimeoutMinutes"/>. A missing/unparsable anchor is
|
||||
/// treated as timed-out (fail-closed).
|
||||
/// </summary>
|
||||
/// <param name="principal">The cookie principal.</param>
|
||||
/// <param name="now">The current instant.</param>
|
||||
/// <returns><c>true</c> if the session has exceeded the idle window.</returns>
|
||||
public bool IsIdleTimedOut(ClaimsPrincipal principal, DateTimeOffset now)
|
||||
{
|
||||
var claim = principal.FindFirst(JwtTokenService.LastActivityClaimType);
|
||||
if (claim is null || !DateTimeOffset.TryParse(claim.Value, out var lastActivity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||
}
|
||||
|
||||
// Returns a rebuilt principal when a refresh occurred (role-refresh interval elapsed,
|
||||
// OR last-activity needs advancing); null when nothing changed. The principal is
|
||||
// rebuilt via SessionClaimBuilder so its shape is identical to /auth/login.
|
||||
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
var roleRefreshDue = IsRoleRefreshDue(principal, now);
|
||||
if (!roleRefreshDue)
|
||||
{
|
||||
// No mapping refresh due. We deliberately do NOT mint a new principal just to
|
||||
// advance LastActivity: the cookie middleware's SlidingExpiration already
|
||||
// renews the cookie window on activity, so the idle anchor only needs
|
||||
// advancing when we are rebuilding the principal anyway (on a role refresh).
|
||||
// This keeps the no-op request path allocation-free and avoids a cookie
|
||||
// re-issue on every request.
|
||||
return null;
|
||||
}
|
||||
|
||||
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
|
||||
var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
|
||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(displayName))
|
||||
{
|
||||
// Malformed principal — cannot rebuild faithfully. Keep it (do not reject).
|
||||
_logger.LogWarning("Cannot refresh role mapping: principal is missing username/display-name claims.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var groups = SessionClaimBuilder.ReadGroups(principal);
|
||||
|
||||
// Re-run the DB-backed mapping on the STORED groups — NO LDAP call.
|
||||
var mapping = await _roleMapper.MapAsync(groups, ct).ConfigureAwait(false);
|
||||
var scope = mapping.Scope is RoleMappingResult mapped
|
||||
? mapped
|
||||
: new RoleMappingResult(mapping.Roles, [], IsSystemWideDeployment: false);
|
||||
|
||||
// Rebuild identically to /auth/login, advancing BOTH anchors: the role-refresh
|
||||
// anchor (we just refreshed) and the idle anchor (this is a genuine request).
|
||||
return SessionClaimBuilder.Build(username, displayName, groups, scope, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the elapsed time since the last role refresh exceeds
|
||||
/// <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>. A missing/unparsable
|
||||
/// anchor is treated as due (refresh now and re-stamp the anchor).
|
||||
/// </summary>
|
||||
/// <param name="principal">The cookie principal.</param>
|
||||
/// <param name="now">The current instant.</param>
|
||||
/// <returns><c>true</c> if a role-mapping refresh is due.</returns>
|
||||
public bool IsRoleRefreshDue(ClaimsPrincipal principal, DateTimeOffset now)
|
||||
{
|
||||
var claim = principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType);
|
||||
if (claim is null || !DateTimeOffset.TryParse(claim.Value, out var lastRefresh))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return (now - lastRefresh).TotalMinutes > _options.RoleRefreshThresholdMinutes;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,22 @@ public class JwtTokenService
|
||||
public const string SiteIdClaimType = ZbClaimTypes.ScopeId;
|
||||
public const string LastActivityClaimType = "LastActivity";
|
||||
|
||||
// M2.19 (#15): the cookie session now stores the user's raw LDAP groups and a
|
||||
// role-mapping refresh anchor so an active interactive session can re-run the
|
||||
// DB-backed RoleMapper (NOT LDAP) mid-session and pick up central role-mapping
|
||||
// changes. These two have no canonical ZbClaimTypes equivalent (the shared
|
||||
// vocabulary covers identity/role/scope, not the ScadaBridge-internal refresh
|
||||
// machinery), so they keep "zb:"-prefixed ScadaBridge-local literals:
|
||||
// - GroupClaimType ("zb:group", one per LDAP group) is the input the
|
||||
// mid-session RoleMapper re-run consumes — the groups are the durable
|
||||
// fact; the roles are the derived projection that can go stale.
|
||||
// - LastRoleRefreshClaimType ("zb:lastrolerefresh", ISO-8601 "o") anchors
|
||||
// the role-mapping refresh interval (SecurityOptions.RoleRefreshThresholdMinutes).
|
||||
// LastActivityClaimType (above) remains the idle-timeout anchor — a separate
|
||||
// clock from the role-refresh anchor.
|
||||
public const string GroupClaimType = "zb:group";
|
||||
public const string LastRoleRefreshClaimType = "zb:lastrolerefresh";
|
||||
|
||||
/// <summary>
|
||||
/// Fixed issuer bound into every token and required on validation. Binding
|
||||
/// issuer/audience is defence-in-depth: even though the HMAC key is shared only
|
||||
|
||||
@@ -35,6 +35,20 @@ public class SecurityOptions
|
||||
/// </summary>
|
||||
public int JwtRefreshThresholdMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// M2.19 (#15): how long a cookie session's role-mapping projection may be stale
|
||||
/// before <c>OnValidatePrincipal</c> re-runs the DB-backed <c>RoleMapper</c> on the
|
||||
/// session's stored LDAP group claims and rebuilds the role/scope claims (default
|
||||
/// <b>15 minutes</b>, matching the documented sliding-refresh cadence). This is a
|
||||
/// purely central (database) refresh — it picks up LDAP-group→role mapping changes
|
||||
/// and scope-rule changes WITHOUT contacting LDAP, so revoked roles take effect
|
||||
/// within this window. It does NOT pick up live LDAP group-membership changes (the
|
||||
/// shared LDAP library exposes no passwordless group-search; that remains a
|
||||
/// next-login refresh — see Component-Security.md). Kept <= the cookie idle
|
||||
/// window so a refresh never outlives the session it refreshes.
|
||||
/// </summary>
|
||||
public int RoleRefreshThresholdMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) the authentication cookie is always marked
|
||||
/// <c>Secure</c> (sent only over HTTPS) — the correct production setting,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
@@ -51,6 +54,14 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<JwtTokenService>();
|
||||
services.AddScoped<RoleMapper>();
|
||||
|
||||
// M2.19 (#15): the cookie OnValidatePrincipal core. Scoped to match the
|
||||
// IGroupRoleMapper<string> it depends on (which depends on the Scoped
|
||||
// ISecurityRepository). The clock is injected (TimeProvider) so the idle/refresh
|
||||
// thresholds can be exercised deterministically in tests; the production default
|
||||
// is the wall clock. TryAddSingleton keeps the Host free to register its own.
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddScoped<CookieSessionValidator>();
|
||||
|
||||
// Audit Actor wiring (Phase 3): the user-facing inbound API audit path
|
||||
// sources AuditEvent.Actor from the authenticated principal via this
|
||||
// seam. HttpAuditActorAccessor reads IHttpContextAccessor.HttpContext?.User
|
||||
@@ -94,6 +105,16 @@ public static class ServiceCollectionExtensions
|
||||
// environments sharing a hostname can be given distinct names. HttpOnly /
|
||||
// SameSite / SecurePolicy / SlidingExpiration / ExpireTimeSpan are likewise
|
||||
// applied there via ZbCookieDefaults.Apply.
|
||||
|
||||
// M2.19 (#15): OnValidatePrincipal enforces the idle timeout and refreshes
|
||||
// the role/scope claims from the session's STORED LDAP groups (DB-backed
|
||||
// RoleMapper, NO LDAP) so central role-mapping changes take effect
|
||||
// mid-session. The lambda is a THIN adapter: it resolves the request-scoped
|
||||
// CookieSessionValidator (which holds all the testable idle/refresh logic)
|
||||
// and translates its decision into the cookie context calls. It NEVER
|
||||
// throws — CookieSessionValidator.ValidateAsync swallows refresh faults and
|
||||
// keeps the session (mirrors "LDAP failure: active sessions continue").
|
||||
options.Events.OnValidatePrincipal = OnValidatePrincipalAsync;
|
||||
});
|
||||
|
||||
// CentralUI-005: configure the cookie session as a sliding window so the
|
||||
@@ -152,6 +173,53 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M2.19 (#15): the thin <see cref="CookieAuthenticationEvents.OnValidatePrincipal"/>
|
||||
/// adapter. It resolves the request-scoped <see cref="CookieSessionValidator"/>,
|
||||
/// asks it for a decision, and applies it to the cookie context:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="SessionValidationAction.Reject"/> → <see cref="CookieValidatePrincipalContext.RejectPrincipal"/> + sign out (idle-timeout — the only sign-out path).</item>
|
||||
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + <c>ShouldRenew = true</c> (role mapping refreshed).</item>
|
||||
/// <item><see cref="SessionValidationAction.Keep"/> → no-op (no refresh due, or a swallowed refresh fault).</item>
|
||||
/// </list>
|
||||
/// All logic lives in <see cref="CookieSessionValidator.ValidateAsync"/>, which never
|
||||
/// throws, so this adapter cannot bubble an exception out into the request pipeline.
|
||||
/// </summary>
|
||||
/// <param name="context">The cookie validation context supplied by the middleware.</param>
|
||||
/// <returns>A task that completes when the decision has been applied.</returns>
|
||||
internal static async Task OnValidatePrincipalAsync(CookieValidatePrincipalContext context)
|
||||
{
|
||||
var validator = context.HttpContext.RequestServices.GetRequiredService<CookieSessionValidator>();
|
||||
|
||||
var result = await validator
|
||||
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
switch (result.Action)
|
||||
{
|
||||
case SessionValidationAction.Reject:
|
||||
// Idle-timeout: drop the principal AND clear the cookie so the next
|
||||
// request is treated as anonymous and redirected to /login.
|
||||
context.RejectPrincipal();
|
||||
await context.HttpContext
|
||||
.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case SessionValidationAction.Replace when result.Principal is not null:
|
||||
// Role mapping refreshed from stored groups — swap in the rebuilt
|
||||
// principal and re-issue the cookie so the new claims persist.
|
||||
context.ReplacePrincipal(result.Principal);
|
||||
context.ShouldRenew = true;
|
||||
break;
|
||||
|
||||
case SessionValidationAction.Keep:
|
||||
default:
|
||||
// Leave the principal untouched.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers security-related Akka actors (placeholder for future actor registrations).
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// M2.19 (#15): the single, shared source of truth for the FULL set of claims that
|
||||
/// back an interactive cookie session. BOTH the <c>/auth/login</c> endpoint and the
|
||||
/// <c>OnValidatePrincipal</c> mid-session role-refresh path build their principal
|
||||
/// through <see cref="Build"/>, so the two can never drift — the spec requires the
|
||||
/// refresh to "rebuild claims identically to /auth/login".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The claim shape is exactly what the login endpoint historically minted, plus the
|
||||
/// two M2.19 additions:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ClaimTypes.Name"/> — resolves <c>Identity.Name</c>.</item>
|
||||
/// <item><see cref="JwtTokenService.DisplayNameClaimType"/> — human display name.</item>
|
||||
/// <item><see cref="JwtTokenService.UsernameClaimType"/> — canonical username.</item>
|
||||
/// <item><see cref="JwtTokenService.RoleClaimType"/> — one per mapped role.</item>
|
||||
/// <item><see cref="JwtTokenService.SiteIdClaimType"/> — one per permitted site,
|
||||
/// ONLY when the mapping is not system-wide (deny-by-omission preserved).</item>
|
||||
/// <item><see cref="JwtTokenService.GroupClaimType"/> — one per raw LDAP group
|
||||
/// (M2.19): the durable input the mid-session RoleMapper re-run consumes.</item>
|
||||
/// <item><see cref="JwtTokenService.LastRoleRefreshClaimType"/> — the role-mapping
|
||||
/// refresh anchor (M2.19), ISO-8601 round-trippable.</item>
|
||||
/// <item><see cref="JwtTokenService.LastActivityClaimType"/> — the idle-timeout
|
||||
/// anchor; seeded to the refresh timestamp at login so idle-timeout can be
|
||||
/// enforced consistently from the very first request.</item>
|
||||
/// </list>
|
||||
/// The <see cref="ClaimsIdentity"/> is built with <c>nameType = ClaimTypes.Name</c>
|
||||
/// and <c>roleType = RoleClaimType</c> so <c>Identity.Name</c> / <c>IsInRole</c> /
|
||||
/// <c>[Authorize(Roles=…)]</c> resolve against exactly the canonical types minted here.
|
||||
/// </remarks>
|
||||
public static class SessionClaimBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the full cookie-session <see cref="ClaimsPrincipal"/> from the resolved
|
||||
/// identity, the raw LDAP groups, the DB-backed role mapping, and the refresh
|
||||
/// timestamp. Used identically by <c>/auth/login</c> and the
|
||||
/// <c>OnValidatePrincipal</c> refresh path so the two cannot diverge.
|
||||
/// </summary>
|
||||
/// <param name="username">The canonical authenticated username (becomes <see cref="ClaimTypes.Name"/> + <see cref="JwtTokenService.UsernameClaimType"/>).</param>
|
||||
/// <param name="displayName">The human-readable display name.</param>
|
||||
/// <param name="groups">The user's raw LDAP groups, stored one per <see cref="JwtTokenService.GroupClaimType"/> claim.</param>
|
||||
/// <param name="mapping">The DB-backed role mapping (roles + permitted sites + system-wide flag).</param>
|
||||
/// <param name="refreshTimestamp">The role-mapping refresh anchor; also seeds the last-activity anchor.</param>
|
||||
/// <param name="authenticationType">The authentication type stamped on the identity (defaults to the cookie scheme).</param>
|
||||
/// <returns>A fully populated cookie <see cref="ClaimsPrincipal"/>.</returns>
|
||||
public static ClaimsPrincipal Build(
|
||||
string username,
|
||||
string displayName,
|
||||
IReadOnlyList<string> groups,
|
||||
RoleMappingResult mapping,
|
||||
DateTimeOffset refreshTimestamp,
|
||||
string authenticationType = CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(username);
|
||||
ArgumentNullException.ThrowIfNull(displayName);
|
||||
ArgumentNullException.ThrowIfNull(groups);
|
||||
ArgumentNullException.ThrowIfNull(mapping);
|
||||
|
||||
var refreshStamp = refreshTimestamp.ToString("o");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, username),
|
||||
new(JwtTokenService.DisplayNameClaimType, displayName),
|
||||
new(JwtTokenService.UsernameClaimType, username),
|
||||
// Role-refresh anchor AND idle anchor are seeded from the same instant at
|
||||
// build time. They then diverge: OnValidatePrincipal advances LastActivity
|
||||
// on every request but only advances LastRoleRefresh when it actually
|
||||
// re-runs the mapping.
|
||||
new(JwtTokenService.LastRoleRefreshClaimType, refreshStamp),
|
||||
new(JwtTokenService.LastActivityClaimType, refreshStamp),
|
||||
};
|
||||
|
||||
foreach (var role in mapping.Roles)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
||||
}
|
||||
|
||||
// Deny-by-omission: only stamp SiteId claims for a non-system-wide mapping.
|
||||
if (!mapping.IsSystemWideDeployment)
|
||||
{
|
||||
foreach (var siteId in mapping.PermittedSiteIds)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
||||
}
|
||||
}
|
||||
|
||||
// Store the raw LDAP groups so the mid-session refresh can re-run the
|
||||
// DB-backed RoleMapper without any LDAP round-trip.
|
||||
foreach (var group in groups)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.GroupClaimType, group));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
claims,
|
||||
authenticationType: authenticationType,
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: JwtTokenService.RoleClaimType);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
/// <summary>Reads the stored LDAP group claims (<see cref="JwtTokenService.GroupClaimType"/>) off a principal.</summary>
|
||||
/// <param name="principal">The cookie principal to read from.</param>
|
||||
/// <returns>The stored LDAP group names; empty if none were stored.</returns>
|
||||
public static IReadOnlyList<string> ReadGroups(ClaimsPrincipal principal)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
return principal.FindAll(JwtTokenService.GroupClaimType).Select(c => c.Value).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
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
|
||||
Reference in New Issue
Block a user