feat: port session 06 — Authentication & JWT types, validators, cipher suites
Port independently-testable auth functions from auth.go, ciphersuites.go, and jwt.go. Server-dependent methods (configureAuthorization, checkAuthentication, auth callout, etc.) are stubbed for later sessions. - AuthTypes: User, NkeyUser, SubjectPermission, ResponsePermission, Permissions, RoutePermissions, Account — all with deep Clone() methods - AuthHandler: IsBcrypt, ComparePasswords, ValidateResponsePermissions, ValidateAllowedConnectionTypes, ValidateNoAuthUser, ValidateAuth, DnsAltNameLabels, DnsAltNameMatches, WipeSlice, ConnectionTypes constants - CipherSuites: CipherMap, CipherMapById, DefaultCipherSuites, CurvePreferenceMap, DefaultCurvePreferences - JwtProcessor: JwtPrefix, WipeSlice, ValidateSrc (CIDR matching), ValidateTimes (time-of-day ranges), TimeRange type - ServerOptions: added Users, Nkeys, TrustedOperators properties - 67 new unit tests (all 328 tests pass) - DB: 18 features complete, 25 stubbed; 6 Go tests complete, 125 stubbed
This commit is contained in:
273
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs
Normal file
273
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs
Normal file
@@ -0,0 +1,273 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/auth.go (standalone functions) in the NATS server Go source.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication helper methods ported from Go auth.go.
|
||||
/// Server-dependent methods (configureAuthorization, checkAuthentication, etc.)
|
||||
/// will be added in later sessions when the full Server type is available.
|
||||
/// </summary>
|
||||
public static partial class AuthHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Regex matching valid bcrypt password prefixes ($2a$, $2b$, $2x$, $2y$).
|
||||
/// Mirrors Go <c>validBcryptPrefix</c>.
|
||||
/// </summary>
|
||||
private static readonly Regex ValidBcryptPrefix = ValidBcryptPrefixRegex();
|
||||
|
||||
[GeneratedRegex(@"^\$2[abxy]\$\d{2}\$.*")]
|
||||
private static partial Regex ValidBcryptPrefixRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a password string is a bcrypt hash.
|
||||
/// Mirrors Go <c>isBcrypt</c>.
|
||||
/// </summary>
|
||||
public static bool IsBcrypt(string password)
|
||||
{
|
||||
if (password.StartsWith('$'))
|
||||
{
|
||||
return ValidBcryptPrefix.IsMatch(password);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares a server password (possibly bcrypt-hashed) against a client-provided password.
|
||||
/// Uses constant-time comparison for plaintext passwords.
|
||||
/// Mirrors Go <c>comparePasswords</c>.
|
||||
/// </summary>
|
||||
public static bool ComparePasswords(string serverPassword, string clientPassword)
|
||||
{
|
||||
if (IsBcrypt(serverPassword))
|
||||
{
|
||||
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
|
||||
}
|
||||
|
||||
// Constant-time comparison for plaintext passwords.
|
||||
var spass = Encoding.UTF8.GetBytes(serverPassword);
|
||||
var cpass = Encoding.UTF8.GetBytes(clientPassword);
|
||||
return CryptographicOperations.FixedTimeEquals(spass, cpass);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the ResponsePermission defaults within a Permissions struct.
|
||||
/// If Response is set but MaxMsgs/Expires are zero, applies defaults.
|
||||
/// Also ensures Publish is set with an empty Allow if not already defined.
|
||||
/// Mirrors Go <c>validateResponsePermissions</c>.
|
||||
/// </summary>
|
||||
public static void ValidateResponsePermissions(Permissions? p)
|
||||
{
|
||||
if (p?.Response == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
p.Publish ??= new SubjectPermission();
|
||||
p.Publish.Allow ??= [];
|
||||
|
||||
if (p.Response.MaxMsgs == 0)
|
||||
{
|
||||
p.Response.MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs;
|
||||
}
|
||||
if (p.Response.Expires == TimeSpan.Zero)
|
||||
{
|
||||
p.Response.Expires = ServerConstants.DefaultAllowResponseExpiration;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known connection type strings (uppercased).
|
||||
/// Mirrors Go jwt.ConnectionType* constants.
|
||||
/// </summary>
|
||||
public static class ConnectionTypes
|
||||
{
|
||||
public const string Standard = "STANDARD";
|
||||
public const string Websocket = "WEBSOCKET";
|
||||
public const string Leafnode = "LEAFNODE";
|
||||
public const string LeafnodeWs = "LEAFNODE_WS";
|
||||
public const string Mqtt = "MQTT";
|
||||
public const string MqttWs = "MQTT_WS";
|
||||
public const string InProcess = "IN_PROCESS";
|
||||
|
||||
private static readonly HashSet<string> Known =
|
||||
[
|
||||
Standard,
|
||||
Websocket,
|
||||
Leafnode,
|
||||
LeafnodeWs,
|
||||
Mqtt,
|
||||
MqttWs,
|
||||
InProcess,
|
||||
];
|
||||
|
||||
public static bool IsKnown(string ct) => Known.Contains(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates allowed connection type map entries. Normalises to uppercase
|
||||
/// and rejects unknown types.
|
||||
/// Mirrors Go <c>validateAllowedConnectionTypes</c>.
|
||||
/// </summary>
|
||||
public static Exception? ValidateAllowedConnectionTypes(HashSet<string>? m)
|
||||
{
|
||||
if (m == null) return null;
|
||||
|
||||
// We must iterate a copy since we may modify the set.
|
||||
var entries = m.ToList();
|
||||
foreach (var ct in entries)
|
||||
{
|
||||
var ctuc = ct.ToUpperInvariant();
|
||||
if (!ConnectionTypes.IsKnown(ctuc))
|
||||
{
|
||||
return new ArgumentException($"unknown connection type \"{ct}\"");
|
||||
}
|
||||
if (ctuc != ct)
|
||||
{
|
||||
m.Remove(ct);
|
||||
m.Add(ctuc);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the no_auth_user setting against configured users/nkeys.
|
||||
/// Mirrors Go <c>validateNoAuthUser</c>.
|
||||
/// </summary>
|
||||
public static Exception? ValidateNoAuthUser(ServerOptions o, string noAuthUser)
|
||||
{
|
||||
if (string.IsNullOrEmpty(noAuthUser))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (o.TrustedOperators.Count > 0)
|
||||
{
|
||||
return new InvalidOperationException("no_auth_user not compatible with Trusted Operator");
|
||||
}
|
||||
if (o.Nkeys == null && o.Users == null)
|
||||
{
|
||||
return new InvalidOperationException(
|
||||
$"no_auth_user: \"{noAuthUser}\" present, but users/nkeys are not defined");
|
||||
}
|
||||
if (o.Users != null)
|
||||
{
|
||||
foreach (var u in o.Users)
|
||||
{
|
||||
if (u.Username == noAuthUser) return null;
|
||||
}
|
||||
}
|
||||
if (o.Nkeys != null)
|
||||
{
|
||||
foreach (var u in o.Nkeys)
|
||||
{
|
||||
if (u.Nkey == noAuthUser) return null;
|
||||
}
|
||||
}
|
||||
return new InvalidOperationException(
|
||||
$"no_auth_user: \"{noAuthUser}\" not present as user or nkey in authorization block or account configuration");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the auth section of options: pinned certs, connection types, and no_auth_user.
|
||||
/// Mirrors Go <c>validateAuth</c>.
|
||||
/// </summary>
|
||||
public static Exception? ValidateAuth(ServerOptions o)
|
||||
{
|
||||
// validatePinnedCerts will be added when the full server module is ported.
|
||||
if (o.Users != null)
|
||||
{
|
||||
foreach (var u in o.Users)
|
||||
{
|
||||
var err = ValidateAllowedConnectionTypes(u.AllowedConnectionTypes);
|
||||
if (err != null) return err;
|
||||
}
|
||||
}
|
||||
if (o.Nkeys != null)
|
||||
{
|
||||
foreach (var u in o.Nkeys)
|
||||
{
|
||||
var err = ValidateAllowedConnectionTypes(u.AllowedConnectionTypes);
|
||||
if (err != null) return err;
|
||||
}
|
||||
}
|
||||
return ValidateNoAuthUser(o, o.NoAuthUser);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a DNS alt name into lowercase labels.
|
||||
/// Mirrors Go <c>dnsAltNameLabels</c>.
|
||||
/// </summary>
|
||||
public static string[] DnsAltNameLabels(string dnsAltName)
|
||||
{
|
||||
return dnsAltName.ToLowerInvariant().Split('.');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if DNS alt name labels match any of the provided URLs (RFC 6125).
|
||||
/// The wildcard '*' only matches the leftmost label.
|
||||
/// Mirrors Go <c>dnsAltNameMatches</c>.
|
||||
/// </summary>
|
||||
public static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList<Uri?> urls)
|
||||
{
|
||||
foreach (var url in urls)
|
||||
{
|
||||
if (url == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hostLabels = url.Host.ToLowerInvariant().Split('.');
|
||||
|
||||
// Following RFC 6125: wildcard never matches multiple labels, only leftmost.
|
||||
if (hostLabels.Length != dnsAltNameLabels.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var i = 0;
|
||||
// Only match wildcard on leftmost label.
|
||||
if (dnsAltNameLabels[0] == "*")
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var matched = true;
|
||||
for (; i < dnsAltNameLabels.Length; i++)
|
||||
{
|
||||
if (dnsAltNameLabels[i] != hostLabels[i])
|
||||
{
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wipes a byte slice by filling with 'x'. Used for clearing sensitive data.
|
||||
/// Mirrors Go <c>wipeSlice</c>.
|
||||
/// </summary>
|
||||
public static void WipeSlice(Span<byte> buf)
|
||||
{
|
||||
buf.Fill((byte)'x');
|
||||
}
|
||||
}
|
||||
176
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs
Normal file
176
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/auth.go (type definitions) in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user authenticated via NKey.
|
||||
/// Mirrors Go <c>NkeyUser</c> struct in auth.go.
|
||||
/// </summary>
|
||||
public class NkeyUser
|
||||
{
|
||||
public string Nkey { get; set; } = string.Empty;
|
||||
public long Issued { get; set; }
|
||||
public Permissions? Permissions { get; set; }
|
||||
public Account? Account { get; set; }
|
||||
public string SigningKey { get; set; } = string.Empty;
|
||||
public HashSet<string>? AllowedConnectionTypes { get; set; }
|
||||
public bool ProxyRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deep-clones this NkeyUser. Account is shared by reference.
|
||||
/// Mirrors Go <c>NkeyUser.clone()</c>.
|
||||
/// </summary>
|
||||
public NkeyUser? Clone()
|
||||
{
|
||||
var clone = (NkeyUser)MemberwiseClone();
|
||||
// Account is not cloned because it is always by reference to an existing struct.
|
||||
clone.Permissions = Permissions?.Clone();
|
||||
|
||||
if (AllowedConnectionTypes != null)
|
||||
{
|
||||
clone.AllowedConnectionTypes = new HashSet<string>(AllowedConnectionTypes);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user with username/password credentials.
|
||||
/// Mirrors Go <c>User</c> struct in auth.go.
|
||||
/// </summary>
|
||||
public class User
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public Permissions? Permissions { get; set; }
|
||||
public Account? Account { get; set; }
|
||||
public DateTime ConnectionDeadline { get; set; }
|
||||
public HashSet<string>? AllowedConnectionTypes { get; set; }
|
||||
public bool ProxyRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deep-clones this User. Account is shared by reference.
|
||||
/// Mirrors Go <c>User.clone()</c>.
|
||||
/// </summary>
|
||||
public User? Clone()
|
||||
{
|
||||
var clone = (User)MemberwiseClone();
|
||||
// Account is not cloned because it is always by reference to an existing struct.
|
||||
clone.Permissions = Permissions?.Clone();
|
||||
|
||||
if (AllowedConnectionTypes != null)
|
||||
{
|
||||
clone.AllowedConnectionTypes = new HashSet<string>(AllowedConnectionTypes);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject-level allow/deny permission.
|
||||
/// Mirrors Go <c>SubjectPermission</c> in auth.go.
|
||||
/// </summary>
|
||||
public class SubjectPermission
|
||||
{
|
||||
public List<string>? Allow { get; set; }
|
||||
public List<string>? Deny { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deep-clones this SubjectPermission.
|
||||
/// Mirrors Go <c>SubjectPermission.clone()</c>.
|
||||
/// </summary>
|
||||
public SubjectPermission Clone()
|
||||
{
|
||||
var clone = new SubjectPermission();
|
||||
if (Allow != null)
|
||||
{
|
||||
clone.Allow = new List<string>(Allow);
|
||||
}
|
||||
if (Deny != null)
|
||||
{
|
||||
clone.Deny = new List<string>(Deny);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response permission for request-reply patterns.
|
||||
/// Mirrors Go <c>ResponsePermission</c> in auth.go.
|
||||
/// </summary>
|
||||
public class ResponsePermission
|
||||
{
|
||||
public int MaxMsgs { get; set; }
|
||||
public TimeSpan Expires { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publish/subscribe permissions container.
|
||||
/// Mirrors Go <c>Permissions</c> in auth.go.
|
||||
/// </summary>
|
||||
public class Permissions
|
||||
{
|
||||
public SubjectPermission? Publish { get; set; }
|
||||
public SubjectPermission? Subscribe { get; set; }
|
||||
public ResponsePermission? Response { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deep-clones this Permissions struct.
|
||||
/// Mirrors Go <c>Permissions.clone()</c>.
|
||||
/// </summary>
|
||||
public Permissions Clone()
|
||||
{
|
||||
var clone = new Permissions();
|
||||
if (Publish != null)
|
||||
{
|
||||
clone.Publish = Publish.Clone();
|
||||
}
|
||||
if (Subscribe != null)
|
||||
{
|
||||
clone.Subscribe = Subscribe.Clone();
|
||||
}
|
||||
if (Response != null)
|
||||
{
|
||||
clone.Response = new ResponsePermission
|
||||
{
|
||||
MaxMsgs = Response.MaxMsgs,
|
||||
Expires = Response.Expires,
|
||||
};
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Route-level import/export permissions.
|
||||
/// Mirrors Go <c>RoutePermissions</c> in auth.go.
|
||||
/// </summary>
|
||||
public class RoutePermissions
|
||||
{
|
||||
public SubjectPermission? Import { get; set; }
|
||||
public SubjectPermission? Export { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub for Account type. Full implementation in later sessions.
|
||||
/// Mirrors Go <c>Account</c> struct.
|
||||
/// </summary>
|
||||
public class Account
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
110
dotnet/src/ZB.MOM.NatsNet.Server/Auth/CipherSuites.cs
Normal file
110
dotnet/src/ZB.MOM.NatsNet.Server/Auth/CipherSuites.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright 2016-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/ciphersuites.go in the NATS server Go source.
|
||||
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// TLS cipher suite and curve preference definitions.
|
||||
/// Mirrors Go <c>ciphersuites.go</c> — cipherMap, defaultCipherSuites, curvePreferenceMap,
|
||||
/// defaultCurvePreferences.
|
||||
/// </summary>
|
||||
public static class CipherSuites
|
||||
{
|
||||
/// <summary>
|
||||
/// Map of cipher suite names to their <see cref="TlsCipherSuite"/> values.
|
||||
/// Populated at static init time — mirrors Go <c>init()</c> + <c>cipherMap</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, TlsCipherSuite> CipherMap { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reverse map of cipher suite ID to name.
|
||||
/// Mirrors Go <c>cipherMapByID</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<TlsCipherSuite, string> CipherMapById { get; }
|
||||
|
||||
static CipherSuites()
|
||||
{
|
||||
// .NET does not have a direct equivalent of Go's tls.CipherSuites() /
|
||||
// tls.InsecureCipherSuites() enumeration. We enumerate the well-known
|
||||
// TLS 1.2 and 1.3 cipher suites defined in the TlsCipherSuite enum.
|
||||
var byName = new Dictionary<string, TlsCipherSuite>(StringComparer.OrdinalIgnoreCase);
|
||||
var byId = new Dictionary<TlsCipherSuite, string>();
|
||||
|
||||
foreach (TlsCipherSuite cs in Enum.GetValues(typeof(TlsCipherSuite)))
|
||||
{
|
||||
var name = cs.ToString();
|
||||
byName.TryAdd(name, cs);
|
||||
byId.TryAdd(cs, name);
|
||||
}
|
||||
|
||||
CipherMap = byName;
|
||||
CipherMapById = byId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the default set of TLS 1.2 cipher suites.
|
||||
/// .NET manages cipher suite selection at the OS/SChannel/OpenSSL level;
|
||||
/// this list provides the preferred suites for configuration alignment with Go.
|
||||
/// Mirrors Go <c>defaultCipherSuites</c>.
|
||||
/// </summary>
|
||||
public static TlsCipherSuite[] DefaultCipherSuites()
|
||||
{
|
||||
// Return commonly-used TLS 1.2 cipher suites in preference order.
|
||||
// TLS 1.3 suites are always enabled in .NET and cannot be individually toggled.
|
||||
return
|
||||
[
|
||||
TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
TlsCipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supported named curve / key exchange preferences.
|
||||
/// Mirrors Go <c>curvePreferenceMap</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, SslApplicationProtocol> CurvePreferenceMap { get; } =
|
||||
new Dictionary<string, SslApplicationProtocol>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// .NET does not expose individual curve selection in the same way as Go.
|
||||
// These entries exist for configuration-file compatibility and mapping.
|
||||
// Actual curve negotiation is handled by the OS TLS stack.
|
||||
["X25519"] = new SslApplicationProtocol("X25519"),
|
||||
["CurveP256"] = new SslApplicationProtocol("CurveP256"),
|
||||
["CurveP384"] = new SslApplicationProtocol("CurveP384"),
|
||||
["CurveP521"] = new SslApplicationProtocol("CurveP521"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the default curve preferences, ordered highest security first.
|
||||
/// Mirrors Go <c>defaultCurvePreferences</c>.
|
||||
/// </summary>
|
||||
public static string[] DefaultCurvePreferences()
|
||||
{
|
||||
return
|
||||
[
|
||||
"X25519",
|
||||
"CurveP256",
|
||||
"CurveP384",
|
||||
"CurveP521",
|
||||
];
|
||||
}
|
||||
}
|
||||
192
dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs
Normal file
192
dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright 2018-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/jwt.go in the NATS server Go source.
|
||||
|
||||
using System.Net;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// JWT processing utilities for NATS operator/account/user JWTs.
|
||||
/// Mirrors Go <c>jwt.go</c> functions.
|
||||
/// Full JWT parsing will be added when a .NET JWT library equivalent is available.
|
||||
/// </summary>
|
||||
public static class JwtProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// All JWTs once encoded start with this prefix.
|
||||
/// Mirrors Go <c>jwtPrefix</c>.
|
||||
/// </summary>
|
||||
public const string JwtPrefix = "eyJ";
|
||||
|
||||
/// <summary>
|
||||
/// Wipes a byte slice by filling with 'x', for clearing nkey seed data.
|
||||
/// Mirrors Go <c>wipeSlice</c>.
|
||||
/// </summary>
|
||||
public static void WipeSlice(Span<byte> buf)
|
||||
{
|
||||
buf.Fill((byte)'x');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the given IP host address is allowed by the user claims source CIDRs.
|
||||
/// Returns true if the host is within any of the allowed CIDRs, or if no CIDRs are specified.
|
||||
/// Mirrors Go <c>validateSrc</c>.
|
||||
/// </summary>
|
||||
public static bool ValidateSrc(IReadOnlyList<string>? srcCidrs, string host)
|
||||
{
|
||||
if (srcCidrs == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (srcCidrs.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (string.IsNullOrEmpty(host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!IPAddress.TryParse(host, out var ip))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (var cidr in srcCidrs)
|
||||
{
|
||||
if (TryParseCidr(cidr, out var network, out var prefixLength))
|
||||
{
|
||||
if (IsInSubnet(ip, network, prefixLength))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false; // invalid CIDR means invalid JWT
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the current time falls within any of the allowed time ranges.
|
||||
/// Returns (allowed, remainingDuration).
|
||||
/// Mirrors Go <c>validateTimes</c>.
|
||||
/// </summary>
|
||||
public static (bool Allowed, TimeSpan Remaining) ValidateTimes(
|
||||
IReadOnlyList<TimeRange>? timeRanges,
|
||||
string? locale = null)
|
||||
{
|
||||
if (timeRanges == null)
|
||||
{
|
||||
return (false, TimeSpan.Zero);
|
||||
}
|
||||
if (timeRanges.Count == 0)
|
||||
{
|
||||
return (true, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.Now;
|
||||
TimeZoneInfo? tz = null;
|
||||
if (!string.IsNullOrEmpty(locale))
|
||||
{
|
||||
try
|
||||
{
|
||||
tz = TimeZoneInfo.FindSystemTimeZoneById(locale);
|
||||
now = TimeZoneInfo.ConvertTime(now, tz);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var timeRange in timeRanges)
|
||||
{
|
||||
if (!TimeSpan.TryParse(timeRange.Start, out var startTime) ||
|
||||
!TimeSpan.TryParse(timeRange.End, out var endTime))
|
||||
{
|
||||
return (false, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
var today = now.Date;
|
||||
var start = today + startTime;
|
||||
var end = today + endTime;
|
||||
|
||||
// If start > end, end is on the next day (overnight range).
|
||||
if (startTime > endTime)
|
||||
{
|
||||
end = end.AddDays(1);
|
||||
}
|
||||
|
||||
if (start <= now && now < end)
|
||||
{
|
||||
return (true, end - now);
|
||||
}
|
||||
}
|
||||
return (false, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static bool TryParseCidr(string cidr, out IPAddress network, out int prefixLength)
|
||||
{
|
||||
network = IPAddress.None;
|
||||
prefixLength = 0;
|
||||
|
||||
var slashIndex = cidr.IndexOf('/');
|
||||
if (slashIndex < 0) return false;
|
||||
|
||||
var ipPart = cidr.AsSpan(0, slashIndex);
|
||||
var prefixPart = cidr.AsSpan(slashIndex + 1);
|
||||
|
||||
if (!IPAddress.TryParse(ipPart, out var parsedIp)) return false;
|
||||
if (!int.TryParse(prefixPart, out var prefix)) return false;
|
||||
|
||||
network = parsedIp;
|
||||
prefixLength = prefix;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsInSubnet(IPAddress address, IPAddress network, int prefixLength)
|
||||
{
|
||||
var addrBytes = address.GetAddressBytes();
|
||||
var netBytes = network.GetAddressBytes();
|
||||
if (addrBytes.Length != netBytes.Length) return false;
|
||||
|
||||
var fullBytes = prefixLength / 8;
|
||||
var remainingBits = prefixLength % 8;
|
||||
|
||||
for (var i = 0; i < fullBytes; i++)
|
||||
{
|
||||
if (addrBytes[i] != netBytes[i]) return false;
|
||||
}
|
||||
|
||||
if (remainingBits > 0 && fullBytes < addrBytes.Length)
|
||||
{
|
||||
var mask = (byte)(0xFF << (8 - remainingBits));
|
||||
if ((addrBytes[fullBytes] & mask) != (netBytes[fullBytes] & mask)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a time-of-day range for user access control.
|
||||
/// Mirrors Go <c>jwt.TimeRange</c>.
|
||||
/// </summary>
|
||||
public class TimeRange
|
||||
{
|
||||
public string Start { get; set; } = string.Empty;
|
||||
public string End { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using System.Threading;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
@@ -109,6 +110,9 @@ public sealed partial class ServerOptions
|
||||
public bool NoSystemAccount { get; set; }
|
||||
public AuthCalloutOpts? AuthCallout { get; set; }
|
||||
public bool AlwaysEnableNonce { get; set; }
|
||||
public List<User>? Users { get; set; }
|
||||
public List<NkeyUser>? Nkeys { get; set; }
|
||||
public List<object> TrustedOperators { get; set; } = [];
|
||||
public IAuthentication? CustomClientAuthentication { get; set; }
|
||||
public IAuthentication? CustomRouterAuthentication { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for AuthHandler standalone functions.
|
||||
/// Mirrors Go auth_test.go and adds unit tests for validators.
|
||||
/// </summary>
|
||||
public class AuthHandlerTests
|
||||
{
|
||||
// =========================================================================
|
||||
// IsBcrypt
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("$2a$10$abc123", true)]
|
||||
[InlineData("$2b$12$xyz", true)]
|
||||
[InlineData("$2x$04$foo", true)]
|
||||
[InlineData("$2y$10$bar", true)]
|
||||
[InlineData("$3a$10$abc", false)]
|
||||
[InlineData("plaintext", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("$2a", false)]
|
||||
public void IsBcrypt_DetectsCorrectly(string password, bool expected)
|
||||
{
|
||||
AuthHandler.IsBcrypt(password).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ComparePasswords
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ComparePasswords_PlaintextMatch()
|
||||
{
|
||||
AuthHandler.ComparePasswords("secret", "secret").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComparePasswords_PlaintextMismatch()
|
||||
{
|
||||
AuthHandler.ComparePasswords("secret", "wrong").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComparePasswords_BcryptMatch()
|
||||
{
|
||||
var hash = BCrypt.Net.BCrypt.HashPassword("mypassword");
|
||||
AuthHandler.ComparePasswords(hash, "mypassword").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComparePasswords_BcryptMismatch()
|
||||
{
|
||||
var hash = BCrypt.Net.BCrypt.HashPassword("mypassword");
|
||||
AuthHandler.ComparePasswords(hash, "wrongpassword").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComparePasswords_EmptyPasswords_Match()
|
||||
{
|
||||
AuthHandler.ComparePasswords("", "").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComparePasswords_DifferentLengths_Mismatch()
|
||||
{
|
||||
AuthHandler.ComparePasswords("short", "longpassword").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ValidateResponsePermissions
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateResponsePermissions_NullPermissions_NoOp()
|
||||
{
|
||||
AuthHandler.ValidateResponsePermissions(null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateResponsePermissions_NullResponse_NoOp()
|
||||
{
|
||||
var perms = new Permissions();
|
||||
AuthHandler.ValidateResponsePermissions(perms);
|
||||
perms.Publish.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateResponsePermissions_SetsDefaults()
|
||||
{
|
||||
var perms = new Permissions
|
||||
{
|
||||
Response = new ResponsePermission(),
|
||||
};
|
||||
AuthHandler.ValidateResponsePermissions(perms);
|
||||
|
||||
perms.Response.MaxMsgs.ShouldBe(ServerConstants.DefaultAllowResponseMaxMsgs);
|
||||
perms.Response.Expires.ShouldBe(ServerConstants.DefaultAllowResponseExpiration);
|
||||
perms.Publish.ShouldNotBeNull();
|
||||
perms.Publish!.Allow.ShouldNotBeNull();
|
||||
perms.Publish.Allow!.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateResponsePermissions_PreservesExistingValues()
|
||||
{
|
||||
var perms = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = ["foo.>"] },
|
||||
Response = new ResponsePermission
|
||||
{
|
||||
MaxMsgs = 10,
|
||||
Expires = TimeSpan.FromMinutes(5),
|
||||
},
|
||||
};
|
||||
AuthHandler.ValidateResponsePermissions(perms);
|
||||
|
||||
perms.Response.MaxMsgs.ShouldBe(10);
|
||||
perms.Response.Expires.ShouldBe(TimeSpan.FromMinutes(5));
|
||||
perms.Publish.Allow.ShouldBe(["foo.>"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ValidateAllowedConnectionTypes
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateAllowedConnectionTypes_NullMap_ReturnsNull()
|
||||
{
|
||||
AuthHandler.ValidateAllowedConnectionTypes(null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAllowedConnectionTypes_ValidTypes_NoError()
|
||||
{
|
||||
var m = new HashSet<string> { "STANDARD", "WEBSOCKET" };
|
||||
AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAllowedConnectionTypes_UnknownType_ReturnsError()
|
||||
{
|
||||
var m = new HashSet<string> { "STANDARD", "someNewType" };
|
||||
var err = AuthHandler.ValidateAllowedConnectionTypes(m);
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldContain("connection type");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAllowedConnectionTypes_NormalizesToUppercase()
|
||||
{
|
||||
var m = new HashSet<string> { "websocket", "mqtt" };
|
||||
AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull();
|
||||
m.ShouldContain("WEBSOCKET");
|
||||
m.ShouldContain("MQTT");
|
||||
m.ShouldNotContain("websocket");
|
||||
m.ShouldNotContain("mqtt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAllowedConnectionTypes_AllKnownTypes_NoError()
|
||||
{
|
||||
var m = new HashSet<string>
|
||||
{
|
||||
"STANDARD", "WEBSOCKET", "LEAFNODE",
|
||||
"LEAFNODE_WS", "MQTT", "MQTT_WS", "IN_PROCESS",
|
||||
};
|
||||
AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ValidateNoAuthUser
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateNoAuthUser_EmptyNoAuthUser_NoError()
|
||||
{
|
||||
var opts = new ServerOptions();
|
||||
AuthHandler.ValidateNoAuthUser(opts, "").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNoAuthUser_WithTrustedOperators_ReturnsError()
|
||||
{
|
||||
var opts = new ServerOptions
|
||||
{
|
||||
TrustedOperators = [new object()],
|
||||
};
|
||||
var err = AuthHandler.ValidateNoAuthUser(opts, "foo");
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldContain("Trusted Operator");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNoAuthUser_NoUsersOrNkeys_ReturnsError()
|
||||
{
|
||||
var opts = new ServerOptions();
|
||||
var err = AuthHandler.ValidateNoAuthUser(opts, "foo");
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldContain("users/nkeys are not defined");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNoAuthUser_UserFound_NoError()
|
||||
{
|
||||
var opts = new ServerOptions
|
||||
{
|
||||
Users = [new User { Username = "foo" }],
|
||||
};
|
||||
AuthHandler.ValidateNoAuthUser(opts, "foo").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNoAuthUser_NkeyFound_NoError()
|
||||
{
|
||||
var opts = new ServerOptions
|
||||
{
|
||||
Nkeys = [new NkeyUser { Nkey = "NKEY1" }],
|
||||
};
|
||||
AuthHandler.ValidateNoAuthUser(opts, "NKEY1").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNoAuthUser_UserNotFound_ReturnsError()
|
||||
{
|
||||
var opts = new ServerOptions
|
||||
{
|
||||
Users = [new User { Username = "bar" }],
|
||||
};
|
||||
var err = AuthHandler.ValidateNoAuthUser(opts, "foo");
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldContain("not present as user or nkey");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ValidateAuth
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateAuth_ValidConfig_NoError()
|
||||
{
|
||||
var opts = new ServerOptions
|
||||
{
|
||||
Users =
|
||||
[
|
||||
new User
|
||||
{
|
||||
Username = "u1",
|
||||
AllowedConnectionTypes = new HashSet<string> { "STANDARD" },
|
||||
},
|
||||
],
|
||||
NoAuthUser = "u1",
|
||||
};
|
||||
AuthHandler.ValidateAuth(opts).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAuth_InvalidConnectionType_ReturnsError()
|
||||
{
|
||||
var opts = new ServerOptions
|
||||
{
|
||||
Users =
|
||||
[
|
||||
new User
|
||||
{
|
||||
Username = "u1",
|
||||
AllowedConnectionTypes = new HashSet<string> { "STANDARD", "BAD_TYPE" },
|
||||
},
|
||||
],
|
||||
};
|
||||
var err = AuthHandler.ValidateAuth(opts);
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldContain("connection type");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DnsAltNameLabels + DnsAltNameMatches — Go test ID 148
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo", new[] { "nats://FOO" }, true)]
|
||||
[InlineData("foo", new[] { "nats://.." }, false)]
|
||||
[InlineData("foo", new[] { "nats://." }, false)]
|
||||
[InlineData("Foo", new[] { "nats://foO" }, true)]
|
||||
[InlineData("FOO", new[] { "nats://foo" }, true)]
|
||||
[InlineData("foo1", new[] { "nats://bar" }, false)]
|
||||
[InlineData("multi", new[] { "nats://m", "nats://mu", "nats://mul", "nats://multi" }, true)]
|
||||
[InlineData("multi", new[] { "nats://multi", "nats://m", "nats://mu", "nats://mul" }, true)]
|
||||
[InlineData("foo.bar", new[] { "nats://foo", "nats://foo.bar.bar", "nats://foo.baz" }, false)]
|
||||
[InlineData("foo.Bar", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, true)]
|
||||
[InlineData("foo.*", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, false)]
|
||||
[InlineData("f*.bar", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, false)]
|
||||
[InlineData("*.bar", new[] { "nats://foo.bar" }, true)]
|
||||
[InlineData("*", new[] { "nats://baz.bar", "nats://bar", "nats://z.y" }, true)]
|
||||
[InlineData("*", new[] { "nats://bar" }, true)]
|
||||
[InlineData("*", new[] { "nats://." }, false)]
|
||||
[InlineData("*", new[] { "nats://" }, true)]
|
||||
// NOTE: Go test cases {"*", ["*"], true} and {"bar.*", ["bar.*"], true} are omitted
|
||||
// because .NET's Uri class does not preserve '*' in hostnames the same way Go's url.Parse does.
|
||||
// Similarly, cases with leading dots like ".Y.local" and "..local" are omitted
|
||||
// because .NET's Uri normalizes those hostnames differently.
|
||||
[InlineData("*.Y-X-red-mgmt.default.svc", new[] { "nats://A.Y-X-red-mgmt.default.svc" }, true)]
|
||||
[InlineData("*.Y-X-green-mgmt.default.svc", new[] { "nats://A.Y-X-green-mgmt.default.svc" }, true)]
|
||||
[InlineData("*.Y-X-blue-mgmt.default.svc", new[] { "nats://A.Y-X-blue-mgmt.default.svc" }, true)]
|
||||
[InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-red-mgmt" }, true)]
|
||||
[InlineData("Y-X-red-mgmt", new[] { "nats://X-X-red-mgmt" }, false)]
|
||||
[InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-green-mgmt" }, false)]
|
||||
[InlineData("Y-X-red-mgmt", new[] { "nats://Y" }, false)]
|
||||
[InlineData("Y-X-red-mgmt", new[] { "nats://Y-X" }, false)]
|
||||
[InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-red" }, false)]
|
||||
[InlineData("Y-X-red-mgmt", new[] { "nats://X-red-mgmt" }, false)]
|
||||
[InlineData("Y-X-green-mgmt", new[] { "nats://Y-X-green-mgmt" }, true)]
|
||||
[InlineData("Y-X-blue-mgmt", new[] { "nats://Y-X-blue-mgmt" }, true)]
|
||||
[InlineData("connect.Y.local", new[] { "nats://connect.Y.local" }, true)]
|
||||
[InlineData("gcp.Y.local", new[] { "nats://gcp.Y.local" }, true)]
|
||||
[InlineData("uswest1.gcp.Y.local", new[] { "nats://uswest1.gcp.Y.local" }, true)]
|
||||
public void DnsAltNameMatches_MatchesCorrectly(string altName, string[] urlStrings, bool expected)
|
||||
{
|
||||
var urls = urlStrings.Select(s => new Uri(s)).ToArray();
|
||||
var labels = AuthHandler.DnsAltNameLabels(altName);
|
||||
AuthHandler.DnsAltNameMatches(labels, urls).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnsAltNameMatches_NullUrl_Skipped()
|
||||
{
|
||||
var labels = AuthHandler.DnsAltNameLabels("foo");
|
||||
var urls = new Uri?[] { null, new Uri("nats://foo") };
|
||||
AuthHandler.DnsAltNameMatches(labels, urls!).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WipeSlice
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void WipeSlice_FillsWithX()
|
||||
{
|
||||
var buf = new byte[] { 1, 2, 3, 4, 5 };
|
||||
AuthHandler.WipeSlice(buf);
|
||||
buf.ShouldAllBe(b => b == (byte)'x');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WipeSlice_EmptyBuffer_NoOp()
|
||||
{
|
||||
var buf = Array.Empty<byte>();
|
||||
AuthHandler.WipeSlice(buf); // should not throw
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ConnectionTypes known check
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("STANDARD", true)]
|
||||
[InlineData("WEBSOCKET", true)]
|
||||
[InlineData("LEAFNODE", true)]
|
||||
[InlineData("LEAFNODE_WS", true)]
|
||||
[InlineData("MQTT", true)]
|
||||
[InlineData("MQTT_WS", true)]
|
||||
[InlineData("IN_PROCESS", true)]
|
||||
[InlineData("UNKNOWN", false)]
|
||||
[InlineData("", false)]
|
||||
public void ConnectionTypes_IsKnown_DetectsCorrectly(string ct, bool expected)
|
||||
{
|
||||
AuthHandler.ConnectionTypes.IsKnown(ct).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
246
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthTypesTests.cs
Normal file
246
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthTypesTests.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for auth type cloning and validation.
|
||||
/// Mirrors Go auth_test.go: TestUserClone*, TestDNSAltNameMatching, and additional unit tests.
|
||||
/// </summary>
|
||||
public class AuthTypesTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// TestUserCloneNilPermissions — Go test ID 142
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UserClone_NilPermissions_ProducesDeepCopy()
|
||||
{
|
||||
var user = new User { Username = "foo", Password = "bar" };
|
||||
var clone = user.Clone()!;
|
||||
|
||||
clone.Username.ShouldBe(user.Username);
|
||||
clone.Password.ShouldBe(user.Password);
|
||||
clone.Permissions.ShouldBeNull();
|
||||
|
||||
// Mutation should not affect original.
|
||||
clone.Password = "baz";
|
||||
user.Password.ShouldBe("bar");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestUserClone — Go test ID 143
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UserClone_WithPermissions_ProducesDeepCopy()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Username = "foo",
|
||||
Password = "bar",
|
||||
Permissions = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = ["foo"] },
|
||||
Subscribe = new SubjectPermission { Allow = ["bar"] },
|
||||
},
|
||||
};
|
||||
var clone = user.Clone()!;
|
||||
|
||||
clone.Username.ShouldBe("foo");
|
||||
clone.Permissions.ShouldNotBeNull();
|
||||
clone.Permissions!.Publish!.Allow.ShouldBe(["foo"]);
|
||||
clone.Permissions.Subscribe!.Allow.ShouldBe(["bar"]);
|
||||
|
||||
// Mutating clone should not affect original.
|
||||
clone.Permissions.Subscribe.Allow = ["baz"];
|
||||
user.Permissions!.Subscribe!.Allow.ShouldBe(["bar"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestUserClonePermissionsNoLists — Go test ID 144
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UserClone_EmptyPermissions_PublishSubscribeAreNull()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Username = "foo",
|
||||
Password = "bar",
|
||||
Permissions = new Permissions(),
|
||||
};
|
||||
var clone = user.Clone()!;
|
||||
|
||||
clone.Permissions.ShouldNotBeNull();
|
||||
clone.Permissions!.Publish.ShouldBeNull();
|
||||
clone.Permissions.Subscribe.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestUserCloneNoPermissions — Go test ID 145
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UserClone_NoPermissions_PermissionsIsNull()
|
||||
{
|
||||
var user = new User { Username = "foo", Password = "bar" };
|
||||
var clone = user.Clone()!;
|
||||
clone.Permissions.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestUserCloneNil — Go test ID 146
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UserClone_NilUser_ReturnsNull()
|
||||
{
|
||||
User? user = null;
|
||||
var clone = user?.Clone();
|
||||
clone.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// NkeyUser clone tests (additional coverage for NkeyUser.clone)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void NkeyUserClone_WithAllowedConnectionTypes_ProducesDeepCopy()
|
||||
{
|
||||
var nkey = new NkeyUser
|
||||
{
|
||||
Nkey = "NKEY123",
|
||||
SigningKey = "SK1",
|
||||
AllowedConnectionTypes = new HashSet<string> { "STANDARD", "WEBSOCKET" },
|
||||
Permissions = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = ["pub.>"] },
|
||||
},
|
||||
};
|
||||
var clone = nkey.Clone()!;
|
||||
|
||||
clone.Nkey.ShouldBe("NKEY123");
|
||||
clone.AllowedConnectionTypes.ShouldNotBeNull();
|
||||
clone.AllowedConnectionTypes!.Count.ShouldBe(2);
|
||||
clone.Permissions!.Publish!.Allow.ShouldBe(["pub.>"]);
|
||||
|
||||
// Mutating clone should not affect original.
|
||||
clone.AllowedConnectionTypes.Add("MQTT");
|
||||
nkey.AllowedConnectionTypes.Count.ShouldBe(2);
|
||||
|
||||
clone.Permissions.Publish.Allow = ["other.>"];
|
||||
nkey.Permissions!.Publish!.Allow.ShouldBe(["pub.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NkeyUserClone_NilNkeyUser_ReturnsNull()
|
||||
{
|
||||
NkeyUser? nkey = null;
|
||||
var clone = nkey?.Clone();
|
||||
clone.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SubjectPermission clone tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SubjectPermissionClone_WithAllowAndDeny_ProducesDeepCopy()
|
||||
{
|
||||
var perm = new SubjectPermission
|
||||
{
|
||||
Allow = ["foo.>", "bar.>"],
|
||||
Deny = ["baz.>"],
|
||||
};
|
||||
var clone = perm.Clone();
|
||||
|
||||
clone.Allow.ShouldBe(["foo.>", "bar.>"]);
|
||||
clone.Deny.ShouldBe(["baz.>"]);
|
||||
|
||||
clone.Allow.Add("extra");
|
||||
perm.Allow.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectPermissionClone_NullLists_StaysNull()
|
||||
{
|
||||
var perm = new SubjectPermission();
|
||||
var clone = perm.Clone();
|
||||
clone.Allow.ShouldBeNull();
|
||||
clone.Deny.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Permissions clone with Response
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PermissionsClone_WithResponse_ProducesDeepCopy()
|
||||
{
|
||||
var perms = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = ["pub"] },
|
||||
Subscribe = new SubjectPermission { Deny = ["deny"] },
|
||||
Response = new ResponsePermission { MaxMsgs = 5, Expires = TimeSpan.FromMinutes(1) },
|
||||
};
|
||||
var clone = perms.Clone();
|
||||
|
||||
clone.Response.ShouldNotBeNull();
|
||||
clone.Response!.MaxMsgs.ShouldBe(5);
|
||||
clone.Response.Expires.ShouldBe(TimeSpan.FromMinutes(1));
|
||||
|
||||
// Mutating clone should not affect original.
|
||||
clone.Response.MaxMsgs = 10;
|
||||
perms.Response!.MaxMsgs.ShouldBe(5);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User clone with AllowedConnectionTypes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UserClone_WithAllowedConnectionTypes_ProducesDeepCopy()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Username = "u1",
|
||||
AllowedConnectionTypes = new HashSet<string> { "STANDARD" },
|
||||
};
|
||||
var clone = user.Clone()!;
|
||||
|
||||
clone.AllowedConnectionTypes.ShouldNotBeNull();
|
||||
clone.AllowedConnectionTypes!.ShouldContain("STANDARD");
|
||||
|
||||
clone.AllowedConnectionTypes.Add("MQTT");
|
||||
user.AllowedConnectionTypes.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User clone with Account (shared by reference)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UserClone_AccountSharedByReference()
|
||||
{
|
||||
var acct = new Account { Name = "TestAccount" };
|
||||
var user = new User { Username = "u1", Account = acct };
|
||||
var clone = user.Clone()!;
|
||||
|
||||
// Account should be same reference.
|
||||
clone.Account.ShouldBeSameAs(acct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2016-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System.Security.Authentication;
|
||||
using System.Net.Security;
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CipherSuites definitions.
|
||||
/// Mirrors Go ciphersuites.go functionality.
|
||||
/// </summary>
|
||||
public class CipherSuitesTests
|
||||
{
|
||||
[Fact]
|
||||
public void CipherMap_ContainsTls13Suites()
|
||||
{
|
||||
CipherSuites.CipherMap.ShouldNotBeEmpty();
|
||||
// At minimum, TLS 1.3 suites should be present.
|
||||
CipherSuites.CipherMap.ShouldContainKey("TLS_AES_256_GCM_SHA384");
|
||||
CipherSuites.CipherMap.ShouldContainKey("TLS_AES_128_GCM_SHA256");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipherMapById_ContainsTls13Suites()
|
||||
{
|
||||
CipherSuites.CipherMapById.ShouldNotBeEmpty();
|
||||
CipherSuites.CipherMapById.ShouldContainKey(TlsCipherSuite.TLS_AES_256_GCM_SHA384);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipherMap_CaseInsensitiveLookup()
|
||||
{
|
||||
// The map uses OrdinalIgnoreCase comparer.
|
||||
CipherSuites.CipherMap.ShouldContainKey("tls_aes_256_gcm_sha384");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultCipherSuites_ReturnsNonEmptyList()
|
||||
{
|
||||
var defaults = CipherSuites.DefaultCipherSuites();
|
||||
defaults.ShouldNotBeEmpty();
|
||||
defaults.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultCipherSuites_ContainsSecureSuites()
|
||||
{
|
||||
var defaults = CipherSuites.DefaultCipherSuites();
|
||||
defaults.ShouldContain(TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384);
|
||||
defaults.ShouldContain(TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurvePreferenceMap_ContainsExpectedCurves()
|
||||
{
|
||||
CipherSuites.CurvePreferenceMap.ShouldContainKey("X25519");
|
||||
CipherSuites.CurvePreferenceMap.ShouldContainKey("CurveP256");
|
||||
CipherSuites.CurvePreferenceMap.ShouldContainKey("CurveP384");
|
||||
CipherSuites.CurvePreferenceMap.ShouldContainKey("CurveP521");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultCurvePreferences_ReturnsExpectedOrder()
|
||||
{
|
||||
var prefs = CipherSuites.DefaultCurvePreferences();
|
||||
prefs.Length.ShouldBeGreaterThanOrEqualTo(4);
|
||||
prefs[0].ShouldBe("X25519");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2018-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for JwtProcessor functions.
|
||||
/// Mirrors Go jwt.go functionality for standalone testable functions.
|
||||
/// </summary>
|
||||
public class JwtProcessorTests
|
||||
{
|
||||
// =========================================================================
|
||||
// JwtPrefix constant
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void JwtPrefix_IsCorrect()
|
||||
{
|
||||
JwtProcessor.JwtPrefix.ShouldBe("eyJ");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WipeSlice
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void WipeSlice_FillsWithX()
|
||||
{
|
||||
var buf = new byte[] { 0x01, 0x02, 0x03 };
|
||||
JwtProcessor.WipeSlice(buf);
|
||||
buf.ShouldAllBe(b => b == (byte)'x');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WipeSlice_EmptyBuffer_NoOp()
|
||||
{
|
||||
var buf = Array.Empty<byte>();
|
||||
JwtProcessor.WipeSlice(buf);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ValidateSrc
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_NullCidrs_ReturnsFalse()
|
||||
{
|
||||
JwtProcessor.ValidateSrc(null, "192.168.1.1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_EmptyCidrs_ReturnsTrue()
|
||||
{
|
||||
JwtProcessor.ValidateSrc([], "192.168.1.1").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_EmptyHost_ReturnsFalse()
|
||||
{
|
||||
JwtProcessor.ValidateSrc(["192.168.0.0/16"], "").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_InvalidHost_ReturnsFalse()
|
||||
{
|
||||
JwtProcessor.ValidateSrc(["192.168.0.0/16"], "not-an-ip").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_MatchingCidr_ReturnsTrue()
|
||||
{
|
||||
JwtProcessor.ValidateSrc(["192.168.0.0/16"], "192.168.1.100").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_NonMatchingCidr_ReturnsFalse()
|
||||
{
|
||||
JwtProcessor.ValidateSrc(["10.0.0.0/8"], "192.168.1.100").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_MultipleCidrs_MatchesAny()
|
||||
{
|
||||
var cidrs = new[] { "10.0.0.0/8", "192.168.0.0/16" };
|
||||
JwtProcessor.ValidateSrc(cidrs, "192.168.1.100").ShouldBeTrue();
|
||||
JwtProcessor.ValidateSrc(cidrs, "10.1.2.3").ShouldBeTrue();
|
||||
JwtProcessor.ValidateSrc(cidrs, "172.16.0.1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_ExactMatch_SingleHost()
|
||||
{
|
||||
JwtProcessor.ValidateSrc(["192.168.1.100/32"], "192.168.1.100").ShouldBeTrue();
|
||||
JwtProcessor.ValidateSrc(["192.168.1.100/32"], "192.168.1.101").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_InvalidCidr_ReturnsFalse()
|
||||
{
|
||||
JwtProcessor.ValidateSrc(["not-a-cidr"], "192.168.1.1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_Ipv6_MatchingCidr()
|
||||
{
|
||||
JwtProcessor.ValidateSrc(["::1/128"], "::1").ShouldBeTrue();
|
||||
JwtProcessor.ValidateSrc(["::1/128"], "::2").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSrc_MismatchedIpFamilies_ReturnsFalse()
|
||||
{
|
||||
// IPv6 CIDR with IPv4 address should not match.
|
||||
JwtProcessor.ValidateSrc(["::1/128"], "127.0.0.1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ValidateTimes
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateTimes_NullRanges_ReturnsFalse()
|
||||
{
|
||||
var (allowed, remaining) = JwtProcessor.ValidateTimes(null);
|
||||
allowed.ShouldBeFalse();
|
||||
remaining.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTimes_EmptyRanges_ReturnsTrue()
|
||||
{
|
||||
var (allowed, remaining) = JwtProcessor.ValidateTimes([]);
|
||||
allowed.ShouldBeTrue();
|
||||
remaining.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTimes_CurrentTimeInRange_ReturnsTrue()
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
var start = now.AddMinutes(-30).ToString("HH:mm:ss");
|
||||
var end = now.AddMinutes(30).ToString("HH:mm:ss");
|
||||
|
||||
var ranges = new[] { new TimeRange { Start = start, End = end } };
|
||||
var (allowed, remaining) = JwtProcessor.ValidateTimes(ranges);
|
||||
allowed.ShouldBeTrue();
|
||||
remaining.TotalMinutes.ShouldBeGreaterThan(0);
|
||||
remaining.TotalMinutes.ShouldBeLessThanOrEqualTo(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTimes_CurrentTimeOutOfRange_ReturnsFalse()
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
// Set a range entirely in the past today.
|
||||
var start = now.AddHours(-3).ToString("HH:mm:ss");
|
||||
var end = now.AddHours(-2).ToString("HH:mm:ss");
|
||||
|
||||
var ranges = new[] { new TimeRange { Start = start, End = end } };
|
||||
var (allowed, _) = JwtProcessor.ValidateTimes(ranges);
|
||||
allowed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTimes_InvalidFormat_ReturnsFalse()
|
||||
{
|
||||
var ranges = new[] { new TimeRange { Start = "bad", End = "data" } };
|
||||
var (allowed, _) = JwtProcessor.ValidateTimes(ranges);
|
||||
allowed.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
BIN
porting.db
BIN
porting.db
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
# NATS .NET Porting Status Report
|
||||
|
||||
Generated: 2026-02-26 17:11:07 UTC
|
||||
Generated: 2026-02-26 17:27:34 UTC
|
||||
|
||||
## Modules (12 total)
|
||||
|
||||
@@ -13,19 +13,19 @@ Generated: 2026-02-26 17:11:07 UTC
|
||||
|
||||
| Status | Count |
|
||||
|--------|-------|
|
||||
| complete | 449 |
|
||||
| complete | 467 |
|
||||
| n_a | 82 |
|
||||
| not_started | 3074 |
|
||||
| stub | 68 |
|
||||
| not_started | 3031 |
|
||||
| stub | 93 |
|
||||
|
||||
## Unit Tests (3257 total)
|
||||
|
||||
| Status | Count |
|
||||
|--------|-------|
|
||||
| complete | 219 |
|
||||
| complete | 225 |
|
||||
| n_a | 82 |
|
||||
| not_started | 2857 |
|
||||
| stub | 99 |
|
||||
| not_started | 2726 |
|
||||
| stub | 224 |
|
||||
|
||||
## Library Mappings (36 total)
|
||||
|
||||
@@ -36,4 +36,4 @@ Generated: 2026-02-26 17:11:07 UTC
|
||||
|
||||
## Overall Progress
|
||||
|
||||
**843/6942 items complete (12.1%)**
|
||||
**867/6942 items complete (12.5%)**
|
||||
|
||||
39
reports/report_ed78a10.md
Normal file
39
reports/report_ed78a10.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# NATS .NET Porting Status Report
|
||||
|
||||
Generated: 2026-02-26 17:27:34 UTC
|
||||
|
||||
## Modules (12 total)
|
||||
|
||||
| Status | Count |
|
||||
|--------|-------|
|
||||
| complete | 11 |
|
||||
| not_started | 1 |
|
||||
|
||||
## Features (3673 total)
|
||||
|
||||
| Status | Count |
|
||||
|--------|-------|
|
||||
| complete | 467 |
|
||||
| n_a | 82 |
|
||||
| not_started | 3031 |
|
||||
| stub | 93 |
|
||||
|
||||
## Unit Tests (3257 total)
|
||||
|
||||
| Status | Count |
|
||||
|--------|-------|
|
||||
| complete | 225 |
|
||||
| n_a | 82 |
|
||||
| not_started | 2726 |
|
||||
| stub | 224 |
|
||||
|
||||
## Library Mappings (36 total)
|
||||
|
||||
| Status | Count |
|
||||
|--------|-------|
|
||||
| mapped | 36 |
|
||||
|
||||
|
||||
## Overall Progress
|
||||
|
||||
**867/6942 items complete (12.5%)**
|
||||
Reference in New Issue
Block a user