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).