From 83856b7c27396fcdf00f7b8ac13d32ed70d1b7ff Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 06:11:00 -0400 Subject: [PATCH] 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. --- .../Endpoints/AuthEndpoints.cs | 13 +- .../Jwt/JwtTokenService.cs | 20 ++- .../ServiceCollectionExtensions.cs | 25 ++-- .../ZB.MOM.WW.OtOpcUa.Security.csproj | 1 + .../AuthEndpointsIntegrationTests.cs | 136 ++++++++++++++++++ 5 files changed, 179 insertions(+), 16 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs index 36e67cc5..48495d78 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs @@ -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 { - 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))); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs index e8d0d77c..a240f4b9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs @@ -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"; + /// + /// Alias of — the canonical "zb:displayname" claim. + /// All read and mint sites inherit the canonical spelling through this constant. + /// + public const string DisplayNameClaimType = ZbClaimTypes.DisplayName; + + /// + /// Alias of — the canonical "zb:username" claim. + /// All read and mint sites inherit the canonical spelling through this constant. + /// + public const string UsernameClaimType = ZbClaimTypes.Username; + + /// + /// Role claim type used in the JWT payload. Kept as the short "Role" key for the + /// bearer token payload; the cookie-principal uses + /// (= ) for framework role resolution. + /// public const string RoleClaimType = "Role"; private readonly JwtOptions _options; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 3c809188..269a9880 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -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(CookieAuthenticationDefaults.AuthenticationScheme) .Configure, 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) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj index 3dcc405a..d81e4eb6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs index 5fc22fd7..bbd92169 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs @@ -1,6 +1,8 @@ using System.Net; using System.Net.Http.Json; +using System.Security.Claims; using System.Text.Json; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -11,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Shouldly; using Xunit; +using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; @@ -83,6 +86,15 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime // Protected root used by AuthChallengeTests below — exercises the cookie // scheme's challenge heuristic without depending on the full Razor host. e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization(); + // Canonical-claims probe: returns all claim types+values from the cookie + // principal so tests can assert the canonical ZbClaimTypes vocabulary. + e.MapGet("/auth/whoami", (HttpContext ctx) => + { + var claims = ctx.User.Claims + .Select(c => new { c.Type, c.Value }) + .ToArray(); + return Results.Ok(claims); + }).RequireAuthorization(); }); }); }) @@ -251,6 +263,20 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime roles.ShouldBeEmpty(); } + /// Parses the payload segment of a JWT and returns it as a . + private static JsonElement JwtPayloadJson(string jwt) + { + var payloadSegment = jwt.Split('.')[1]; + var padded = payloadSegment.Replace('-', '+').Replace('_', '/'); + padded = (padded.Length % 4) switch + { + 2 => padded + "==", + 3 => padded + "=", + _ => padded, + }; + return JsonDocument.Parse(Convert.FromBase64String(padded)).RootElement; + } + /// Extracts the "Role" claim values from a JWT's payload segment. private static IReadOnlyList JwtRoleClaims(string jwt) { @@ -268,6 +294,110 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime : [roleProp.GetString()!]; } + /// + /// Task 1.5 — canonical claims contract: after a successful cookie login the authenticated + /// principal MUST carry the canonical ZbClaimTypes vocabulary: + /// + /// (= ClaimTypes.Name) so Identity.Name resolves. + /// (= "zb:username") — login username. + /// (= "zb:displayname") — human-friendly name. + /// (= ClaimTypes.Role) — at least one role claim. + /// + /// Also asserts that the old short-name literals "Username" and "DisplayName" are NOT emitted + /// (the pre-Task-1.5 strings that would indicate the migration was incomplete). + /// + [Fact] + public async Task Login_emits_canonical_ZbClaimTypes_on_cookie_principal() + { + // Arrange — seed a DB role so the mapper produces a role claim. + _roleMappings.Rows.Add(new LdapGroupRoleMapping + { + Id = Guid.NewGuid(), + LdapGroup = "ReadOnly", + Role = AdminRole.FleetAdmin, + IsSystemWide = true, + ClusterId = null, + }); + + var client = NewClient(); + + // Act — login. + var loginResp = await client.PostAsJsonAsync("/auth/login", + new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct); + loginResp.StatusCode.ShouldBe(HttpStatusCode.NoContent); + + // Call the whoami probe to read back the cookie principal's claims. + var whoamiReq = new HttpRequestMessage(HttpMethod.Get, "/auth/whoami"); + AttachCookies(whoamiReq, loginResp); + var whoamiResp = await client.SendAsync(whoamiReq, Ct); + whoamiResp.StatusCode.ShouldBe(HttpStatusCode.OK); + + var claims = (await whoamiResp.Content.ReadFromJsonAsync(Ct))!; + + // Assert — canonical name claim (ClaimTypes.Name URI) so Identity.Name resolves. + claims.ShouldContain(c => c.Type == ZbClaimTypes.Name && c.Value == "alice", + $"Expected {ZbClaimTypes.Name} claim with value 'alice'"); + + // Assert — canonical username claim ("zb:username"). + claims.ShouldContain(c => c.Type == ZbClaimTypes.Username && c.Value == "alice", + $"Expected {ZbClaimTypes.Username} claim with value 'alice'"); + + // Assert — canonical display-name claim ("zb:displayname"). + claims.ShouldContain(c => c.Type == ZbClaimTypes.DisplayName && c.Value == "Alice User", + $"Expected {ZbClaimTypes.DisplayName} claim with value 'Alice User'"); + + // Assert — at least one role claim using canonical ZbClaimTypes.Role (= ClaimTypes.Role). + claims.ShouldContain(c => c.Type == ZbClaimTypes.Role, + $"Expected at least one {ZbClaimTypes.Role} claim"); + + // Assert — old pre-Task-1.5 short literals must NOT appear. + claims.ShouldNotContain(c => c.Type == "Username", + "Old 'Username' literal must not be emitted after Task 1.5 migration"); + claims.ShouldNotContain(c => c.Type == "DisplayName", + "Old 'DisplayName' literal must not be emitted after Task 1.5 migration"); + } + + /// + /// Task 1.5 — JWT payload uses canonical claim keys: after login and token issue the JWT + /// payload segment MUST contain "zb:username" and "zb:displayname" keys (not the old short + /// "Username"/"DisplayName" strings). + /// + [Fact] + public async Task Token_payload_uses_canonical_zb_claim_keys() + { + var client = NewClient(); + + var loginResp = await client.PostAsJsonAsync("/auth/login", + new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct); + loginResp.EnsureSuccessStatusCode(); + + var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token"); + AttachCookies(tokenReq, loginResp); + var tokenResp = await client.SendAsync(tokenReq, Ct); + tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK); + + var payload = await tokenResp.Content.ReadFromJsonAsync(Ct); + var jwt = payload.GetProperty("token").GetString()!; + + var payloadJson = JwtPayloadJson(jwt); + + // Canonical "zb:username" key must be present. + payloadJson.TryGetProperty("zb:username", out var usernameEl).ShouldBeTrue( + "JWT payload must carry 'zb:username' claim (canonical ZbClaimTypes.Username)"); + usernameEl.GetString().ShouldBe("alice"); + + // Canonical "zb:displayname" key must be present. + payloadJson.TryGetProperty("zb:displayname", out var displayNameEl).ShouldBeTrue( + "JWT payload must carry 'zb:displayname' claim (canonical ZbClaimTypes.DisplayName)"); + displayNameEl.GetString().ShouldBe("Alice User"); + + // Old short-name literals must NOT be present. + payloadJson.TryGetProperty("Username", out _).ShouldBeFalse( + "JWT payload must not carry old 'Username' key after Task 1.5 migration"); + payloadJson.TryGetProperty("DisplayName", out _).ShouldBeFalse( + "JWT payload must not carry old 'DisplayName' key after Task 1.5 migration"); + } + /// Tests that logout clears the cookie. [Fact] public async Task Logout_clears_the_cookie() @@ -392,4 +522,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => throw new NotSupportedException(); } + + /// + /// DTO for deserialising the /auth/whoami claim list. + /// Must match the anonymous projection in the whoami endpoint. + /// + private sealed record ClaimDto(string Type, string Value); }