feat(auth): ScadaBridge full canonical claims (ZbClaimTypes role/scope) + ZbCookieDefaults, keep cookie name (Task 1.5)

This commit is contained in:
Joseph Doherty
2026-06-02 06:23:15 -04:00
parent afa55981d5
commit a0938f708b
25 changed files with 247 additions and 50 deletions
@@ -91,7 +91,18 @@ public static class AuthEndpoints
}
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
// 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);
await context.SignInAsync(
@@ -4,6 +4,7 @@ using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.ScadaBridge.Security;
@@ -12,10 +13,20 @@ public class JwtTokenService
private readonly SecurityOptions _options;
private readonly ILogger<JwtTokenService> _logger;
public const string DisplayNameClaimType = "DisplayName";
public const string UsernameClaimType = "Username";
public const string RoleClaimType = "Role";
public const string SiteIdClaimType = "SiteId";
// Task 1.5 (full canonical claims): these constants are the single source of
// truth that every mint site, every authorization policy, and the token
// validation parameters reference. Redefining them as aliases of the shared
// ZbClaimTypes vocabulary migrates the whole module centrally:
// - Role → the framework URI (ClaimTypes.Role) so [Authorize(Roles=…)],
// IsInRole, AND RequireClaim(RoleClaimType,…) all resolve.
// - SiteId → ZbClaimTypes.ScopeId ("zb:scopeid") — the canonical scope claim.
// - DisplayName/Username → the canonical "zb:" strings.
// LastActivity has no canonical equivalent (it is a ScadaBridge-internal
// idle-timeout anchor), so it keeps its existing literal.
public const string DisplayNameClaimType = ZbClaimTypes.DisplayName;
public const string UsernameClaimType = ZbClaimTypes.Username;
public const string RoleClaimType = ZbClaimTypes.Role;
public const string SiteIdClaimType = ZbClaimTypes.ScopeId;
public const string LastActivityClaimType = "LastActivity";
/// <summary>
@@ -100,7 +111,19 @@ public class JwtTokenService
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
// MapOutboundClaims=false: write the claim TYPE strings into the JWT
// verbatim (e.g. the ClaimTypes.Role URI for RoleClaimType) rather than
// letting JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap rewrite the
// framework URI to a short JWT name ("role"). Paired with
// MapInboundClaims=false on validation (see ValidateToken), this makes the
// claim type the policy checks — RequireClaim(RoleClaimType, …) — byte-for-byte
// the same string both in the token and after it is read back, with no
// mapping round-trip surprises. The "zb:" scope/display/username claims are
// not in any map and were unaffected either way; pinning both directions
// makes the role/site migration to canonical types deterministic.
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
handler.OutboundClaimTypeMap.Clear();
return handler.WriteToken(token);
}
/// <summary>Validates a JWT string and returns the decoded <see cref="ClaimsPrincipal"/>, or null if the token is invalid or expired.</summary>
@@ -118,12 +141,26 @@ public class JwtTokenService
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ClockSkew = TimeSpan.Zero
ClockSkew = TimeSpan.Zero,
// The token was minted carrying the canonical claim TYPE strings
// verbatim (RoleClaimType = ClaimTypes.Role URI, SiteIdClaimType =
// ZbClaimTypes.ScopeId, etc.). Pin the same role/name claim types here
// so the validated principal's Identity.Name and IsInRole resolve, and
// so RequireClaim(RoleClaimType, …) sees exactly the type that is in the
// token.
RoleClaimType = RoleClaimType,
NameClaimType = ZbClaimTypes.Name
};
try
{
var handler = new JwtSecurityTokenHandler();
// MapInboundClaims=false: do NOT let JwtSecurityTokenHandler rewrite
// inbound claim types via DefaultInboundClaimTypeMap. The token already
// holds the canonical type strings (see GenerateToken's
// MapOutboundClaims=false), so reading them back unmapped yields exactly
// the strings every policy + claim helper expects.
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
var principal = handler.ValidateToken(token, validationParameters, out _);
return principal;
}
@@ -63,17 +63,23 @@ public static class ServiceCollectionExtensions
// now enforces Server + SearchBase + ServiceAccountDn + transport at startup. The
// JWT signing key continues to fail-fast at JwtTokenService construction.
// Register ASP.NET Core authentication with cookie scheme
// Register ASP.NET Core authentication with cookie scheme. The non-
// SecurityOptions-coupled settings (paths, cookie name) are set here; the
// hardened cookie defaults that depend on SecurityOptions (idle timeout,
// HTTPS policy) are applied via the SecurityOptions-bound PostConfigure
// below through ZbCookieDefaults.Apply.
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
// The cookie NAME is app-owned and not set by ZbCookieDefaults.Apply
// (so co-hosted ZB apps do not clobber each other's session). Keep
// ScadaBridge's existing name so live sessions survive this change.
options.Cookie.Name = "ZB.MOM.WW.ScadaBridge.Auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict;
// Cookie.SecurePolicy is set in the PostConfigure block below so it
// can honour SecurityOptions.RequireHttpsCookie.
// HttpOnly / SameSite / SecurePolicy / SlidingExpiration /
// ExpireTimeSpan are all set by ZbCookieDefaults.Apply in the
// SecurityOptions-bound PostConfigure below.
});
// CentralUI-005: configure the cookie session as a sliding window so the
@@ -90,27 +96,27 @@ public static class ServiceCollectionExtensions
// itself (see JwtTokenService) — a separate layer from the cookie
// session window. Bound here via PostConfigure so SecurityOptions
// (configured by the Host after AddSecurity) is honoured.
//
// Task 1.5: the cookie hardening (HttpOnly=true, SameSite=Strict,
// SecurePolicy, SlidingExpiration=true, ExpireTimeSpan=idle) now comes from
// the shared ZbCookieDefaults.Apply, with requireHttps + idleTimeout driven
// by SecurityOptions so behaviour (30-min sliding idle window, HTTPS-only
// unless explicitly opted out) is preserved.
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
{
var idleMinutes = securityOptions.Value.IdleTimeoutMinutes;
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(idleMinutes);
cookieOptions.SlidingExpiration = true;
// The cookie carries the embedded JWT bearer credential. Production
// keeps it HTTPS-only (Always); an HTTP-only deployment (e.g. the
// local Docker dev cluster) opts out via RequireHttpsCookie=false and
// uses SameAsRequest — still Secure on any HTTPS request.
cookieOptions.Cookie.SecurePolicy = securityOptions.Value.RequireHttpsCookie
? Microsoft.AspNetCore.Http.CookieSecurePolicy.Always
: Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
ZbCookieDefaults.Apply(
cookieOptions,
requireHttps: securityOptions.Value.RequireHttpsCookie,
idleTimeout: TimeSpan.FromMinutes(securityOptions.Value.IdleTimeoutMinutes));
// Security-021: when the operator opts out of HTTPS-only cookies,
// log a Warning so an HTTP-only deployment is at least audible in
// the startup log. The cookie carries the embedded JWT bearer
// credential — over plain HTTP that travels in cleartext on every
// request. The default is true; this branch fires only on an
// explicit opt-out (typically the dev Docker cluster).
// explicit opt-out (typically the dev Docker cluster). Apply sets
// SecurePolicy=SameAsRequest in that case.
if (!securityOptions.Value.RequireHttpsCookie)
{
loggerFactory.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(