From 7e1af37eb11bc53b35efac3a991b604c23b2c526 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 06:10:48 -0400 Subject: [PATCH] feat(auth): MxGateway dashboard adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5) - DashboardAuthenticator.CreatePrincipal: emit ZbClaimTypes.Username ("zb:username") with the login username, ZbClaimTypes.DisplayName ("zb:displayname") with the display name, ZbClaimTypes.Name (== ClaimTypes.Name) for Identity.Name resolution, ZbClaimTypes.Role (== ClaimTypes.Role) for IsInRole/[Authorize]. Keep ClaimTypes.NameIdentifier for back-compat read-sites; keep mxgateway:ldap_group unchanged (MxGateway-specific, no ZbClaimType for groups). ClaimsIdentity built with nameType=ZbClaimTypes.Name, roleType=ZbClaimTypes.Role. - DashboardServiceCollectionExtensions.AddGatewayDashboard: route cookie hardening through ZbCookieDefaults.Apply(requireHttps:true, idleTimeout:8h); set cookie name/path/redirects after Apply; PostConfigure still overrides SecurePolicy per RequireHttpsCookie setting. - DashboardAuthenticatorTests: add AuthenticateAsync_Success_EmitsCanonicalZbClaims asserting zb:username, zb:displayname, ZbClaimTypes.Role per role, Identity.Name, and ldap_group preserved. --- .../Dashboard/DashboardAuthenticator.cs | 28 +++++++--- .../DashboardServiceCollectionExtensions.cs | 16 +++--- .../Dashboard/DashboardAuthenticatorTests.cs | 54 +++++++++++++++++++ 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs index 47c211a..37d78f5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Abstractions.Roles; +using ZB.MOM.WW.Auth.AspNetCore; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; @@ -75,8 +76,15 @@ public sealed class DashboardAuthenticator( /// /// Builds the dashboard from the LDAP outcome. /// - /// The (trimmed) login name → . - /// The user's display name → . + /// + /// The (trimmed) login name. Emitted as (kept for + /// back-compat reads) and as the canonical ("zb:username"). + /// + /// + /// The user's display name. Emitted as (= + /// so Identity.Name resolves) and as ("zb:displayname") + /// for cross-app consistency. + /// /// /// The user's LDAP groups, as returned by . NOTE /// (review C1): these are already-normalized short RDN names (e.g. @@ -97,13 +105,21 @@ public sealed class DashboardAuthenticator( { List claims = [ + // Keep NameIdentifier so any existing read-site that uses it continues to work. new Claim(ClaimTypes.NameIdentifier, username), - new Claim(ClaimTypes.Name, displayName), + // Canonical login-username claim (Task 1.5). + new Claim(ZbClaimTypes.Username, username), + // ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution. + new Claim(ZbClaimTypes.Name, displayName), + // Canonical display-name claim for cross-app consistency (Task 1.5). + new Claim(ZbClaimTypes.DisplayName, displayName), ]; - claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + // ZbClaimTypes.Role == ClaimTypes.Role — drives IsInRole and [Authorize(Roles=...)]. + claims.AddRange(roles.Select(role => new Claim(ZbClaimTypes.Role, role))); // Groups are short RDN names from ILdapAuthService (see param doc above), so // this claim value is the short group name, not the original DN. + // LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups. claims.AddRange(groups.Select(group => new Claim( DashboardAuthenticationDefaults.LdapGroupClaimType, group))); @@ -111,8 +127,8 @@ public sealed class DashboardAuthenticator( ClaimsIdentity claimsIdentity = new( claims, DashboardAuthenticationDefaults.AuthenticationScheme, - ClaimTypes.Name, - ClaimTypes.Role); + ZbClaimTypes.Name, + ZbClaimTypes.Role); return new ClaimsPrincipal(claimsIdentity); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index 98bf16e..da8619a 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -57,23 +57,25 @@ public static class DashboardServiceCollectionExtensions .AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme) .AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions => { + // Hardened defaults (HttpOnly, SameSite=Strict, SecurePolicy, SlidingExpiration, + // ExpireTimeSpan) via the shared ZbCookieDefaults.Apply. requireHttps is set to + // its default (true / Always) here and overridden per-environment by the + // PostConfigure below; the 8-hour idle timeout is preserved (not the 30-min default). + ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8)); + // Cookie name, path, and redirect paths are MxGateway-specific — set after Apply + // so they are never overwritten by the shared helper (Apply intentionally skips name). cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName; - cookieOptions.Cookie.HttpOnly = true; - cookieOptions.Cookie.SameSite = SameSiteMode.Strict; - // SecurePolicy is bound via PostConfigure below so it can honour - // DashboardOptions.RequireHttpsCookie (default Always; dev HTTP - // deployments set RequireHttpsCookie=false to use SameAsRequest). cookieOptions.Cookie.Path = "/"; cookieOptions.LoginPath = "/login"; cookieOptions.LogoutPath = "/logout"; cookieOptions.AccessDeniedPath = "/denied"; - cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8); - cookieOptions.SlidingExpiration = true; }) .AddScheme( DashboardAuthenticationDefaults.HubAuthenticationScheme, _ => { }); + // Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev + // HTTP deployments → SameAsRequest). This overrides the Apply default above. services.AddOptions(DashboardAuthenticationDefaults.AuthenticationScheme) .Configure>((cookieOptions, gatewayOptions) => { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs index 49ba362..48fd21c 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Abstractions.Roles; +using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; @@ -125,6 +126,59 @@ public sealed class DashboardAuthenticatorTests Assert.Equal(["GwAdmin", "GwReader"], groupClaims); } + /// + /// Task 1.5: the principal emits canonical ZbClaimTypes.Username ("zb:username") with + /// the login username, ZbClaimTypes.Role (= ClaimTypes.Role) for each resolved role, and + /// ZbClaimTypes.DisplayName ("zb:displayname") with the display name — while keeping + /// ClaimTypes.NameIdentifier, ClaimTypes.Name, and mxgateway:ldap_group claims intact. + /// + [Fact] + public async Task AuthenticateAsync_Success_EmitsCanonicalZbClaims() + { + FakeLdapAuthService ldap = new(LdapAuthResult.Success( + username: "jsmith", + displayName: "John Smith", + groups: ["GwAdmin", "GwReader"])); + DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping()); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "jsmith", + "pw", + CancellationToken.None); + + Assert.True(result.Succeeded); + ClaimsPrincipal principal = Assert.IsType(result.Principal); + + // ZbClaimTypes.Username ("zb:username") carries the login username. + Assert.Equal("jsmith", principal.FindFirstValue(ZbClaimTypes.Username)); + + // ZbClaimTypes.DisplayName ("zb:displayname") carries the display name. + Assert.Equal("John Smith", principal.FindFirstValue(ZbClaimTypes.DisplayName)); + + // ZbClaimTypes.Role (= ClaimTypes.Role) is used for each role claim. + IReadOnlyList roleClaims = principal + .FindAll(ZbClaimTypes.Role) + .Select(c => c.Value) + .OrderBy(r => r) + .ToList(); + Assert.Contains(DashboardRoles.Admin, roleClaims); + Assert.Contains(DashboardRoles.Viewer, roleClaims); + + // IsInRole still works (identity built with roleType = ZbClaimTypes.Role). + Assert.True(principal.IsInRole(DashboardRoles.Admin)); + Assert.True(principal.IsInRole(DashboardRoles.Viewer)); + + // ClaimTypes.Name still resolves as Identity.Name (nameType = ZbClaimTypes.Name = ClaimTypes.Name). + Assert.Equal("John Smith", principal.Identity?.Name); + + // mxgateway:ldap_group claims are preserved. + IReadOnlyList groups = principal + .FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType) + .Select(c => c.Value) + .ToList(); + Assert.Equal(["GwAdmin", "GwReader"], groups); + } + /// /// When the user authenticates but none of their groups map to a dashboard role, /// the login is denied (the long-standing "no roles matched → denied" rule).