Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 105 additions & 37 deletions src/NATS.Jwt/NatsJwt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ public static class NatsJwt

private const int LibraryVersion = 2;

/// <summary>
/// Maximum accepted size of an encoded JWT string, in characters.
/// </summary>
public const int MaxTokenSize = 1024 * 1024;

/// <summary>
/// Represents the value that indicates no limit.
/// </summary>
Expand Down Expand Up @@ -108,10 +113,24 @@ public static class NatsJwt
/// <param name="seed">The seed.</param>
/// <returns>The formatted user configuration.</returns>
/// <exception cref="NatsJwtException">Thrown when the seed is not an operator, account, or user seed.</exception>
public static string FormatUserConfig(string jwt, string seed)
public static string FormatUserConfig(string jwt, string? seed = null)
{
// TODO: Decode JWT and validate
if (jwt == null)
{
throw new NatsJwtException("JWT is null");
}

if (jwt.Length > MaxTokenSize)
{
throw new NatsJwtException($"JWT exceeds maximum allowed size of {MaxTokenSize} bytes");
}

var parts = jwt.Split('.');
if (parts.Length != 3)
{
throw new NatsJwtException("Invalid JWT format");
}

var json = EncodingUtils.FromBase64UrlEncoded(parts[1]);
var fields = JsonSerializer.Deserialize(json, JsonContext.Default.NatsGenericFieldsClaims);
var type = fields!.GenericFields.Type;
Expand All @@ -122,10 +141,32 @@ public static string FormatUserConfig(string jwt, string seed)

var jwtKind = type.ToUpperInvariant();

var seedKind = seed.StartsWith("SU", StringComparison.Ordinal) ? "USER"
: seed.StartsWith("SA", StringComparison.Ordinal) ? "ACCOUNT"
: seed.StartsWith("SO", StringComparison.Ordinal) ? "OPERATOR"
: throw new NatsJwtException("Seed is not an operator, account or user seed");
if (seed == null)
{
return $"""
-----BEGIN NATS {jwtKind} JWT-----
{jwt}
------END NATS {jwtKind} JWT------
""";
}

KeyPair kp;
try
{
kp = KeyPair.FromSeed(seed.AsSpan());
}
catch (Exception ex)
{
throw new NatsJwtException("Invalid seed: " + ex.Message, ex);
}

var seedKind = kp.Prefix switch
{
PrefixByte.User => "USER",
PrefixByte.Account => "ACCOUNT",
PrefixByte.Operator => "OPERATOR",
_ => throw new NatsJwtException("Seed is not an operator, account or user seed"),
};

return $"""
-----BEGIN NATS {jwtKind} JWT-----
Expand Down Expand Up @@ -389,56 +430,77 @@ public static NatsActivationClaims DecodeActivationClaims(string jwt)
private static T DecodeClaims<T>(string jwt, JsonTypeInfo<T> jsonTypeInfo)
where T : JwtClaimsData
{
string[] parts = jwt.Split('.');
if (parts.Length != 3)
if (jwt == null)
{
throw new NatsJwtException("Invalid JWT format");
throw new NatsJwtException("JWT is null");
}

var header = JsonSerializer.Deserialize(EncodingUtils.FromBase64UrlEncoded(parts[0]), JsonContext.Default.JwtHeader);
if (header == null)
if (jwt.Length > MaxTokenSize)
{
throw new NatsJwtException("Can't parse JWT header");
throw new NatsJwtException($"JWT exceeds maximum allowed size of {MaxTokenSize} bytes");
}

header.Validate();
try
{
string[] parts = jwt.Split('.');
if (parts.Length != 3)
{
throw new NatsJwtException("Invalid JWT format");
}

var payloadJson = EncodingUtils.FromBase64UrlEncoded(parts[1]);
var header = JsonSerializer.Deserialize(EncodingUtils.FromBase64UrlEncoded(parts[0]), JsonContext.Default.JwtHeader);
if (header == null)
{
throw new NatsJwtException("Can't parse JWT header");
}

using var json = JsonDocument.Parse(payloadJson);
var jsonRoot = json.RootElement;
(string kind, int version) = GetClaimsKindAndVersion(jsonRoot);
header.Validate();

if (version > LibraryVersion)
{
throw new NatsJwtException("JWT was generated by a newer version");
}
var payloadJson = EncodingUtils.FromBase64UrlEncoded(parts[1]);

if (!ClaimTypes.TryGetValue(kind, out var claimType))
{
throw new NatsJwtException("Unsupported claim type " + kind);
}
using var json = JsonDocument.Parse(payloadJson);
var jsonRoot = json.RootElement;
(string kind, int version) = GetClaimsKindAndVersion(jsonRoot);

if (typeof(T) != claimType)
{
throw new NatsJwtException($"Claim type mismatch: requested {typeof(T)} but found {claimType} (for {kind}) in JWT");
}
if (version > LibraryVersion)
{
throw new NatsJwtException("JWT was generated by a newer version");
}

var claims = jsonRoot.Deserialize(jsonTypeInfo)!;
if (!ClaimTypes.TryGetValue(kind, out var claimType))
{
throw new NatsJwtException("Unsupported claim type " + kind);
}

byte[] signature = EncodingUtils.FromBase64UrlEncoded(parts[2]);
if (typeof(T) != claimType)
{
throw new NatsJwtException($"Claim type mismatch: requested {typeof(T)} but found {claimType} (for {kind}) in JWT");
}

if (version <= 1)
var claims = jsonRoot.Deserialize(jsonTypeInfo)!;

byte[] signature = EncodingUtils.FromBase64UrlEncoded(parts[2]);

if (version <= 1)
{
VerifyClaims(claims, parts[1], signature);
}
else
{
VerifyClaims(claims, parts[0] + "." + parts[1], signature);
}

return claims;
}
catch (NatsJwtException)
{
VerifyClaims(claims, parts[1], signature);
throw;
}
else
catch (Exception ex)
{
VerifyClaims(claims, parts[0] + "." + parts[1], signature);
throw new NatsJwtException("Invalid JWT: " + ex.Message, ex);
}

return claims;

static void VerifyClaims(JwtClaimsData claimsData, string payload, byte[] signature)
{
string issuer = claimsData.Issuer;
Expand All @@ -447,6 +509,12 @@ static void VerifyClaims(JwtClaimsData claimsData, string payload, byte[] signat
throw new NatsJwtException("Invalid JWT: can't find issuer");
}

PrefixByte[] expectedPrefixes = claimsData.ExpectedPrefixes();
if (expectedPrefixes.Length > 0 && !expectedPrefixes.Any(p => KeyPair.IsValidPublicKey(p, issuer.AsSpan())))
{
throw new NatsJwtException($"Invalid issuer key for {claimsData.GetType().Name}: expected one of '{string.Join(",", expectedPrefixes)}'");
}

KeyPair kp = KeyPair.FromPublicKey(issuer.AsSpan());
if (!kp.Verify(Encoding.ASCII.GetBytes(payload), signature))
{
Expand Down
24 changes: 22 additions & 2 deletions src/NATS.Jwt/NatsJwtException.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) The NATS Authors.
// Copyright (c) The NATS Authors.
// Licensed under the Apache License, Version 2.0.

using System;
Expand All @@ -8,4 +8,24 @@ namespace NATS.Jwt;
/// <summary>
/// Represents an exception thrown when an error related to NATS JWT occurs.
/// </summary>
public class NatsJwtException(string message) : Exception(message);
public class NatsJwtException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="NatsJwtException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
public NatsJwtException(string message)
: base(message)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="NatsJwtException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
/// <param name="innerException">The inner exception.</param>
public NatsJwtException(string message, Exception innerException)
: base(message, innerException)
{
}
}
4 changes: 3 additions & 1 deletion src/NATS.Jwt/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const NATS.Jwt.NatsJwt.AnyAccount = "*" -> string!
const NATS.Jwt.NatsJwt.AuthorizationRequestClaim = "authorization_request" -> string!
const NATS.Jwt.NatsJwt.AuthorizationResponseClaim = "authorization_response" -> string!
const NATS.Jwt.NatsJwt.GenericClaim = "generic" -> string!
const NATS.Jwt.NatsJwt.MaxTokenSize = 1048576 -> int
const NATS.Jwt.NatsJwt.NoLimit = -1 -> long
const NATS.Jwt.NatsJwt.OperatorClaim = "operator" -> string!
const NATS.Jwt.NatsJwt.TokenTypeJwt = "JWT" -> string!
Expand Down Expand Up @@ -388,6 +389,7 @@ NATS.Jwt.ModelValidationExtensions
NATS.Jwt.NatsJwt
NATS.Jwt.NatsJwtException
NATS.Jwt.NatsJwtException.NatsJwtException(string! message) -> void
NATS.Jwt.NatsJwtException.NatsJwtException(string! message, System.Exception! innerException) -> void
override NATS.Jwt.Models.NatsTags.Equals(object? obj) -> bool
override NATS.Jwt.Models.NatsTags.GetHashCode() -> int
override NATS.Jwt.Models.NatsTags.ToString() -> string!
Expand All @@ -406,7 +408,7 @@ static NATS.Jwt.NatsJwt.EncodeAuthorizationResponseClaims(NATS.Jwt.Models.NatsAu
static NATS.Jwt.NatsJwt.EncodeGenericClaims(NATS.Jwt.Models.NatsGenericClaims! genericClaims, NATS.NKeys.KeyPair! keyPair, System.DateTimeOffset? issuedAt = null) -> string!
static NATS.Jwt.NatsJwt.EncodeOperatorClaims(NATS.Jwt.Models.NatsOperatorClaims! operatorClaims, NATS.NKeys.KeyPair! keyPair, System.DateTimeOffset? issuedAt = null) -> string!
static NATS.Jwt.NatsJwt.EncodeUserClaims(NATS.Jwt.Models.NatsUserClaims! userClaims, NATS.NKeys.KeyPair! keyPair, System.DateTimeOffset? issuedAt = null) -> string!
static NATS.Jwt.NatsJwt.FormatUserConfig(string! jwt, string! seed) -> string!
static NATS.Jwt.NatsJwt.FormatUserConfig(string! jwt, string? seed = null) -> string!
static NATS.Jwt.NatsJwt.NewAccountClaims(string! subject) -> NATS.Jwt.Models.NatsAccountClaims!
static NATS.Jwt.NatsJwt.NewActivationClaims(string! subject) -> NATS.Jwt.Models.NatsActivationClaims!
static NATS.Jwt.NatsJwt.NewAuthorizationRequestClaims(string! subject) -> NATS.Jwt.Models.NatsAuthorizationRequestClaims!
Expand Down
Loading
Loading