feat(auth): OtOpcUa adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5)

Add ZB.MOM.WW.Auth.AspNetCore package ref to Security project (version 0.1.1
from central PM). Alias JwtTokenService.UsernameClaimType and DisplayNameClaimType
to ZbClaimTypes.Username ("zb:username") and ZbClaimTypes.DisplayName ("zb:displayname")
so every mint/read site inherits the canonical spelling. AuthEndpoints login path now
emits ZbClaimTypes.Name (= ClaimTypes.Name, populates Identity.Name) instead of
ClaimTypes.NameIdentifier (no other read site used it), and references ZbClaimTypes.Role
(= ClaimTypes.Role) for role claims so [Authorize(Roles=...)] continues to resolve.
Cookie hardening now flows through ZbCookieDefaults.Apply (sets HttpOnly, SameSite=Strict,
SlidingExpiration, SecurePolicy, ExpireTimeSpan) followed by opts.Cookie.Name = v.Name to
preserve the OtOpcUa-specific "ZB.MOM.WW.OtOpcUa.Auth" cookie name. Two new tests added
to AuthEndpointsIntegrationTests assert canonical ZbClaimTypes on the cookie principal and
canonical zb: keys in the JWT payload; all 35 security tests green.
This commit is contained in:
Joseph Doherty
2026-06-02 06:11:00 -04:00
parent c4f315ec90
commit 83856b7c27
5 changed files with 179 additions and 16 deletions
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
@@ -116,12 +117,14 @@ public static class AuthEndpoints
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, result.Username ?? username),
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
// ZbClaimTypes.Name = ClaimTypes.Name — populates Identity.Name canonically.
new(ZbClaimTypes.Name, result.Username ?? username),
new(ZbClaimTypes.Username, result.Username ?? username),
new(ZbClaimTypes.DisplayName, result.DisplayName ?? username),
};
foreach (var role in roles)
claims.Add(new Claim(ClaimTypes.Role, role));
// ZbClaimTypes.Role = ClaimTypes.Role — framework [Authorize(Roles=...)] + IsInRole work.
claims.Add(new Claim(ZbClaimTypes.Role, role));
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
@@ -157,7 +160,7 @@ public static class AuthEndpoints
?? user.Identity?.Name
?? string.Empty;
var displayName = user.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username;
var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray();
var roles = user.FindAll(ZbClaimTypes.Role).Select(c => c.Value).ToArray();
return Results.Ok(new TokenResponse(jwt.Issue(displayName, username, roles)));
}
@@ -4,13 +4,29 @@ 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.OtOpcUa.Security.Jwt;
public sealed class JwtTokenService
{
public const string DisplayNameClaimType = "DisplayName";
public const string UsernameClaimType = "Username";
/// <summary>
/// Alias of <see cref="ZbClaimTypes.DisplayName"/> — the canonical "zb:displayname" claim.
/// All read and mint sites inherit the canonical spelling through this constant.
/// </summary>
public const string DisplayNameClaimType = ZbClaimTypes.DisplayName;
/// <summary>
/// Alias of <see cref="ZbClaimTypes.Username"/> — the canonical "zb:username" claim.
/// All read and mint sites inherit the canonical spelling through this constant.
/// </summary>
public const string UsernameClaimType = ZbClaimTypes.Username;
/// <summary>
/// Role claim type used in the JWT payload. Kept as the short "Role" key for the
/// bearer token payload; the cookie-principal uses <see cref="ZbClaimTypes.Role"/>
/// (= <see cref="ClaimTypes.Role"/>) for framework role resolution.
/// </summary>
public const string RoleClaimType = "Role";
private readonly JwtOptions _options;
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
@@ -57,28 +58,34 @@ public static class ServiceCollectionExtensions
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(o =>
{
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
// are bound from OtOpcUaCookieOptions in the PostConfigure block below.
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration /
// HttpOnly / SameSite are applied from OtOpcUaCookieOptions via ZbCookieDefaults
// in the PostConfigure block below.
o.LoginPath = "/login";
o.LogoutPath = "/auth/logout";
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = SameSiteMode.Strict;
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
});
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
// ZbCookieDefaults.Apply sets HttpOnly=true, SameSite=Strict, SlidingExpiration=true,
// SecurePolicy, and ExpireTimeSpan; we then set the app-specific cookie name on top.
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
{
var v = ourOpts.Value;
// Apply canonical hardened defaults (HttpOnly, SameSite=Strict, SlidingExpiration,
// SecurePolicy, ExpireTimeSpan). Cookie name is NOT touched by ZbCookieDefaults —
// we set it below so each app keeps its own distinct cookie name.
ZbCookieDefaults.Apply(
cookieOpts,
requireHttps: v.RequireHttpsCookie,
idleTimeout: TimeSpan.FromMinutes(v.ExpiryMinutes));
// Keep OtOpcUa's own cookie name (default "ZB.MOM.WW.OtOpcUa.Auth").
cookieOpts.Cookie.Name = v.Name;
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
cookieOpts.SlidingExpiration = true;
cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;
if (!v.RequireHttpsCookie)
{
@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore"/>
<PackageReference Include="ZB.MOM.WW.Auth.Ldap"/>
</ItemGroup>