diff --git a/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json b/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json index 11be7d69..87265889 100644 --- a/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json +++ b/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json @@ -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)"}, diff --git a/docs/requirements/Component-Security.md b/docs/requirements/Component-Security.md index 09652c9b..c4e844e4 100644 --- a/docs/requirements/Component-Security.md +++ b/docs/requirements/Component-Security.md @@ -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 diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs index ac235eba..a66d8f45 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs @@ -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 - { - 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, diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs new file mode 100644 index 00000000..e6a57e10 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs @@ -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; + +/// +/// 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; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs index 6d47471c..d2292ad9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs @@ -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"; + /// /// 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 diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs index 05dc9fd4..6f693a10 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs @@ -35,6 +35,20 @@ public class SecurityOptions /// public int JwtRefreshThresholdMinutes { get; set; } = 5; + /// + /// M2.19 (#15): how long a cookie session's role-mapping projection may be stale + /// before OnValidatePrincipal re-runs the DB-backed RoleMapper on the + /// session's stored LDAP group claims and rebuilds the role/scope claims (default + /// 15 minutes, 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. + /// + public int RoleRefreshThresholdMinutes { get; set; } = 15; + /// /// When true (default) the authentication cookie is always marked /// Secure (sent only over HTTPS) — the correct production setting, diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs index bd7fbab7..cef6f32f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs @@ -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(); services.AddScoped(); + // M2.19 (#15): the cookie OnValidatePrincipal core. Scoped to match the + // IGroupRoleMapper 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(); + // 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; } + /// + /// M2.19 (#15): the thin + /// adapter. It resolves the request-scoped , + /// asks it for a decision, and applies it to the cookie context: + /// + /// + sign out (idle-timeout — the only sign-out path). + /// + ShouldRenew = true (role mapping refreshed). + /// → no-op (no refresh due, or a swallowed refresh fault). + /// + /// All logic lives in , which never + /// throws, so this adapter cannot bubble an exception out into the request pipeline. + /// + /// The cookie validation context supplied by the middleware. + /// A task that completes when the decision has been applied. + internal static async Task OnValidatePrincipalAsync(CookieValidatePrincipalContext context) + { + var validator = context.HttpContext.RequestServices.GetRequiredService(); + + 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; + } + } + /// /// Registers security-related Akka actors (placeholder for future actor registrations). /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SessionClaimBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SessionClaimBuilder.cs new file mode 100644 index 00000000..47301c2a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Security/SessionClaimBuilder.cs @@ -0,0 +1,116 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace ZB.MOM.WW.ScadaBridge.Security; + +/// +/// M2.19 (#15): the single, shared source of truth for the FULL set of claims that +/// back an interactive cookie session. BOTH the /auth/login endpoint and the +/// OnValidatePrincipal mid-session role-refresh path build their principal +/// through , so the two can never drift — the spec requires the +/// refresh to "rebuild claims identically to /auth/login". +/// +/// +/// The claim shape is exactly what the login endpoint historically minted, plus the +/// two M2.19 additions: +/// +/// — resolves Identity.Name. +/// — human display name. +/// — canonical username. +/// — one per mapped role. +/// — one per permitted site, +/// ONLY when the mapping is not system-wide (deny-by-omission preserved). +/// — one per raw LDAP group +/// (M2.19): the durable input the mid-session RoleMapper re-run consumes. +/// — the role-mapping +/// refresh anchor (M2.19), ISO-8601 round-trippable. +/// — the idle-timeout +/// anchor; seeded to the refresh timestamp at login so idle-timeout can be +/// enforced consistently from the very first request. +/// +/// The is built with nameType = ClaimTypes.Name +/// and roleType = RoleClaimType so Identity.Name / IsInRole / +/// [Authorize(Roles=…)] resolve against exactly the canonical types minted here. +/// +public static class SessionClaimBuilder +{ + /// + /// Builds the full cookie-session from the resolved + /// identity, the raw LDAP groups, the DB-backed role mapping, and the refresh + /// timestamp. Used identically by /auth/login and the + /// OnValidatePrincipal refresh path so the two cannot diverge. + /// + /// The canonical authenticated username (becomes + ). + /// The human-readable display name. + /// The user's raw LDAP groups, stored one per claim. + /// The DB-backed role mapping (roles + permitted sites + system-wide flag). + /// The role-mapping refresh anchor; also seeds the last-activity anchor. + /// The authentication type stamped on the identity (defaults to the cookie scheme). + /// A fully populated cookie . + public static ClaimsPrincipal Build( + string username, + string displayName, + IReadOnlyList 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 + { + 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); + } + + /// Reads the stored LDAP group claims () off a principal. + /// The cookie principal to read from. + /// The stored LDAP group names; empty if none were stored. + public static IReadOnlyList ReadGroups(ClaimsPrincipal principal) + { + ArgumentNullException.ThrowIfNull(principal); + return principal.FindAll(JwtTokenService.GroupClaimType).Select(c => c.Value).ToList(); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs new file mode 100644 index 00000000..d7e35a30 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs @@ -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 + +/// +/// Tests for the cookie OnValidatePrincipal core () +/// and the shared . 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. +/// +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 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 + { + private RoleMappingResult _result; + private readonly Exception? _throw; + public int CallCount { get; private set; } + public IReadOnlyList? 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> MapAsync(IReadOnlyList groups, CancellationToken ct) + { + CallCount++; + LastGroups = groups; + if (_throw is not null) throw _throw; + return Task.FromResult(new GroupRoleMapping(_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 mapper, TimeProvider clock, SecurityOptions? options = null) => + new(mapper, Microsoft.Extensions.Options.Options.Create(options ?? Options()), clock, + NullLogger.Instance); + + // Build a session the way login does (via SessionClaimBuilder), anchored at `at`. + private static ClaimsPrincipal Session( + DateTimeOffset at, + IReadOnlyList 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))); + } +} + +/// +/// Claim-parity tests: the shared 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. +/// +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 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