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;
}
}