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; }
|
||||
}
|
||||
Reference in New Issue
Block a user