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; /// /// The outcome of a single cookie OnValidatePrincipal evaluation. The thin /// OnValidatePrincipal lambda translates this into the matching /// CookieValidatePrincipalContext calls (RejectPrincipal / /// ReplacePrincipal + ShouldRenew); the decision itself is computed by /// so it is unit-testable in isolation. /// /// What the caller must do with the principal. /// The replacement principal when is ; otherwise null. public readonly record struct SessionValidationResult( SessionValidationAction Action, ClaimsPrincipal? Principal) { /// Keep the existing principal unchanged. public static SessionValidationResult Keep { get; } = new(SessionValidationAction.Keep, null); /// Reject the principal (idle-timed-out) — the caller signs the user out. public static SessionValidationResult Reject { get; } = new(SessionValidationAction.Reject, null); /// Replace the principal with a refreshed one and renew the cookie. /// The rebuilt principal. /// A replace result carrying . public static SessionValidationResult Replace(ClaimsPrincipal principal) => new(SessionValidationAction.Replace, principal); } /// The action a cookie session validation requires of the caller. public enum SessionValidationAction { /// Leave the principal as-is (no idle timeout, no refresh due, or a refresh error we swallow). Keep, /// The session is idle-timed-out; reject + sign out. Reject, /// The role mapping was refreshed; replace the principal and renew the cookie. Replace, } /// /// M2.19 (#15): the unit-testable core of the cookie OnValidatePrincipal event. /// Enforces the idle timeout and refreshes the session's role/scope claims from the /// STORED LDAP group claims via the DB-backed without any /// LDAP call — picking up central role-mapping (and scope-rule) changes mid-session. /// /// /// /// Idle timeout (default = 30): /// computed from the anchor. This is /// the explicit, deterministic counterpart to the cookie middleware's /// ExpireTimeSpan + SlidingExpiration 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. /// /// /// Role refresh (default /// = 15): when the elapsed time since /// exceeds the threshold, the stored groups are re-mapped and the principal is rebuilt via /// (identical shape to /auth/login). If the DB /// mapping revoked the user's roles, the rebuilt principal reflects the loss. /// /// /// Failure policy: 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 /// , mirroring the documented "LDAP failure: /// active sessions continue with current roles" stance. Only the explicit idle-timeout /// path rejects. /// /// public sealed class CookieSessionValidator { private readonly IGroupRoleMapper _roleMapper; private readonly SecurityOptions _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; /// Initializes the validator. /// The DB-backed group→role mapping seam (no LDAP) used for the mid-session refresh. /// Security options carrying the idle and role-refresh thresholds. /// Clock source; injected so tests can advance time deterministically. /// Logger instance. public CookieSessionValidator( IGroupRoleMapper roleMapper, IOptions options, TimeProvider timeProvider, ILogger 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)); } /// /// 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. /// /// The current cookie principal under validation. /// Cancellation token (the request-aborted token in the pipeline). /// The action the caller must take and any replacement principal. public async Task 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; } /// /// Returns true when the session's last-activity anchor is older than /// . A missing/unparsable anchor is /// treated as timed-out (fail-closed). /// /// The cookie principal. /// The current instant. /// true if the session has exceeded the idle window. 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 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); } /// /// Returns true when the elapsed time since the last role refresh exceeds /// . A missing/unparsable /// anchor is treated as due (refresh now and re-stamp the anchor). /// /// The cookie principal. /// The current instant. /// true if a role-mapping refresh is due. 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; } }