feat: add JWT core decode/verify and claim structs for NATS auth
Implement NatsJwt static class with Ed25519 signature verification, base64url decoding, and JWT parsing. Add UserClaims and AccountClaims with all NATS-specific fields (permissions, bearer tokens, limits, signing keys, revocations). Includes 44 tests covering decode, verify, nonce verification, and full round-trip signing with real NKey keypairs.
This commit is contained in:
90
src/NATS.Server/Auth/Jwt/AccountClaims.cs
Normal file
90
src/NATS.Server/Auth/Jwt/AccountClaims.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the claims in a NATS account JWT.
|
||||
/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
|
||||
/// with account limits, signing keys, and revocations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: github.com/nats-io/jwt/v2 — AccountClaims, Account, OperatorLimits types
|
||||
/// </remarks>
|
||||
public sealed class AccountClaims
|
||||
{
|
||||
/// <summary>Subject — the account's NKey public key.</summary>
|
||||
[JsonPropertyName("sub")]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
/// <summary>Issuer — the operator or signing key that issued this JWT.</summary>
|
||||
[JsonPropertyName("iss")]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
/// <summary>Issued-at time as Unix epoch seconds.</summary>
|
||||
[JsonPropertyName("iat")]
|
||||
public long IssuedAt { get; set; }
|
||||
|
||||
/// <summary>Expiration time as Unix epoch seconds. 0 means no expiry.</summary>
|
||||
[JsonPropertyName("exp")]
|
||||
public long Expires { get; set; }
|
||||
|
||||
/// <summary>Human-readable name for the account.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>NATS-specific account claims.</summary>
|
||||
[JsonPropertyName("nats")]
|
||||
public AccountNats? Nats { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS-specific portion of account JWT claims.
|
||||
/// Contains limits, signing keys, and user revocations.
|
||||
/// </summary>
|
||||
public sealed class AccountNats
|
||||
{
|
||||
/// <summary>Account resource limits.</summary>
|
||||
[JsonPropertyName("limits")]
|
||||
public AccountLimits? Limits { get; set; }
|
||||
|
||||
/// <summary>NKey public keys authorized to sign user JWTs for this account.</summary>
|
||||
[JsonPropertyName("signing_keys")]
|
||||
public string[]? SigningKeys { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Map of revoked user NKey public keys to the Unix epoch time of revocation.
|
||||
/// Any user JWT issued before the revocation time is considered revoked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("revocations")]
|
||||
public Dictionary<string, long>? Revocations { get; set; }
|
||||
|
||||
/// <summary>Claim type (e.g., "account").</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>Claim version.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource limits for a NATS account. A value of -1 means unlimited.
|
||||
/// </summary>
|
||||
public sealed class AccountLimits
|
||||
{
|
||||
/// <summary>Maximum number of connections. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("conn")]
|
||||
public long MaxConnections { get; set; }
|
||||
|
||||
/// <summary>Maximum number of subscriptions. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("subs")]
|
||||
public long MaxSubscriptions { get; set; }
|
||||
|
||||
/// <summary>Maximum payload size in bytes. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public long MaxPayload { get; set; }
|
||||
|
||||
/// <summary>Maximum data transfer in bytes. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("data")]
|
||||
public long MaxData { get; set; }
|
||||
}
|
||||
221
src/NATS.Server/Auth/Jwt/NatsJwt.cs
Normal file
221
src/NATS.Server/Auth/Jwt/NatsJwt.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.NKeys;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Provides NATS JWT decode, verify, and claim extraction.
|
||||
/// NATS JWTs are standard JWT format (base64url header.payload.signature) with Ed25519 signing.
|
||||
/// All NATS JWTs start with "eyJ" (base64url for '{"').
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: golang/nats-server/server/jwt.go and github.com/nats-io/jwt/v2
|
||||
/// </remarks>
|
||||
public static class NatsJwt
|
||||
{
|
||||
private const string JwtPrefix = "eyJ";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the string appears to be a JWT (starts with "eyJ").
|
||||
/// </summary>
|
||||
public static bool IsJwt(string token)
|
||||
{
|
||||
return !string.IsNullOrEmpty(token) && token.StartsWith(JwtPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT token into its constituent parts without verifying the signature.
|
||||
/// Returns null if the token is structurally invalid.
|
||||
/// </summary>
|
||||
public static JwtToken? Decode(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return null;
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var headerBytes = Base64UrlDecode(parts[0]);
|
||||
var payloadBytes = Base64UrlDecode(parts[1]);
|
||||
var signatureBytes = Base64UrlDecode(parts[2]);
|
||||
|
||||
var header = JsonSerializer.Deserialize<JwtHeader>(headerBytes);
|
||||
if (header is null)
|
||||
return null;
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
var signingInput = $"{parts[0]}.{parts[1]}";
|
||||
|
||||
return new JwtToken
|
||||
{
|
||||
Header = header,
|
||||
PayloadJson = payloadJson,
|
||||
Signature = signatureBytes,
|
||||
SigningInput = signingInput,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT token and deserializes the payload as <see cref="UserClaims"/>.
|
||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||
/// </summary>
|
||||
public static UserClaims? DecodeUserClaims(string token)
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
if (jwt is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<UserClaims>(jwt.PayloadJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT token and deserializes the payload as <see cref="AccountClaims"/>.
|
||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||
/// </summary>
|
||||
public static AccountClaims? DecodeAccountClaims(string token)
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
if (jwt is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AccountClaims>(jwt.PayloadJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Ed25519 signature on a JWT token against the given NKey public key.
|
||||
/// </summary>
|
||||
public static bool Verify(string token, string publicNkey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
if (jwt is null)
|
||||
return false;
|
||||
|
||||
var kp = KeyPair.FromPublicKey(publicNkey);
|
||||
var signingInputBytes = Encoding.UTF8.GetBytes(jwt.SigningInput);
|
||||
return kp.Verify(signingInputBytes, jwt.Signature);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a nonce signature against the given NKey public key.
|
||||
/// Tries base64url decoding first, then falls back to standard base64 (Go compatibility).
|
||||
/// </summary>
|
||||
public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sigBytes = TryDecodeSignature(signature);
|
||||
if (sigBytes is null)
|
||||
return false;
|
||||
|
||||
var kp = KeyPair.FromPublicKey(publicNkey);
|
||||
return kp.Verify(nonce, sigBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a base64url-encoded byte array.
|
||||
/// Replaces URL-safe characters and adds padding as needed.
|
||||
/// </summary>
|
||||
internal static byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var s = input.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(s);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to decode a signature string. Tries base64url first, then standard base64.
|
||||
/// Returns null if neither encoding works.
|
||||
/// </summary>
|
||||
private static byte[]? TryDecodeSignature(string signature)
|
||||
{
|
||||
// Try base64url first
|
||||
try
|
||||
{
|
||||
return Base64UrlDecode(signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Fall through to standard base64
|
||||
}
|
||||
|
||||
// Try standard base64
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a decoded JWT token with its constituent parts.
|
||||
/// </summary>
|
||||
public sealed class JwtToken
|
||||
{
|
||||
/// <summary>The decoded JWT header.</summary>
|
||||
public required JwtHeader Header { get; init; }
|
||||
|
||||
/// <summary>The raw JSON string of the payload.</summary>
|
||||
public required string PayloadJson { get; init; }
|
||||
|
||||
/// <summary>The raw signature bytes.</summary>
|
||||
public required byte[] Signature { get; init; }
|
||||
|
||||
/// <summary>The signing input (header.payload in base64url) used for signature verification.</summary>
|
||||
public required string SigningInput { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS JWT header. Algorithm is "ed25519-nkey" for NATS JWTs.
|
||||
/// </summary>
|
||||
public sealed class JwtHeader
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("alg")]
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("typ")]
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
173
src/NATS.Server/Auth/Jwt/UserClaims.cs
Normal file
173
src/NATS.Server/Auth/Jwt/UserClaims.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the claims in a NATS user JWT.
|
||||
/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
|
||||
/// with user permissions, bearer token flags, and connection restrictions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: github.com/nats-io/jwt/v2 — UserClaims, User, Permission types
|
||||
/// </remarks>
|
||||
public sealed class UserClaims
|
||||
{
|
||||
/// <summary>Subject — the user's NKey public key.</summary>
|
||||
[JsonPropertyName("sub")]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
/// <summary>Issuer — the account or signing key that issued this JWT.</summary>
|
||||
[JsonPropertyName("iss")]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
/// <summary>Issued-at time as Unix epoch seconds.</summary>
|
||||
[JsonPropertyName("iat")]
|
||||
public long IssuedAt { get; set; }
|
||||
|
||||
/// <summary>Expiration time as Unix epoch seconds. 0 means no expiry.</summary>
|
||||
[JsonPropertyName("exp")]
|
||||
public long Expires { get; set; }
|
||||
|
||||
/// <summary>Human-readable name for the user.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>NATS-specific user claims.</summary>
|
||||
[JsonPropertyName("nats")]
|
||||
public UserNats? Nats { get; set; }
|
||||
|
||||
// =========================================================================
|
||||
// Convenience properties that delegate to the Nats sub-object
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Whether this is a bearer token (no client nonce signature required).</summary>
|
||||
[JsonIgnore]
|
||||
public bool BearerToken => Nats?.BearerToken ?? false;
|
||||
|
||||
/// <summary>The account NKey public key that issued this user JWT.</summary>
|
||||
[JsonIgnore]
|
||||
public string? IssuerAccount => Nats?.IssuerAccount;
|
||||
|
||||
// =========================================================================
|
||||
// Expiry helpers
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the JWT has expired. A zero Expires value means no expiry.
|
||||
/// </summary>
|
||||
public bool IsExpired()
|
||||
{
|
||||
if (Expires == 0)
|
||||
return false;
|
||||
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the expiry as a <see cref="DateTimeOffset"/>, or null if there is no expiry (Expires == 0).
|
||||
/// </summary>
|
||||
public DateTimeOffset? GetExpiry()
|
||||
{
|
||||
if (Expires == 0)
|
||||
return null;
|
||||
return DateTimeOffset.FromUnixTimeSeconds(Expires);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS-specific portion of user JWT claims.
|
||||
/// Contains permissions, bearer token flag, connection restrictions, and more.
|
||||
/// </summary>
|
||||
public sealed class UserNats
|
||||
{
|
||||
/// <summary>Publish permission with allow/deny subject lists.</summary>
|
||||
[JsonPropertyName("pub")]
|
||||
public JwtSubjectPermission? Pub { get; set; }
|
||||
|
||||
/// <summary>Subscribe permission with allow/deny subject lists.</summary>
|
||||
[JsonPropertyName("sub")]
|
||||
public JwtSubjectPermission? Sub { get; set; }
|
||||
|
||||
/// <summary>Response permission controlling request-reply behavior.</summary>
|
||||
[JsonPropertyName("resp")]
|
||||
public JwtResponsePermission? Resp { get; set; }
|
||||
|
||||
/// <summary>Whether this is a bearer token (no nonce signature required).</summary>
|
||||
[JsonPropertyName("bearer_token")]
|
||||
public bool BearerToken { get; set; }
|
||||
|
||||
/// <summary>The account NKey public key that issued this user JWT.</summary>
|
||||
[JsonPropertyName("issuer_account")]
|
||||
public string? IssuerAccount { get; set; }
|
||||
|
||||
/// <summary>Tags associated with this user.</summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public string[]? Tags { get; set; }
|
||||
|
||||
/// <summary>Allowed source CIDRs for this user's connections.</summary>
|
||||
[JsonPropertyName("src")]
|
||||
public string[]? Src { get; set; }
|
||||
|
||||
/// <summary>Allowed connection types (e.g., "STANDARD", "WEBSOCKET", "LEAFNODE").</summary>
|
||||
[JsonPropertyName("allowed_connection_types")]
|
||||
public string[]? AllowedConnectionTypes { get; set; }
|
||||
|
||||
/// <summary>Time-of-day restrictions for when the user may connect.</summary>
|
||||
[JsonPropertyName("times")]
|
||||
public JwtTimeRange[]? Times { get; set; }
|
||||
|
||||
/// <summary>Claim type (e.g., "user").</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>Claim version.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject permission with allow and deny lists, as used in NATS JWTs.
|
||||
/// </summary>
|
||||
public sealed class JwtSubjectPermission
|
||||
{
|
||||
/// <summary>Subjects the user is allowed to publish/subscribe to.</summary>
|
||||
[JsonPropertyName("allow")]
|
||||
public string[]? Allow { get; set; }
|
||||
|
||||
/// <summary>Subjects the user is denied from publishing/subscribing to.</summary>
|
||||
[JsonPropertyName("deny")]
|
||||
public string[]? Deny { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response permission controlling request-reply behavior in NATS JWTs.
|
||||
/// </summary>
|
||||
public sealed class JwtResponsePermission
|
||||
{
|
||||
/// <summary>Maximum number of response messages allowed.</summary>
|
||||
[JsonPropertyName("max")]
|
||||
public int MaxMsgs { get; set; }
|
||||
|
||||
/// <summary>Time-to-live for the response permission, in nanoseconds.</summary>
|
||||
[JsonPropertyName("ttl")]
|
||||
public long TtlNanos { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property: converts <see cref="TtlNanos"/> to a <see cref="TimeSpan"/>.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public TimeSpan Ttl => TimeSpan.FromTicks(TtlNanos / 100); // 1 tick = 100 nanoseconds
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A time-of-day range for connection restrictions.
|
||||
/// </summary>
|
||||
public sealed class JwtTimeRange
|
||||
{
|
||||
/// <summary>Start time in HH:mm:ss format.</summary>
|
||||
[JsonPropertyName("start")]
|
||||
public string? Start { get; set; }
|
||||
|
||||
/// <summary>End time in HH:mm:ss format.</summary>
|
||||
[JsonPropertyName("end")]
|
||||
public string? End { get; set; }
|
||||
}
|
||||
932
tests/NATS.Server.Tests/JwtTests.cs
Normal file
932
tests/NATS.Server.Tests/JwtTests.cs
Normal file
@@ -0,0 +1,932 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JwtTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper: base64url-encode a string for constructing test JWTs.
|
||||
/// </summary>
|
||||
private static string Base64UrlEncode(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper: build a minimal unsigned JWT from header and payload JSON strings.
|
||||
/// The signature part is a base64url-encoded 64-byte zero array (invalid but structurally correct).
|
||||
/// </summary>
|
||||
private static string BuildUnsignedToken(string headerJson, string payloadJson)
|
||||
{
|
||||
var header = Base64UrlEncode(headerJson);
|
||||
var payload = Base64UrlEncode(payloadJson);
|
||||
var fakeSig = Convert.ToBase64String(new byte[64])
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
return $"{header}.{payload}.{fakeSig}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper: build a real signed NATS JWT using an NKey keypair.
|
||||
/// Signs header.payload with Ed25519.
|
||||
/// </summary>
|
||||
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var header = Base64UrlEncode(headerJson);
|
||||
var payload = Base64UrlEncode(payloadJson);
|
||||
var signingInput = $"{header}.{payload}";
|
||||
var signingInputBytes = Encoding.UTF8.GetBytes(signingInput);
|
||||
|
||||
var sig = new byte[64];
|
||||
signingKey.Sign(signingInputBytes, sig);
|
||||
|
||||
var sigB64 = Convert.ToBase64String(sig)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
return $"{header}.{payload}.{sigB64}";
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// IsJwt tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_true_for_eyJ_prefix()
|
||||
{
|
||||
NatsJwt.IsJwt("eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_true_for_minimal_eyJ()
|
||||
{
|
||||
NatsJwt.IsJwt("eyJ").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_false_for_non_jwt()
|
||||
{
|
||||
NatsJwt.IsJwt("notajwt").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_false_for_empty_string()
|
||||
{
|
||||
NatsJwt.IsJwt("").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_false_for_null()
|
||||
{
|
||||
NatsJwt.IsJwt(null!).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Decode tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Decode_splits_header_payload_signature_correctly()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var result = NatsJwt.Decode(token);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Header.ShouldNotBeNull();
|
||||
result.Header.Type.ShouldBe("JWT");
|
||||
result.Header.Algorithm.ShouldBe("ed25519-nkey");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_payload_json()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var result = NatsJwt.Decode(token);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.PayloadJson.ShouldNotBeNullOrEmpty();
|
||||
|
||||
// The payload JSON should parse back to matching fields
|
||||
using var doc = JsonDocument.Parse(result.PayloadJson);
|
||||
doc.RootElement.GetProperty("sub").GetString().ShouldBe("UAXXX");
|
||||
doc.RootElement.GetProperty("iss").GetString().ShouldBe("AAXXX");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_preserves_signature_bytes()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """{"sub":"test"}""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var result = NatsJwt.Decode(token);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Signature.ShouldNotBeNull();
|
||||
result.Signature.Length.ShouldBe(64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_preserves_signing_input()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """{"sub":"test"}""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var result = NatsJwt.Decode(token);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
|
||||
// SigningInput should be "header.payload" (the first two parts)
|
||||
var parts = token.Split('.');
|
||||
var expectedSigningInput = $"{parts[0]}.{parts[1]}";
|
||||
result.SigningInput.ShouldBe(expectedSigningInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_null_for_invalid_token_missing_parts()
|
||||
{
|
||||
NatsJwt.Decode("onlyonepart").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_null_for_two_parts()
|
||||
{
|
||||
NatsJwt.Decode("part1.part2").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_null_for_empty_string()
|
||||
{
|
||||
NatsJwt.Decode("").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_null_for_invalid_base64_in_header()
|
||||
{
|
||||
NatsJwt.Decode("!!!invalid.payload.sig").ShouldBeNull();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Verify tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Verify_returns_true_for_valid_signed_token()
|
||||
{
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var accountPublicKey = accountKp.GetPublicKey();
|
||||
|
||||
var payloadJson = $$"""{"sub":"UAXXX","iss":"{{accountPublicKey}}","iat":1700000000}""";
|
||||
var token = BuildSignedToken(payloadJson, accountKp);
|
||||
|
||||
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_returns_false_for_wrong_key()
|
||||
{
|
||||
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var wrongKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
|
||||
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
|
||||
var token = BuildSignedToken(payloadJson, signingKp);
|
||||
|
||||
NatsJwt.Verify(token, wrongKp.GetPublicKey()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_returns_false_for_tampered_payload()
|
||||
{
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var accountPublicKey = accountKp.GetPublicKey();
|
||||
|
||||
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
|
||||
var token = BuildSignedToken(payloadJson, accountKp);
|
||||
|
||||
// Tamper with the payload
|
||||
var parts = token.Split('.');
|
||||
var tamperedPayload = Base64UrlEncode("""{"sub":"HACKED","iss":"AAXXX","iat":1700000000}""");
|
||||
var tampered = $"{parts[0]}.{tamperedPayload}.{parts[2]}";
|
||||
|
||||
NatsJwt.Verify(tampered, accountPublicKey).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_returns_false_for_invalid_token()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
NatsJwt.Verify("not.a.jwt", kp.GetPublicKey()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// VerifyNonce tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void VerifyNonce_accepts_base64url_signature()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce-data"u8.ToArray();
|
||||
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
|
||||
// Encode as base64url
|
||||
var sigB64Url = Convert.ToBase64String(sig)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
NatsJwt.VerifyNonce(nonce, sigB64Url, publicKey).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyNonce_accepts_standard_base64_signature()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce-data"u8.ToArray();
|
||||
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
|
||||
// Encode as standard base64
|
||||
var sigB64 = Convert.ToBase64String(sig);
|
||||
|
||||
NatsJwt.VerifyNonce(nonce, sigB64, publicKey).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyNonce_returns_false_for_wrong_nonce()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "original-nonce"u8.ToArray();
|
||||
var wrongNonce = "different-nonce"u8.ToArray();
|
||||
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
var sigB64 = Convert.ToBase64String(sig);
|
||||
|
||||
NatsJwt.VerifyNonce(wrongNonce, sigB64, publicKey).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyNonce_returns_false_for_invalid_signature()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce"u8.ToArray();
|
||||
|
||||
NatsJwt.VerifyNonce(nonce, "invalid-sig!", publicKey).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// DecodeUserClaims tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_subject_and_issuer()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX_USER_KEY",
|
||||
"iss":"AAXXX_ISSUER",
|
||||
"iat":1700000000,
|
||||
"name":"test-user",
|
||||
"nats":{
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("UAXXX_USER_KEY");
|
||||
claims.Issuer.ShouldBe("AAXXX_ISSUER");
|
||||
claims.Name.ShouldBe("test-user");
|
||||
claims.IssuedAt.ShouldBe(1700000000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_pub_sub_permissions()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"pub":{"allow":["foo.>","bar.*"],"deny":["bar.secret"]},
|
||||
"sub":{"allow":[">"],"deny":["_INBOX.private.>"]},
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
|
||||
claims.Nats.Pub.ShouldNotBeNull();
|
||||
claims.Nats.Pub.Allow.ShouldBe(["foo.>", "bar.*"]);
|
||||
claims.Nats.Pub.Deny.ShouldBe(["bar.secret"]);
|
||||
|
||||
claims.Nats.Sub.ShouldNotBeNull();
|
||||
claims.Nats.Sub.Allow.ShouldBe([">"]);
|
||||
claims.Nats.Sub.Deny.ShouldBe(["_INBOX.private.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_response_permission()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"resp":{"max":5,"ttl":3000000000},
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Resp.ShouldNotBeNull();
|
||||
claims.Nats.Resp.MaxMsgs.ShouldBe(5);
|
||||
claims.Nats.Resp.TtlNanos.ShouldBe(3000000000L);
|
||||
claims.Nats.Resp.Ttl.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_bearer_token_flag()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"bearer_token":true,
|
||||
"issuer_account":"AAXXX_ISSUER_ACCOUNT",
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.BearerToken.ShouldBeTrue();
|
||||
claims.Nats.IssuerAccount.ShouldBe("AAXXX_ISSUER_ACCOUNT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_tags_src_connection_types()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"tags":["web","mobile"],
|
||||
"src":["192.168.1.0/24","10.0.0.0/8"],
|
||||
"allowed_connection_types":["STANDARD","WEBSOCKET"],
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Tags.ShouldBe(["web", "mobile"]);
|
||||
claims.Nats.Src.ShouldBe(["192.168.1.0/24", "10.0.0.0/8"]);
|
||||
claims.Nats.AllowedConnectionTypes.ShouldBe(["STANDARD", "WEBSOCKET"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_time_ranges()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"times":[
|
||||
{"start":"08:00:00","end":"17:00:00"},
|
||||
{"start":"20:00:00","end":"22:00:00"}
|
||||
],
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Times.ShouldNotBeNull();
|
||||
claims.Nats.Times.Length.ShouldBe(2);
|
||||
claims.Nats.Times[0].Start.ShouldBe("08:00:00");
|
||||
claims.Nats.Times[0].End.ShouldBe("17:00:00");
|
||||
claims.Nats.Times[1].Start.ShouldBe("20:00:00");
|
||||
claims.Nats.Times[1].End.ShouldBe("22:00:00");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_convenience_properties_delegate_to_nats()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"bearer_token":true,
|
||||
"issuer_account":"AAXXX_ACCOUNT",
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
|
||||
// Convenience properties should delegate to Nats sub-object
|
||||
claims.BearerToken.ShouldBeTrue();
|
||||
claims.IssuerAccount.ShouldBe("AAXXX_ACCOUNT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_IsExpired_returns_false_when_no_expiry()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"user","version":2}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Expires.ShouldBe(0);
|
||||
claims.IsExpired().ShouldBeFalse();
|
||||
claims.GetExpiry().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_IsExpired_returns_true_for_past_expiry()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
// Expired in 2020
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1500000000,
|
||||
"exp":1600000000,
|
||||
"nats":{"type":"user","version":2}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Expires.ShouldBe(1600000000);
|
||||
claims.IsExpired().ShouldBeTrue();
|
||||
claims.GetExpiry().ShouldNotBeNull();
|
||||
claims.GetExpiry()!.Value.ToUnixTimeSeconds().ShouldBe(1600000000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_IsExpired_returns_false_for_future_expiry()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
// Expires far in the future
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"exp":4102444800,
|
||||
"nats":{"type":"user","version":2}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.IsExpired().ShouldBeFalse();
|
||||
claims.GetExpiry().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_returns_null_for_invalid_token()
|
||||
{
|
||||
NatsJwt.DecodeUserClaims("not-a-jwt").ShouldBeNull();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// DecodeAccountClaims tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_subject_and_issuer()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX_ACCOUNT_KEY",
|
||||
"iss":"OAXXX_OPERATOR",
|
||||
"iat":1700000000,
|
||||
"name":"test-account",
|
||||
"nats":{
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("AAXXX_ACCOUNT_KEY");
|
||||
claims.Issuer.ShouldBe("OAXXX_OPERATOR");
|
||||
claims.Name.ShouldBe("test-account");
|
||||
claims.IssuedAt.ShouldBe(1700000000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_limits()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"limits":{
|
||||
"conn":100,
|
||||
"subs":1000,
|
||||
"payload":1048576,
|
||||
"data":10737418240
|
||||
},
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Limits.ShouldNotBeNull();
|
||||
claims.Nats.Limits.MaxConnections.ShouldBe(100);
|
||||
claims.Nats.Limits.MaxSubscriptions.ShouldBe(1000);
|
||||
claims.Nats.Limits.MaxPayload.ShouldBe(1048576);
|
||||
claims.Nats.Limits.MaxData.ShouldBe(10737418240L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_signing_keys()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"signing_keys":["AAXXX_SIGN_1","AAXXX_SIGN_2","AAXXX_SIGN_3"],
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.SigningKeys.ShouldNotBeNull();
|
||||
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SIGN_1", "AAXXX_SIGN_2", "AAXXX_SIGN_3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_revocations()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"revocations":{
|
||||
"UAXXX_REVOKED_1":1700000000,
|
||||
"UAXXX_REVOKED_2":1700001000
|
||||
},
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Revocations.ShouldNotBeNull();
|
||||
claims.Nats.Revocations.Count.ShouldBe(2);
|
||||
claims.Nats.Revocations["UAXXX_REVOKED_1"].ShouldBe(1700000000);
|
||||
claims.Nats.Revocations["UAXXX_REVOKED_2"].ShouldBe(1700001000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_handles_negative_one_unlimited_limits()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"limits":{
|
||||
"conn":-1,
|
||||
"subs":-1,
|
||||
"payload":-1,
|
||||
"data":-1
|
||||
},
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Limits.ShouldNotBeNull();
|
||||
claims.Nats.Limits.MaxConnections.ShouldBe(-1);
|
||||
claims.Nats.Limits.MaxSubscriptions.ShouldBe(-1);
|
||||
claims.Nats.Limits.MaxPayload.ShouldBe(-1);
|
||||
claims.Nats.Limits.MaxData.ShouldBe(-1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_returns_null_for_invalid_token()
|
||||
{
|
||||
NatsJwt.DecodeAccountClaims("invalid").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_expiry()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"exp":1800000000,
|
||||
"name":"expiring-account",
|
||||
"nats":{
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Expires.ShouldBe(1800000000);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Round-trip with real Ed25519 signing tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_sign_and_verify_user_claims()
|
||||
{
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var accountPublicKey = accountKp.GetPublicKey();
|
||||
|
||||
var payloadJson = $$"""
|
||||
{
|
||||
"sub":"UAXXX_USER",
|
||||
"iss":"{{accountPublicKey}}",
|
||||
"iat":1700000000,
|
||||
"name":"roundtrip-user",
|
||||
"nats":{
|
||||
"pub":{"allow":["test.>"]},
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPublicKey}}",
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var token = BuildSignedToken(payloadJson, accountKp);
|
||||
|
||||
// Verify signature
|
||||
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
|
||||
|
||||
// Decode claims
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("UAXXX_USER");
|
||||
claims.Name.ShouldBe("roundtrip-user");
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Pub.ShouldNotBeNull();
|
||||
claims.Nats.Pub.Allow.ShouldBe(["test.>"]);
|
||||
claims.BearerToken.ShouldBeTrue();
|
||||
claims.IssuerAccount.ShouldBe(accountPublicKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_sign_and_verify_account_claims()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var operatorPublicKey = operatorKp.GetPublicKey();
|
||||
|
||||
var payloadJson = $$"""
|
||||
{
|
||||
"sub":"AAXXX_ACCOUNT",
|
||||
"iss":"{{operatorPublicKey}}",
|
||||
"iat":1700000000,
|
||||
"name":"roundtrip-account",
|
||||
"nats":{
|
||||
"limits":{"conn":50,"subs":500,"payload":65536,"data":-1},
|
||||
"signing_keys":["AAXXX_SK1"],
|
||||
"revocations":{"UAXXX_OLD":1699000000},
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var token = BuildSignedToken(payloadJson, operatorKp);
|
||||
|
||||
// Verify signature
|
||||
NatsJwt.Verify(token, operatorPublicKey).ShouldBeTrue();
|
||||
|
||||
// Decode claims
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("AAXXX_ACCOUNT");
|
||||
claims.Name.ShouldBe("roundtrip-account");
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Limits.ShouldNotBeNull();
|
||||
claims.Nats.Limits.MaxConnections.ShouldBe(50);
|
||||
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SK1"]);
|
||||
claims.Nats.Revocations.ShouldNotBeNull();
|
||||
claims.Nats.Revocations["UAXXX_OLD"].ShouldBe(1699000000);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Edge case tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_handles_missing_nats_object()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
// Should still decode the outer fields even if nats is missing
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("UAXXX");
|
||||
claims.Issuer.ShouldBe("AAXXX");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_handles_empty_nats_object()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("AAXXX");
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_handles_empty_pub_sub_permissions()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"pub":{},
|
||||
"sub":{},
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Pub.ShouldNotBeNull();
|
||||
claims.Nats.Sub.ShouldNotBeNull();
|
||||
// Allow/Deny should be null when not specified
|
||||
claims.Nats.Pub.Allow.ShouldBeNull();
|
||||
claims.Nats.Pub.Deny.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user