From a0938f708bf2457b89f161d684b440fce72c2008 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 06:23:15 -0400 Subject: [PATCH] feat(auth): ScadaBridge full canonical claims (ZbClaimTypes role/scope) + ZbCookieDefaults, keep cookie name (Task 1.5) --- .../Auth/AuthEndpoints.cs | 13 +- .../JwtTokenService.cs | 51 ++++++- .../ServiceCollectionExtensions.cs | 40 +++--- .../Admin/ApiKeyFormAuditDrillinTests.cs | 2 +- .../Admin/ApiKeysListPageTests.cs | 2 +- .../Admin/SiteFormAuditDrillinTests.cs | 2 +- .../DataConnectionFormTests.cs | 3 +- .../DataConnectionsPageTests.cs | 3 +- .../InstanceConfigureAuditDrillinTests.cs | 2 +- .../ExternalSystemFormAuditDrillinTests.cs | 2 +- .../Layout/NavMenuTests.cs | 7 +- .../Pages/AuditLogPagePermissionTests.cs | 2 +- .../Pages/AuditLogPageScaffoldTests.cs | 2 +- .../Pages/ExecutionTreePageTests.cs | 2 +- .../Pages/HealthPageTests.cs | 3 +- .../Pages/NotificationKpisPageTests.cs | 2 +- .../Pages/NotificationListsPageTests.cs | 3 +- .../NotificationReportDetailModalTests.cs | 3 +- .../Pages/NotificationReportPageTests.cs | 2 +- .../Pages/QueryStringDrillInTests.cs | 3 +- .../Pages/SiteCallsReportPageTests.cs | 4 +- .../Pages/SmtpConfigurationPageTests.cs | 3 +- .../TemplatesPageTests.cs | 3 +- .../TopologyPageTests.cs | 5 +- .../SecurityTests.cs | 133 ++++++++++++++++++ 25 files changed, 247 insertions(+), 50 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs index 33e3174c..d00acde2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs @@ -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( diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs index b0cc4285..102cd4be 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs @@ -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 _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"; /// @@ -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); } /// Validates a JWT string and returns the decoded , or null if the token is invalid or expired. @@ -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; } diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs index 0214a196..3e157093 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs @@ -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(CookieAuthenticationDefaults.AuthenticationScheme) .Configure, 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( diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs index 4f67dd3c..e9d6797b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs @@ -36,7 +36,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext var claims = new[] { - new Claim("Username", "admin"), + new Claim(JwtTokenService.UsernameClaimType, "admin"), new Claim(JwtTokenService.RoleClaimType, "Admin"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs index deb19943..19b5a80a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs @@ -26,7 +26,7 @@ public class ApiKeysListPageTests : BunitContext var claims = new[] { - new Claim("Username", "admin"), + new Claim(JwtTokenService.UsernameClaimType, "admin"), new Claim(JwtTokenService.RoleClaimType, "Admin"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs index c7a4a37c..44b59358 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs @@ -35,7 +35,7 @@ public class SiteFormAuditDrillinTests : BunitContext var claims = new[] { - new Claim("Username", "admin"), + new Claim(JwtTokenService.UsernameClaimType, "admin"), new Claim(JwtTokenService.RoleClaimType, "Admin"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs index aaac4756..ae51cb45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; using System.Text.Json; using Bunit; using Microsoft.AspNetCore.Components; @@ -28,7 +29,7 @@ public class DataConnectionFormTests : BunitContext { var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Admin") }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs index 04955e01..bcd92993 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -35,7 +36,7 @@ public class DataConnectionsPageTests : BunitContext { var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Admin") }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs index 069a693d..63bc824a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs @@ -53,7 +53,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext // Auth: a system-wide Deployment user so SiteScope grants everything. var claims = new[] { - new Claim("Username", "deployer"), + new Claim(JwtTokenService.UsernameClaimType, "deployer"), new Claim(JwtTokenService.RoleClaimType, "Deployment"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs index 2fe498cb..81464f00 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs @@ -27,7 +27,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.RoleClaimType, "Design"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs index b3c81df0..8b5ca4dc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs @@ -16,8 +16,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Layout; /// reveals a section's items and persists state to a cookie) and that the /// Notifications section's items are gated per-policy. The /// AuthorizeView Policy=... blocks evaluate the real policies, which -/// require a claim of type ("Role"), -/// so the test principal carries claims of that exact type. +/// require a claim of type (the +/// canonical ZbClaimTypes.Role framework URI), so the test principal +/// carries claims of that exact type. /// public class NavMenuTests : BunitContext { @@ -40,7 +41,7 @@ public class NavMenuTests : BunitContext /// private IRenderedComponent RenderWithRoles(params string[] roles) { - var claims = new List { new("Username", "tester") }; + var claims = new List { new(JwtTokenService.UsernameClaimType, "tester") }; claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs index da85eaaf..0fe6f7e3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs @@ -57,7 +57,7 @@ public class AuditLogPagePermissionTests : BunitContext private static ClaimsPrincipal BuildPrincipal(params string[] roles) { - var claims = new List { new("Username", "tester") }; + var claims = new List { new(JwtTokenService.UsernameClaimType, "tester") }; claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 69d71432..da9220d3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -39,7 +39,7 @@ public class AuditLogPageScaffoldTests : BunitContext private static ClaimsPrincipal BuildPrincipal(params string[] roles) { - var claims = new List { new("Username", "tester") }; + var claims = new List { new(JwtTokenService.UsernameClaimType, "tester") }; claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs index ea201943..b268459f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs @@ -28,7 +28,7 @@ public class ExecutionTreePageTests : BunitContext private static ClaimsPrincipal BuildPrincipal(params string[] roles) { - var claims = new List { new("Username", "tester") }; + var claims = new List { new(JwtTokenService.UsernameClaimType, "tester") }; claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs index 8e90b7b0..350706f9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; using Akka.Actor; using Bunit; using Microsoft.AspNetCore.Components.Authorization; @@ -80,7 +81,7 @@ public class HealthPageTests : BunitContext var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Admin"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs index ba4a2932..9f972062 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs @@ -68,7 +68,7 @@ public class NotificationKpisPageTests : BunitContext var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Deployment"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs index ebae075b..e1750c03 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -21,7 +22,7 @@ public class NotificationListsPageTests : BunitContext var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Design"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs index 9fe7d52b..0504d202 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; using Akka.Actor; using Bunit; using Microsoft.AspNetCore.Components.Authorization; @@ -87,7 +88,7 @@ public class NotificationReportDetailModalTests : BunitContext var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Deployment"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs index 81cfffbf..2da4c880 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs @@ -73,7 +73,7 @@ public class NotificationReportPageTests : BunitContext var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Deployment"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs index e760e661..610333eb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; using Akka.Actor; using Bunit; using Bunit.TestDoubles; @@ -171,7 +172,7 @@ public sealed class QueryStringDrillInTests var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Deployment"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs index 7b0c393d..298db8ea 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs @@ -88,7 +88,7 @@ public class SiteCallsReportPageTests : BunitContext var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Deployment"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); @@ -493,7 +493,7 @@ public class SiteCallsReportPageTests : BunitContext // Last AuthenticationStateProvider registration wins on resolution. var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { - new Claim("Username", "scoped"), + new Claim(JwtTokenService.UsernameClaimType, "scoped"), new Claim(ClaimTypes.Role, "Deployment"), new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only }, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs index 99ba8842..2dc950f7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -19,7 +20,7 @@ public class SmtpConfigurationPageTests : BunitContext { var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Admin"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs index 49c12558..cea64831 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -50,7 +51,7 @@ public class TemplatesPageTests : BunitContext // GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash. var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Design") }; var identity = new ClaimsIdentity(claims, "TestAuth"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs index 8a43c609..6d4ba969 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ZB.MOM.WW.ScadaBridge.Security; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -86,7 +87,7 @@ public class TopologyPageTests : BunitContext { var claims = new[] { - new Claim("Username", "tester"), + new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(ClaimTypes.Role, "Deployment") }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); @@ -215,7 +216,7 @@ public class TopologyPageTests : BunitContext // permitted sites via SiteScopeService. var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { - new Claim("Username", "scoped-tester"), + new Claim(JwtTokenService.UsernameClaimType, "scoped-tester"), new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployment"), // Permitted on site 1 only. new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"), diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs index 2fe3dcb3..a8873204 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs @@ -1205,3 +1205,136 @@ public class SecurityOptionsValidatorTests } #endregion + +#region Task 1.5: Canonical claim vocabulary (ZbClaimTypes) — role/scope migration + +/// +/// Task 1.5 (full canonical adoption): the Security module's claim-type constants now +/// alias the shared vocabulary. These tests pin the contract +/// that the MINTED claim type is exactly the type the policies + token validation CONSUME, +/// across both the JWT bearer and the cookie principal, so a future drift fails loudly. +/// +public class CanonicalClaimVocabularyTests +{ + private static SecurityOptions CreateOptions() => new() + { + JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", + JwtExpiryMinutes = 15, + IdleTimeoutMinutes = 30, + JwtRefreshThresholdMinutes = 5 + }; + + private static JwtTokenService CreateService() => + new(Options.Create(CreateOptions()), NullLogger.Instance); + + [Fact] + public void ClaimTypeConstants_AreCanonicalZbClaimTypes() + { + // The constants must be the canonical shared types so every mint/consume site + // inherits them centrally. + Assert.Equal(ZbClaimTypes.Role, JwtTokenService.RoleClaimType); + Assert.Equal(ZbClaimTypes.ScopeId, JwtTokenService.SiteIdClaimType); + Assert.Equal(ZbClaimTypes.DisplayName, JwtTokenService.DisplayNameClaimType); + Assert.Equal(ZbClaimTypes.Username, JwtTokenService.UsernameClaimType); + + // Role aliases the framework URI; scope/display/username are the zb: strings. + Assert.Equal(ClaimTypes.Role, JwtTokenService.RoleClaimType); + Assert.Equal("zb:scopeid", JwtTokenService.SiteIdClaimType); + Assert.Equal("zb:displayname", JwtTokenService.DisplayNameClaimType); + Assert.Equal("zb:username", JwtTokenService.UsernameClaimType); + + // LastActivity has no canonical equivalent and is unchanged. + Assert.Equal("LastActivity", JwtTokenService.LastActivityClaimType); + } + + [Fact] + public void MintedJwt_RoundTrips_CanonicalClaimTypesVerbatim() + { + var service = CreateService(); + var token = service.GenerateToken( + "Jane Roe", "janer", + new[] { Roles.Audit }, + new[] { "7" }); + + var principal = service.ValidateToken(token); + Assert.NotNull(principal); + + // Claim TYPES must be the canonical strings, not the framework's short JWT names + // (MapInboundClaims=false / cleared outbound map guarantee this). + Assert.Equal("Jane Roe", principal!.FindFirst(ZbClaimTypes.DisplayName)?.Value); + Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value); + Assert.Equal(Roles.Audit, principal.FindFirst(ZbClaimTypes.Role)?.Value); + Assert.Equal("7", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value); + } + + [Fact] + public async Task MintedJwt_RoleClaim_SatisfiesOperationalAuditPolicy() + { + // The load-bearing round-trip: a JWT minted with RoleClaimType=Audit must satisfy + // a RequireClaim(RoleClaimType, OperationalAuditRoles) policy after validation. + var service = CreateService(); + var token = service.GenerateToken("Jane Roe", "janer", new[] { Roles.Audit }, null); + + var principal = service.ValidateToken(token); + Assert.NotNull(principal); + + Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal!)); + // Audit does NOT grant AuditExport via a different vocabulary by accident: + Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal!)); + // AuditReadOnly is read-only — separate assertion that the role VALUE semantics + // are untouched by the type migration. + var roPrincipal = service.ValidateToken( + service.GenerateToken("RO", "ro", new[] { Roles.AuditReadOnly }, null)); + Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, roPrincipal!)); + Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, roPrincipal!)); + } + + [Fact] + public async Task CookiePrincipal_BuiltLikeLogin_AuthorizesAndExposesCanonicalTypes() + { + // Reproduce AuthEndpoints' cookie principal exactly: canonical claim types via the + // constants + a ClaimsIdentity whose roleType is RoleClaimType so IsInRole resolves. + var claims = new List + { + new(ClaimTypes.Name, "janer"), + new(JwtTokenService.DisplayNameClaimType, "Jane Roe"), + new(JwtTokenService.UsernameClaimType, "janer"), + new(JwtTokenService.RoleClaimType, Roles.Admin), + new(JwtTokenService.SiteIdClaimType, "3"), + }; + var identity = new ClaimsIdentity( + claims, + authenticationType: "TestCookie", + nameType: ClaimTypes.Name, + roleType: JwtTokenService.RoleClaimType); + var principal = new ClaimsPrincipal(identity); + + // Claim types are canonical. + Assert.Equal("zb:scopeid", JwtTokenService.SiteIdClaimType); + Assert.Equal("3", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value); + Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name + + // roleType wiring => IsInRole resolves against the canonical role claim. + Assert.True(principal.IsInRole(Roles.Admin)); + + // Admin holds every permission by convention. + Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal)); + Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); + Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); + } + + private static async Task EvaluatePolicy(string policyName, ClaimsPrincipal principal) + { + var services = new ServiceCollection(); + services.AddScadaBridgeAuthorization(); + services.AddLogging(); + + using var provider = services.BuildServiceProvider(); + var authService = provider.GetRequiredService(); + + var result = await authService.AuthorizeAsync(principal, null, policyName); + return result.Succeeded; + } +} + +#endregion