feat: add JwtAuthenticator with account resolution, revocation, and template expansion
This commit is contained in:
@@ -13,6 +13,24 @@ public sealed class Account : IDisposable
|
|||||||
public int MaxConnections { get; set; } // 0 = unlimited
|
public int MaxConnections { get; set; } // 0 = unlimited
|
||||||
public int MaxSubscriptions { get; set; } // 0 = unlimited
|
public int MaxSubscriptions { get; set; } // 0 = unlimited
|
||||||
|
|
||||||
|
// JWT fields
|
||||||
|
public string? Nkey { get; set; }
|
||||||
|
public string? Issuer { get; set; }
|
||||||
|
public Dictionary<string, object>? SigningKeys { get; set; }
|
||||||
|
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
|
||||||
|
|
||||||
|
public bool IsUserRevoked(string userNkey, long issuedAt)
|
||||||
|
{
|
||||||
|
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
|
||||||
|
return issuedAt <= revokedAt;
|
||||||
|
// Check "*" wildcard for all-user revocation
|
||||||
|
if (_revokedUsers.TryGetValue("*", out revokedAt))
|
||||||
|
return issuedAt <= revokedAt;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
|
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
|
||||||
private int _subscriptionCount;
|
private int _subscriptionCount;
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ public sealed class AuthService
|
|||||||
authRequired = true;
|
authRequired = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JWT / Operator mode (highest priority after TLS)
|
||||||
|
if (options.TrustedKeys is { Length: > 0 } && options.AccountResolver is not null)
|
||||||
|
{
|
||||||
|
authenticators.Add(new JwtAuthenticator(options.TrustedKeys, options.AccountResolver));
|
||||||
|
authRequired = true;
|
||||||
|
nonceRequired = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
|
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
|
||||||
|
|
||||||
if (options.NKeys is { Count: > 0 })
|
if (options.NKeys is { Count: > 0 })
|
||||||
@@ -99,7 +107,8 @@ public sealed class AuthService
|
|||||||
&& string.IsNullOrEmpty(opts.Password)
|
&& string.IsNullOrEmpty(opts.Password)
|
||||||
&& string.IsNullOrEmpty(opts.Token)
|
&& string.IsNullOrEmpty(opts.Token)
|
||||||
&& string.IsNullOrEmpty(opts.Nkey)
|
&& string.IsNullOrEmpty(opts.Nkey)
|
||||||
&& string.IsNullOrEmpty(opts.Sig);
|
&& string.IsNullOrEmpty(opts.Sig)
|
||||||
|
&& string.IsNullOrEmpty(opts.JWT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuthResult? ResolveNoAuthUser()
|
private AuthResult? ResolveNoAuthUser()
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ public sealed class AccountNats
|
|||||||
[JsonPropertyName("revocations")]
|
[JsonPropertyName("revocations")]
|
||||||
public Dictionary<string, long>? Revocations { get; set; }
|
public Dictionary<string, long>? Revocations { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tags associated with this account.</summary>
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public string[]? Tags { get; set; }
|
||||||
|
|
||||||
/// <summary>Claim type (e.g., "account").</summary>
|
/// <summary>Claim type (e.g., "account").</summary>
|
||||||
[JsonPropertyName("type")]
|
[JsonPropertyName("type")]
|
||||||
public string? Type { get; set; }
|
public string? Type { get; set; }
|
||||||
|
|||||||
160
src/NATS.Server/Auth/JwtAuthenticator.cs
Normal file
160
src/NATS.Server/Auth/JwtAuthenticator.cs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
using NATS.Server.Auth.Jwt;
|
||||||
|
|
||||||
|
namespace NATS.Server.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticator for JWT-based client connections.
|
||||||
|
/// Decodes user JWT, resolves account, verifies signature, checks revocation.
|
||||||
|
/// Reference: Go auth.go:588+ processClientOrLeafAuthentication.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JwtAuthenticator : IAuthenticator
|
||||||
|
{
|
||||||
|
private readonly string[] _trustedKeys;
|
||||||
|
private readonly IAccountResolver _resolver;
|
||||||
|
|
||||||
|
public JwtAuthenticator(string[] trustedKeys, IAccountResolver resolver)
|
||||||
|
{
|
||||||
|
_trustedKeys = trustedKeys;
|
||||||
|
_resolver = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthResult? Authenticate(ClientAuthContext context)
|
||||||
|
{
|
||||||
|
var jwt = context.Opts.JWT;
|
||||||
|
if (string.IsNullOrEmpty(jwt) || !NatsJwt.IsJwt(jwt))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// 1. Decode user claims
|
||||||
|
var userClaims = NatsJwt.DecodeUserClaims(jwt);
|
||||||
|
if (userClaims is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// 2. Check expiry
|
||||||
|
if (userClaims.IsExpired())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// 3. Resolve issuing account
|
||||||
|
var issuerAccount = !string.IsNullOrEmpty(userClaims.IssuerAccount)
|
||||||
|
? userClaims.IssuerAccount
|
||||||
|
: userClaims.Issuer;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(issuerAccount))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var accountJwt = _resolver.FetchAsync(issuerAccount).GetAwaiter().GetResult();
|
||||||
|
if (accountJwt is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var accountClaims = NatsJwt.DecodeAccountClaims(accountJwt);
|
||||||
|
if (accountClaims is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// 4. Verify account issuer is trusted
|
||||||
|
if (!IsTrusted(accountClaims.Issuer))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// 5. Verify user JWT issuer is the account or a signing key
|
||||||
|
var userIssuer = userClaims.Issuer;
|
||||||
|
if (userIssuer != accountClaims.Subject)
|
||||||
|
{
|
||||||
|
// Check if issuer is a signing key of the account
|
||||||
|
var signingKeys = accountClaims.Nats?.SigningKeys;
|
||||||
|
if (signingKeys is null || !signingKeys.Contains(userIssuer))
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify nonce signature (unless bearer token)
|
||||||
|
if (!userClaims.BearerToken)
|
||||||
|
{
|
||||||
|
if (context.Nonce is null || string.IsNullOrEmpty(context.Opts.Sig))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var userNkey = userClaims.Subject ?? context.Opts.Nkey;
|
||||||
|
if (string.IsNullOrEmpty(userNkey))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!NatsJwt.VerifyNonce(context.Nonce, context.Opts.Sig, userNkey))
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Check user revocation
|
||||||
|
var revocations = accountClaims.Nats?.Revocations;
|
||||||
|
if (revocations is not null && userClaims.Subject is not null)
|
||||||
|
{
|
||||||
|
if (revocations.TryGetValue(userClaims.Subject, out var revokedAt))
|
||||||
|
{
|
||||||
|
if (userClaims.IssuedAt <= revokedAt)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check wildcard revocation
|
||||||
|
if (revocations.TryGetValue("*", out revokedAt))
|
||||||
|
{
|
||||||
|
if (userClaims.IssuedAt <= revokedAt)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Build permissions from JWT claims
|
||||||
|
Permissions? permissions = null;
|
||||||
|
var nats = userClaims.Nats;
|
||||||
|
if (nats is not null)
|
||||||
|
{
|
||||||
|
var pubAllow = nats.Pub?.Allow;
|
||||||
|
var pubDeny = nats.Pub?.Deny;
|
||||||
|
var subAllow = nats.Sub?.Allow;
|
||||||
|
var subDeny = nats.Sub?.Deny;
|
||||||
|
|
||||||
|
// Expand permission templates
|
||||||
|
var name = userClaims.Name ?? "";
|
||||||
|
var subject = userClaims.Subject ?? "";
|
||||||
|
var acctName = accountClaims.Name ?? "";
|
||||||
|
var acctSubject = accountClaims.Subject ?? "";
|
||||||
|
var userTags = nats.Tags ?? [];
|
||||||
|
var acctTags = accountClaims.Nats?.Tags ?? [];
|
||||||
|
|
||||||
|
if (pubAllow is { Length: > 0 })
|
||||||
|
pubAllow = PermissionTemplates.ExpandAll(pubAllow, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||||
|
if (pubDeny is { Length: > 0 })
|
||||||
|
pubDeny = PermissionTemplates.ExpandAll(pubDeny, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||||
|
if (subAllow is { Length: > 0 })
|
||||||
|
subAllow = PermissionTemplates.ExpandAll(subAllow, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||||
|
if (subDeny is { Length: > 0 })
|
||||||
|
subDeny = PermissionTemplates.ExpandAll(subDeny, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||||
|
|
||||||
|
if (pubAllow is not null || pubDeny is not null || subAllow is not null || subDeny is not null)
|
||||||
|
{
|
||||||
|
permissions = new Permissions
|
||||||
|
{
|
||||||
|
Publish = (pubAllow is not null || pubDeny is not null)
|
||||||
|
? new SubjectPermission { Allow = pubAllow, Deny = pubDeny }
|
||||||
|
: null,
|
||||||
|
Subscribe = (subAllow is not null || subDeny is not null)
|
||||||
|
? new SubjectPermission { Allow = subAllow, Deny = subDeny }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Build result
|
||||||
|
return new AuthResult
|
||||||
|
{
|
||||||
|
Identity = userClaims.Subject ?? "",
|
||||||
|
AccountName = issuerAccount,
|
||||||
|
Permissions = permissions,
|
||||||
|
Expiry = userClaims.GetExpiry(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsTrusted(string? issuer)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(issuer)) return false;
|
||||||
|
foreach (var key in _trustedKeys)
|
||||||
|
{
|
||||||
|
if (key == issuer)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,10 @@ public sealed class NatsOptions
|
|||||||
public OcspConfig? OcspConfig { get; set; }
|
public OcspConfig? OcspConfig { get; set; }
|
||||||
public bool OcspPeerVerify { get; set; }
|
public bool OcspPeerVerify { get; set; }
|
||||||
|
|
||||||
|
// JWT / Operator mode
|
||||||
|
public string[]? TrustedKeys { get; set; }
|
||||||
|
public Auth.Jwt.IAccountResolver? AccountResolver { get; set; }
|
||||||
|
|
||||||
// Per-subsystem log level overrides (namespace -> level)
|
// Per-subsystem log level overrides (namespace -> level)
|
||||||
public Dictionary<string, string>? LogOverrides { get; set; }
|
public Dictionary<string, string>? LogOverrides { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -134,4 +134,7 @@ public sealed class ClientOptions
|
|||||||
|
|
||||||
[JsonPropertyName("sig")]
|
[JsonPropertyName("sig")]
|
||||||
public string? Sig { get; set; }
|
public string? Sig { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("jwt")]
|
||||||
|
public string? JWT { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
591
tests/NATS.Server.Tests/JwtAuthenticatorTests.cs
Normal file
591
tests/NATS.Server.Tests/JwtAuthenticatorTests.cs
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
using System.Text;
|
||||||
|
using NATS.NKeys;
|
||||||
|
using NATS.Server.Auth;
|
||||||
|
using NATS.Server.Auth.Jwt;
|
||||||
|
using NATS.Server.Protocol;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests;
|
||||||
|
|
||||||
|
public class JwtAuthenticatorTests
|
||||||
|
{
|
||||||
|
private static string Base64UrlEncode(string input) =>
|
||||||
|
Base64UrlEncode(Encoding.UTF8.GetBytes(input));
|
||||||
|
|
||||||
|
private static string Base64UrlEncode(byte[] input) =>
|
||||||
|
Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
|
||||||
|
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
|
||||||
|
{
|
||||||
|
var header = Base64UrlEncode("""{"typ":"JWT","alg":"ed25519-nkey"}""");
|
||||||
|
var payload = Base64UrlEncode(payloadJson);
|
||||||
|
var signingInput = Encoding.UTF8.GetBytes($"{header}.{payload}");
|
||||||
|
var sig = new byte[64];
|
||||||
|
signingKey.Sign(signingInput, sig);
|
||||||
|
return $"{header}.{payload}.{Base64UrlEncode(sig)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SignNonce(KeyPair kp, byte[] nonce)
|
||||||
|
{
|
||||||
|
var sig = new byte[64];
|
||||||
|
kp.Sign(nonce, sig);
|
||||||
|
return Convert.ToBase64String(sig).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Valid_bearer_jwt_returns_auth_result()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
var accountPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{accountPub}}",
|
||||||
|
"iss":"{{operatorPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{"type":"account","version":2}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||||
|
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{accountPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"bearer_token":true,
|
||||||
|
"issuer_account":"{{accountPub}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||||
|
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
await resolver.StoreAsync(accountPub, accountJwt);
|
||||||
|
|
||||||
|
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt },
|
||||||
|
Nonce = "test-nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = auth.Authenticate(ctx);
|
||||||
|
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Identity.ShouldBe(userPub);
|
||||||
|
result.AccountName.ShouldBe(accountPub);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Valid_jwt_with_nonce_signature_returns_auth_result()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
var accountPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{accountPub}}",
|
||||||
|
"iss":"{{operatorPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{"type":"account","version":2}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||||
|
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{accountPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"issuer_account":"{{accountPub}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||||
|
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
await resolver.StoreAsync(accountPub, accountJwt);
|
||||||
|
|
||||||
|
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||||
|
|
||||||
|
var nonce = "test-nonce-data"u8.ToArray();
|
||||||
|
var sig = SignNonce(userKp, nonce);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt, Nkey = userPub, Sig = sig },
|
||||||
|
Nonce = nonce,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = auth.Authenticate(ctx);
|
||||||
|
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Identity.ShouldBe(userPub);
|
||||||
|
result.AccountName.ShouldBe(accountPub);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_jwt_returns_null()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions(),
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
auth.Authenticate(ctx).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Non_jwt_string_returns_null()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = "not-a-jwt" },
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
auth.Authenticate(ctx).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Expired_jwt_returns_null()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
var accountPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{accountPub}}",
|
||||||
|
"iss":"{{operatorPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{"type":"account","version":2}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||||
|
|
||||||
|
// Expired in 2020
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{accountPub}}",
|
||||||
|
"iat":1500000000,
|
||||||
|
"exp":1600000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"bearer_token":true,
|
||||||
|
"issuer_account":"{{accountPub}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||||
|
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
await resolver.StoreAsync(accountPub, accountJwt);
|
||||||
|
|
||||||
|
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt },
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
auth.Authenticate(ctx).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Revoked_user_returns_null()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
// Account JWT with revocation for user
|
||||||
|
var accountPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{accountPub}}",
|
||||||
|
"iss":"{{operatorPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"account","version":2,
|
||||||
|
"revocations":{
|
||||||
|
"{{userPub}}":1700000001
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||||
|
|
||||||
|
// User JWT issued at 1700000000 (before revocation time 1700000001)
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{accountPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"bearer_token":true,
|
||||||
|
"issuer_account":"{{accountPub}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||||
|
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
await resolver.StoreAsync(accountPub, accountJwt);
|
||||||
|
|
||||||
|
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt },
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
auth.Authenticate(ctx).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Untrusted_operator_returns_null()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
var accountPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{accountPub}}",
|
||||||
|
"iss":"{{operatorPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{"type":"account","version":2}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||||
|
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{accountPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"bearer_token":true,
|
||||||
|
"issuer_account":"{{accountPub}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||||
|
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
await resolver.StoreAsync(accountPub, accountJwt);
|
||||||
|
|
||||||
|
// Use a different trusted key that doesn't match the operator
|
||||||
|
var otherOperator = KeyPair.CreatePair(PrefixByte.Operator).GetPublicKey();
|
||||||
|
var auth = new JwtAuthenticator([otherOperator], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt },
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
auth.Authenticate(ctx).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_account_returns_null()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{accountPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"bearer_token":true,
|
||||||
|
"issuer_account":"{{accountPub}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||||
|
|
||||||
|
// Don't store the account JWT in the resolver
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt },
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
auth.Authenticate(ctx).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Non_bearer_without_sig_returns_null()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
var accountPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{accountPub}}",
|
||||||
|
"iss":"{{operatorPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{"type":"account","version":2}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||||
|
|
||||||
|
// Non-bearer user JWT
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{accountPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"issuer_account":"{{accountPub}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||||
|
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
await resolver.StoreAsync(accountPub, accountJwt);
|
||||||
|
|
||||||
|
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt }, // No Sig provided
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
auth.Authenticate(ctx).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Jwt_with_permissions_returns_permissions()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
var accountPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{accountPub}}",
|
||||||
|
"iss":"{{operatorPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{"type":"account","version":2}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||||
|
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{accountPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"bearer_token":true,
|
||||||
|
"issuer_account":"{{accountPub}}",
|
||||||
|
"pub":{"allow":["foo.>","bar.*"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||||
|
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
await resolver.StoreAsync(accountPub, accountJwt);
|
||||||
|
|
||||||
|
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt },
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = auth.Authenticate(ctx);
|
||||||
|
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Permissions.ShouldNotBeNull();
|
||||||
|
result.Permissions.Publish.ShouldNotBeNull();
|
||||||
|
result.Permissions.Publish.Allow.ShouldNotBeNull();
|
||||||
|
result.Permissions.Publish.Allow.ShouldContain("foo.>");
|
||||||
|
result.Permissions.Publish.Allow.ShouldContain("bar.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Signing_key_based_user_jwt_succeeds()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var signingPub = signingKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
// Account JWT with signing key
|
||||||
|
var accountPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{accountPub}}",
|
||||||
|
"iss":"{{operatorPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"account","version":2,
|
||||||
|
"signing_keys":["{{signingPub}}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||||
|
|
||||||
|
// User JWT issued by the signing key
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{signingPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"bearer_token":true,
|
||||||
|
"issuer_account":"{{accountPub}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, signingKp);
|
||||||
|
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
await resolver.StoreAsync(accountPub, accountJwt);
|
||||||
|
|
||||||
|
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt },
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = auth.Authenticate(ctx);
|
||||||
|
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Identity.ShouldBe(userPub);
|
||||||
|
result.AccountName.ShouldBe(accountPub);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wildcard_revocation_returns_null()
|
||||||
|
{
|
||||||
|
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||||
|
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||||
|
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||||
|
|
||||||
|
var operatorPub = operatorKp.GetPublicKey();
|
||||||
|
var accountPub = accountKp.GetPublicKey();
|
||||||
|
var userPub = userKp.GetPublicKey();
|
||||||
|
|
||||||
|
// Account JWT with wildcard revocation
|
||||||
|
var accountPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{accountPub}}",
|
||||||
|
"iss":"{{operatorPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"account","version":2,
|
||||||
|
"revocations":{
|
||||||
|
"*":1700000001
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||||
|
|
||||||
|
// User JWT issued at 1700000000 (before wildcard revocation)
|
||||||
|
var userPayload = $$"""
|
||||||
|
{
|
||||||
|
"sub":"{{userPub}}",
|
||||||
|
"iss":"{{accountPub}}",
|
||||||
|
"iat":1700000000,
|
||||||
|
"nats":{
|
||||||
|
"type":"user","version":2,
|
||||||
|
"bearer_token":true,
|
||||||
|
"issuer_account":"{{accountPub}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||||
|
|
||||||
|
var resolver = new MemAccountResolver();
|
||||||
|
await resolver.StoreAsync(accountPub, accountJwt);
|
||||||
|
|
||||||
|
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { JWT = userJwt },
|
||||||
|
Nonce = "nonce"u8.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
auth.Authenticate(ctx).ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user