diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs index 8c6ecbe..bce25e1 100644 --- a/src/NATS.Server/Auth/Account.cs +++ b/src/NATS.Server/Auth/Account.cs @@ -13,6 +13,24 @@ public sealed class Account : IDisposable public int MaxConnections { 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? SigningKeys { get; set; } + private readonly ConcurrentDictionary _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 _clients = new(); private int _subscriptionCount; diff --git a/src/NATS.Server/Auth/AuthService.cs b/src/NATS.Server/Auth/AuthService.cs index c17b8aa..f2ca7ff 100644 --- a/src/NATS.Server/Auth/AuthService.cs +++ b/src/NATS.Server/Auth/AuthService.cs @@ -41,6 +41,14 @@ public sealed class AuthService 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 if (options.NKeys is { Count: > 0 }) @@ -99,7 +107,8 @@ public sealed class AuthService && string.IsNullOrEmpty(opts.Password) && string.IsNullOrEmpty(opts.Token) && string.IsNullOrEmpty(opts.Nkey) - && string.IsNullOrEmpty(opts.Sig); + && string.IsNullOrEmpty(opts.Sig) + && string.IsNullOrEmpty(opts.JWT); } private AuthResult? ResolveNoAuthUser() diff --git a/src/NATS.Server/Auth/Jwt/AccountClaims.cs b/src/NATS.Server/Auth/Jwt/AccountClaims.cs index 07513da..d581d98 100644 --- a/src/NATS.Server/Auth/Jwt/AccountClaims.cs +++ b/src/NATS.Server/Auth/Jwt/AccountClaims.cs @@ -58,6 +58,10 @@ public sealed class AccountNats [JsonPropertyName("revocations")] public Dictionary? Revocations { get; set; } + /// Tags associated with this account. + [JsonPropertyName("tags")] + public string[]? Tags { get; set; } + /// Claim type (e.g., "account"). [JsonPropertyName("type")] public string? Type { get; set; } diff --git a/src/NATS.Server/Auth/JwtAuthenticator.cs b/src/NATS.Server/Auth/JwtAuthenticator.cs new file mode 100644 index 0000000..f28a155 --- /dev/null +++ b/src/NATS.Server/Auth/JwtAuthenticator.cs @@ -0,0 +1,160 @@ +using NATS.Server.Auth.Jwt; + +namespace NATS.Server.Auth; + +/// +/// Authenticator for JWT-based client connections. +/// Decodes user JWT, resolves account, verifies signature, checks revocation. +/// Reference: Go auth.go:588+ processClientOrLeafAuthentication. +/// +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; + } +} diff --git a/src/NATS.Server/NatsOptions.cs b/src/NATS.Server/NatsOptions.cs index 016f2fd..a31ede2 100644 --- a/src/NATS.Server/NatsOptions.cs +++ b/src/NATS.Server/NatsOptions.cs @@ -90,6 +90,10 @@ public sealed class NatsOptions public OcspConfig? OcspConfig { 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) public Dictionary? LogOverrides { get; set; } diff --git a/src/NATS.Server/Protocol/NatsProtocol.cs b/src/NATS.Server/Protocol/NatsProtocol.cs index 70dfb3c..ab4ef7a 100644 --- a/src/NATS.Server/Protocol/NatsProtocol.cs +++ b/src/NATS.Server/Protocol/NatsProtocol.cs @@ -134,4 +134,7 @@ public sealed class ClientOptions [JsonPropertyName("sig")] public string? Sig { get; set; } + + [JsonPropertyName("jwt")] + public string? JWT { get; set; } } diff --git a/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs b/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs new file mode 100644 index 0000000..7cb0eaf --- /dev/null +++ b/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs @@ -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(); + } +}