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.
This commit is contained in:
Joseph Doherty
2026-06-02 06:10:48 -04:00
parent 05009d7370
commit 7e1af37eb1
3 changed files with 85 additions and 13 deletions
@@ -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(
/// <summary>
/// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
/// </summary>
/// <param name="username">The (trimmed) login name → <see cref="ClaimTypes.NameIdentifier"/>.</param>
/// <param name="displayName">The user's display name → <see cref="ClaimTypes.Name"/>.</param>
/// <param name="username">
/// The (trimmed) login name. Emitted as <see cref="ClaimTypes.NameIdentifier"/> (kept for
/// back-compat reads) and as the canonical <see cref="ZbClaimTypes.Username"/> ("zb:username").
/// </param>
/// <param name="displayName">
/// The user's display name. Emitted as <see cref="ZbClaimTypes.Name"/> (= <see cref="ClaimTypes.Name"/>
/// so <c>Identity.Name</c> resolves) and as <see cref="ZbClaimTypes.DisplayName"/> ("zb:displayname")
/// for cross-app consistency.
/// </param>
/// <param name="groups">
/// The user's LDAP groups, as returned by <see cref="ILdapAuthService"/>. NOTE
/// (review C1): these are <b>already-normalized short RDN names</b> (e.g.
@@ -97,13 +105,21 @@ public sealed class DashboardAuthenticator(
{
List<Claim> 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);
}
@@ -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<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
DashboardAuthenticationDefaults.HubAuthenticationScheme,
_ => { });
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
// HTTP deployments → SameAsRequest). This overrides the Apply default above.
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
{
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
[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<ClaimsPrincipal>(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<string> 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<string> groups = principal
.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(c => c.Value)
.ToList();
Assert.Equal(["GwAdmin", "GwReader"], groups);
}
/// <summary>
/// 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).