Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Security/SessionClaimBuilder.cs
T
Joseph Doherty 9cff87fe85 docs(comments): strip internal task/milestone/bundle bookkeeping from code comments
Remove project bookkeeping citations from shipped code comments across the
solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/
issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels,
and C/D/K/S/T phase labels.

Comment text only — no code logic, string/log literals, or XML-doc structure
changed. Genuine descriptions are preserved (only the citation is stripped),
and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365,
UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker
TaskReferenceInComment / TrackingReferenceInComment checks plus targeted
grep passes; full solution builds clean, append-only guard tests pass.
2026-07-07 11:03:26 -04:00

117 lines
5.9 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// 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 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
/// — the durable input the mid-session RoleMapper re-run consumes.</item>
/// <item><see cref="JwtTokenService.LastRoleRefreshClaimType"/> — the role-mapping
/// refresh anchor, 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();
}
}