# Conflicts: # differences.md # docs/plans/2026-02-23-jetstream-full-parity-plan.md # src/NATS.Server/Auth/Account.cs # src/NATS.Server/Configuration/ConfigProcessor.cs # src/NATS.Server/Monitoring/VarzHandler.cs # src/NATS.Server/NatsClient.cs # src/NATS.Server/NatsOptions.cs # src/NATS.Server/NatsServer.cs
181 lines
6.3 KiB
C#
181 lines
6.3 KiB
C#
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;
|
|
}
|
|
}
|
|
|
|
// 7b. Check allowed connection types
|
|
var (allowedTypes, hasUnknown) = JwtConnectionTypes.Convert(userClaims.Nats?.AllowedConnectionTypes);
|
|
|
|
if (allowedTypes.Count == 0)
|
|
{
|
|
if (hasUnknown)
|
|
return null; // unknown-only list should reject
|
|
}
|
|
else
|
|
{
|
|
var connType = string.IsNullOrWhiteSpace(context.ConnectionType)
|
|
? JwtConnectionTypes.Standard
|
|
: context.ConnectionType.ToUpperInvariant();
|
|
|
|
if (!allowedTypes.Contains(connType))
|
|
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(),
|
|
MaxJetStreamStreams = accountClaims.Nats?.JetStream?.MaxStreams ?? 0,
|
|
JetStreamTier = accountClaims.Nats?.JetStream?.Tier,
|
|
};
|
|
}
|
|
|
|
private bool IsTrusted(string? issuer)
|
|
{
|
|
if (string.IsNullOrEmpty(issuer)) return false;
|
|
foreach (var key in _trustedKeys)
|
|
{
|
|
if (key == issuer)
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|