f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
295 lines
11 KiB
C#
295 lines
11 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
|
|
|
public sealed class HubTokenServiceTests
|
|
{
|
|
/// <summary>
|
|
/// A token whose data-protected payload has both
|
|
/// <c>Name</c> and <c>NameIdentifier</c> null (the principal that
|
|
/// minted the token had no identity claims) must be rejected by
|
|
/// <see cref="HubTokenService.Validate"/>. The role claims alone are
|
|
/// not enough — without a caller identity, the resulting
|
|
/// <see cref="ClaimsPrincipal"/> would satisfy
|
|
/// <c>IsAuthenticated</c> / <c>IsInRole</c> checks without an
|
|
/// associated user.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
// Issue from a principal with NO Name claim and NO NameIdentifier
|
|
// claim. The Issue method's payload will then carry
|
|
// (Name = null, NameIdentifier = null, Roles = ["Viewer"]).
|
|
ClaimsIdentity identity = new(
|
|
[new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
|
|
authenticationType: "test");
|
|
ClaimsPrincipal principal = new(identity);
|
|
string token = service.Issue(principal);
|
|
|
|
ClaimsPrincipal? result = service.Validate(token);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sanity check: a token minted from a principal with a Name claim
|
|
/// validates and returns a principal carrying that identity. Pins
|
|
/// that the null-identity rejection above does not over-reject valid tokens.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.Name, "alice"),
|
|
new Claim(ClaimTypes.NameIdentifier, "alice-id"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
|
],
|
|
authenticationType: "test",
|
|
nameType: ClaimTypes.Name,
|
|
roleType: ClaimTypes.Role);
|
|
ClaimsPrincipal principal = new(identity);
|
|
string token = service.Issue(principal);
|
|
|
|
ClaimsPrincipal? result = service.Validate(token);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("alice", result.Identity?.Name);
|
|
Assert.True(result.IsInRole(DashboardRoles.Admin));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sanity check: a token minted with only a NameIdentifier (no Name)
|
|
/// still validates — a non-null caller identity is the contract,
|
|
/// either field is sufficient.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.NameIdentifier, "alice-id"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
|
],
|
|
authenticationType: "test");
|
|
ClaimsPrincipal principal = new(identity);
|
|
string token = service.Issue(principal);
|
|
|
|
ClaimsPrincipal? result = service.Validate(token);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.True(result.IsInRole(DashboardRoles.Viewer));
|
|
}
|
|
|
|
/// <summary>Verifies that a null token returns null.</summary>
|
|
[Fact]
|
|
public void Validate_NullToken_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
Assert.Null(service.Validate(null));
|
|
}
|
|
|
|
/// <summary>Verifies that an empty token returns null.</summary>
|
|
[Fact]
|
|
public void Validate_EmptyToken_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
Assert.Null(service.Validate(string.Empty));
|
|
}
|
|
|
|
/// <summary>Verifies that an invalid token returns null.</summary>
|
|
[Fact]
|
|
public void Validate_GarbageToken_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issue/validate round-trip: a freshly minted token (default <see cref="HubTokenService.TokenLifetime"/>)
|
|
/// validates and reconstructs the caller's identity and roles.
|
|
/// </summary>
|
|
[Fact]
|
|
public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.Name, "bob"),
|
|
new Claim(ClaimTypes.NameIdentifier, "bob-id"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
|
],
|
|
authenticationType: "test",
|
|
nameType: ClaimTypes.Name,
|
|
roleType: ClaimTypes.Role);
|
|
|
|
string token = service.Issue(new ClaimsPrincipal(identity));
|
|
ClaimsPrincipal? result = service.Validate(token);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("bob", result.Identity?.Name);
|
|
Assert.True(result.IsInRole(DashboardRoles.Viewer));
|
|
Assert.True(result.IsInRole(DashboardRoles.Admin));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The default token lifetime is the short (5-minute) window, not the
|
|
/// former 30-minute window. Pins the value so a regression that widens the exposure window
|
|
/// of an irrevocable, query-string-carried token is caught in CI.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TokenLifetime_IsFiveMinutes()
|
|
{
|
|
Assert.Equal(TimeSpan.FromMinutes(5), HubTokenService.TokenLifetime);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A token whose lifetime has elapsed is rejected by <see cref="HubTokenService.Validate"/>.
|
|
/// Uses the internal lifetime-issuing seam with a negative lifetime so the token is already
|
|
/// expired at mint time — deterministic, no wall-clock delay.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_ExpiredToken_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
ClaimsIdentity identity = new(
|
|
[new Claim(ClaimTypes.Name, "carol")],
|
|
authenticationType: "test");
|
|
|
|
string expiredToken = service.Issue(
|
|
new ClaimsPrincipal(identity),
|
|
TimeSpan.FromMinutes(-1));
|
|
|
|
Assert.Null(service.Validate(expiredToken));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The dashboard visibility grant survives the mint/validate round-trip: tags are
|
|
/// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at
|
|
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
|
|
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal
|
|
/// <see cref="HubTokenService.Validate"/> reconstructs — which is the principal
|
|
/// <c>IDashboardSessionAcl</c> reads on the hub path.
|
|
/// </summary>
|
|
[Fact]
|
|
public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags()
|
|
{
|
|
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["GwViewer"] = ["team-a"],
|
|
["TeamBViewers"] = ["team-b"],
|
|
});
|
|
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.Name, "dana"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
|
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
|
|
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "TeamBViewers"),
|
|
],
|
|
authenticationType: "test",
|
|
nameType: ClaimTypes.Name,
|
|
roleType: ClaimTypes.Role);
|
|
|
|
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(
|
|
["team-a", "team-b"],
|
|
result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)
|
|
.Select(c => c.Value)
|
|
.Order(StringComparer.Ordinal));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A caller whose groups map to nothing mints a token with no tags, and validating it yields a
|
|
/// principal carrying no tag claims — the empty grant the ACL denies tagged sessions on. This
|
|
/// is also the shape of every token minted before the tag field existed (the payload field
|
|
/// deserializes to null), so the fail-closed direction is covered for both.
|
|
/// </summary>
|
|
[Fact]
|
|
public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant()
|
|
{
|
|
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["SomeOtherGroup"] = ["team-a"],
|
|
});
|
|
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.Name, "erin"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
|
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
|
|
],
|
|
authenticationType: "test",
|
|
nameType: ClaimTypes.Name,
|
|
roleType: ClaimTypes.Role);
|
|
|
|
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A token minted before the payload carried tags at all still validates, and yields an empty
|
|
/// grant rather than throwing or rejecting. Distinct from the empty-grant test above, which
|
|
/// exercises a <c>Tags</c> key that is present and empty: this one protects a hand-built
|
|
/// payload with the key genuinely ABSENT, which is the shape every in-flight token has across
|
|
/// the deploy that introduces the field. Deserialization leaves the field null, and the
|
|
/// null-coalesce in <c>Validate</c> is the only thing standing between that and a crash on
|
|
/// the hub's authentication path.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_TokenMintedBeforeTagsFieldExisted_YieldsEmptyGrant()
|
|
{
|
|
EphemeralDataProtectionProvider dataProtection = new();
|
|
HubTokenService service = CreateService(dataProtection: dataProtection);
|
|
|
|
// The pre-field payload shape, verbatim: no "Tags" key anywhere.
|
|
const string LegacyPayload = """{"Name":"frank","NameIdentifier":"frank-id","Roles":["Viewer"]}""";
|
|
string legacyToken = dataProtection
|
|
.CreateProtector(HubTokenService.ProtectorPurpose)
|
|
.ToTimeLimitedDataProtector()
|
|
.Protect(LegacyPayload, HubTokenService.TokenLifetime);
|
|
|
|
ClaimsPrincipal? result = service.Validate(legacyToken);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("frank", result.Identity?.Name);
|
|
Assert.True(result.IsInRole(DashboardRoles.Viewer));
|
|
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
|
|
}
|
|
|
|
private static HubTokenService CreateService(
|
|
Dictionary<string, string[]>? groupToTag = null,
|
|
IDataProtectionProvider? dataProtection = null)
|
|
{
|
|
GatewayOptions options = new()
|
|
{
|
|
Dashboard = new DashboardOptions
|
|
{
|
|
GroupToTag = groupToTag ?? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase),
|
|
},
|
|
};
|
|
|
|
return new HubTokenService(
|
|
dataProtection ?? new EphemeralDataProtectionProvider(),
|
|
Options.Create(options));
|
|
}
|
|
}
|