feat(auth): ScadaBridge full canonical claims (ZbClaimTypes role/scope) + ZbCookieDefaults, keep cookie name (Task 1.5)
This commit is contained in:
@@ -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);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
await context.SignInAsync(
|
await context.SignInAsync(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
@@ -12,10 +13,20 @@ public class JwtTokenService
|
|||||||
private readonly SecurityOptions _options;
|
private readonly SecurityOptions _options;
|
||||||
private readonly ILogger<JwtTokenService> _logger;
|
private readonly ILogger<JwtTokenService> _logger;
|
||||||
|
|
||||||
public const string DisplayNameClaimType = "DisplayName";
|
// Task 1.5 (full canonical claims): these constants are the single source of
|
||||||
public const string UsernameClaimType = "Username";
|
// truth that every mint site, every authorization policy, and the token
|
||||||
public const string RoleClaimType = "Role";
|
// validation parameters reference. Redefining them as aliases of the shared
|
||||||
public const string SiteIdClaimType = "SiteId";
|
// 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";
|
public const string LastActivityClaimType = "LastActivity";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -100,7 +111,19 @@ public class JwtTokenService
|
|||||||
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
||||||
signingCredentials: credentials);
|
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>
|
/// <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,
|
ValidateLifetime = true,
|
||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
IssuerSigningKey = key,
|
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
|
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 _);
|
var principal = handler.ValidateToken(token, validationParameters, out _);
|
||||||
return principal;
|
return principal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,17 +63,23 @@ public static class ServiceCollectionExtensions
|
|||||||
// now enforces Server + SearchBase + ServiceAccountDn + transport at startup. The
|
// now enforces Server + SearchBase + ServiceAccountDn + transport at startup. The
|
||||||
// JWT signing key continues to fail-fast at JwtTokenService construction.
|
// 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)
|
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(options =>
|
.AddCookie(options =>
|
||||||
{
|
{
|
||||||
options.LoginPath = "/login";
|
options.LoginPath = "/login";
|
||||||
options.LogoutPath = "/auth/logout";
|
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.Name = "ZB.MOM.WW.ScadaBridge.Auth";
|
||||||
options.Cookie.HttpOnly = true;
|
// HttpOnly / SameSite / SecurePolicy / SlidingExpiration /
|
||||||
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict;
|
// ExpireTimeSpan are all set by ZbCookieDefaults.Apply in the
|
||||||
// Cookie.SecurePolicy is set in the PostConfigure block below so it
|
// SecurityOptions-bound PostConfigure below.
|
||||||
// can honour SecurityOptions.RequireHttpsCookie.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// CentralUI-005: configure the cookie session as a sliding window so the
|
// 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
|
// itself (see JwtTokenService) — a separate layer from the cookie
|
||||||
// session window. Bound here via PostConfigure so SecurityOptions
|
// session window. Bound here via PostConfigure so SecurityOptions
|
||||||
// (configured by the Host after AddSecurity) is honoured.
|
// (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)
|
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
|
.Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
|
||||||
{
|
{
|
||||||
var idleMinutes = securityOptions.Value.IdleTimeoutMinutes;
|
ZbCookieDefaults.Apply(
|
||||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(idleMinutes);
|
cookieOptions,
|
||||||
cookieOptions.SlidingExpiration = true;
|
requireHttps: securityOptions.Value.RequireHttpsCookie,
|
||||||
|
idleTimeout: TimeSpan.FromMinutes(securityOptions.Value.IdleTimeoutMinutes));
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Security-021: when the operator opts out of HTTPS-only cookies,
|
// Security-021: when the operator opts out of HTTPS-only cookies,
|
||||||
// log a Warning so an HTTP-only deployment is at least audible in
|
// log a Warning so an HTTP-only deployment is at least audible in
|
||||||
// the startup log. The cookie carries the embedded JWT bearer
|
// the startup log. The cookie carries the embedded JWT bearer
|
||||||
// credential — over plain HTTP that travels in cleartext on every
|
// credential — over plain HTTP that travels in cleartext on every
|
||||||
// request. The default is true; this branch fires only on an
|
// 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)
|
if (!securityOptions.Value.RequireHttpsCookie)
|
||||||
{
|
{
|
||||||
loggerFactory.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(
|
loggerFactory.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "admin"),
|
new Claim(JwtTokenService.UsernameClaimType, "admin"),
|
||||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public class ApiKeysListPageTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "admin"),
|
new Claim(JwtTokenService.UsernameClaimType, "admin"),
|
||||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public class SiteFormAuditDrillinTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "admin"),
|
new Claim(JwtTokenService.UsernameClaimType, "admin"),
|
||||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
@@ -28,7 +29,7 @@ public class DataConnectionFormTests : BunitContext
|
|||||||
{
|
{
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Admin")
|
new Claim(ClaimTypes.Role, "Admin")
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -35,7 +36,7 @@ public class DataConnectionsPageTests : BunitContext
|
|||||||
{
|
{
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Admin")
|
new Claim(ClaimTypes.Role, "Admin")
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
|
|||||||
// Auth: a system-wide Deployment user so SiteScope grants everything.
|
// Auth: a system-wide Deployment user so SiteScope grants everything.
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "deployer"),
|
new Claim(JwtTokenService.UsernameClaimType, "deployer"),
|
||||||
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
|
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(JwtTokenService.RoleClaimType, "Design"),
|
new Claim(JwtTokenService.RoleClaimType, "Design"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -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
|
/// reveals a section's items and persists state to a cookie) and that the
|
||||||
/// Notifications section's items are gated per-policy. The
|
/// Notifications section's items are gated per-policy. The
|
||||||
/// <c>AuthorizeView Policy=...</c> blocks evaluate the real policies, which
|
/// <c>AuthorizeView Policy=...</c> blocks evaluate the real policies, which
|
||||||
/// require a claim of type <see cref="JwtTokenService.RoleClaimType"/> ("Role"),
|
/// require a claim of type <see cref="JwtTokenService.RoleClaimType"/> (the
|
||||||
/// so the test principal carries claims of that exact type.
|
/// canonical <c>ZbClaimTypes.Role</c> framework URI), so the test principal
|
||||||
|
/// carries claims of that exact type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NavMenuTests : BunitContext
|
public class NavMenuTests : BunitContext
|
||||||
{
|
{
|
||||||
@@ -40,7 +41,7 @@ public class NavMenuTests : BunitContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private IRenderedComponent<NavMenu> RenderWithRoles(params string[] roles)
|
private IRenderedComponent<NavMenu> RenderWithRoles(params string[] roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim> { new("Username", "tester") };
|
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
|
||||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||||
|
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public class AuditLogPagePermissionTests : BunitContext
|
|||||||
|
|
||||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim> { new("Username", "tester") };
|
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
|
||||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
|
|
||||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim> { new("Username", "tester") };
|
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
|
||||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class ExecutionTreePageTests : BunitContext
|
|||||||
|
|
||||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim> { new("Username", "tester") };
|
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
|
||||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
@@ -80,7 +81,7 @@ public class HealthPageTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Admin"),
|
new Claim(ClaimTypes.Role, "Admin"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ public class NotificationKpisPageTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Deployment"),
|
new Claim(ClaimTypes.Role, "Deployment"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -21,7 +22,7 @@ public class NotificationListsPageTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Design"),
|
new Claim(ClaimTypes.Role, "Design"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
@@ -87,7 +88,7 @@ public class NotificationReportDetailModalTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Deployment"),
|
new Claim(ClaimTypes.Role, "Deployment"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public class NotificationReportPageTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Deployment"),
|
new Claim(ClaimTypes.Role, "Deployment"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Bunit.TestDoubles;
|
using Bunit.TestDoubles;
|
||||||
@@ -171,7 +172,7 @@ public sealed class QueryStringDrillInTests
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Deployment"),
|
new Claim(ClaimTypes.Role, "Deployment"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ public class SiteCallsReportPageTests : BunitContext
|
|||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Deployment"),
|
new Claim(ClaimTypes.Role, "Deployment"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
@@ -493,7 +493,7 @@ public class SiteCallsReportPageTests : BunitContext
|
|||||||
// Last AuthenticationStateProvider registration wins on resolution.
|
// Last AuthenticationStateProvider registration wins on resolution.
|
||||||
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "scoped"),
|
new Claim(JwtTokenService.UsernameClaimType, "scoped"),
|
||||||
new Claim(ClaimTypes.Role, "Deployment"),
|
new Claim(ClaimTypes.Role, "Deployment"),
|
||||||
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
|
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
|
||||||
}, "TestAuth"));
|
}, "TestAuth"));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -19,7 +20,7 @@ public class SmtpConfigurationPageTests : BunitContext
|
|||||||
{
|
{
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Admin"),
|
new Claim(ClaimTypes.Role, "Admin"),
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -50,7 +51,7 @@ public class TemplatesPageTests : BunitContext
|
|||||||
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
|
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Design")
|
new Claim(ClaimTypes.Role, "Design")
|
||||||
};
|
};
|
||||||
var identity = new ClaimsIdentity(claims, "TestAuth");
|
var identity = new ClaimsIdentity(claims, "TestAuth");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -86,7 +87,7 @@ public class TopologyPageTests : BunitContext
|
|||||||
{
|
{
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
new Claim(ClaimTypes.Role, "Deployment")
|
new Claim(ClaimTypes.Role, "Deployment")
|
||||||
};
|
};
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
@@ -215,7 +216,7 @@ public class TopologyPageTests : BunitContext
|
|||||||
// permitted sites via SiteScopeService.
|
// permitted sites via SiteScopeService.
|
||||||
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
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"),
|
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployment"),
|
||||||
// Permitted on site 1 only.
|
// Permitted on site 1 only.
|
||||||
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"),
|
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"),
|
||||||
|
|||||||
@@ -1205,3 +1205,136 @@ public class SecurityOptionsValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Task 1.5: Canonical claim vocabulary (ZbClaimTypes) — role/scope migration
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 1.5 (full canonical adoption): the Security module's claim-type constants now
|
||||||
|
/// alias the shared <see cref="ZbClaimTypes"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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<JwtTokenService>.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<Claim>
|
||||||
|
{
|
||||||
|
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<bool> EvaluatePolicy(string policyName, ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScadaBridgeAuthorization();
|
||||||
|
services.AddLogging();
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
var authService = provider.GetRequiredService<IAuthorizationService>();
|
||||||
|
|
||||||
|
var result = await authService.AuthorizeAsync(principal, null, policyName);
|
||||||
|
return result.Succeeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|||||||
Reference in New Issue
Block a user