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:
Joseph Doherty
2026-06-16 07:54:23 -04:00
parent a0d9379a4f
commit 8fe7f46df6
9 changed files with 852 additions and 40 deletions
@@ -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 &lt;= 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();
}
}