Auth callout request has no TLS info for leafnode connections accepted over WebSocket
Observed behavior
When a leafnode connects to a hub over WebSocket with TLS (wss://host:port/leafnode), the auth callout request ($SYS.REQ.USER.AUTH) has no client_tls section, even though the hub verified the leaf's client certificate during the TLS handshake (websocket { tls { verify: true } }).
The same leaf, with the same certificate, connecting to the native leafnode listener (nats-leaf://), produces a callout request that includes client_tls.verified_chains as expected. A plain WebSocket client (not a leafnode) over wss:// also receives the TLS info.
As a result, an auth callout service cannot authorize WebSocket leafnodes by their client certificate. The only alternative is to carry credentials some other way. Admitting the connection anyway would accept any certificate the CA trusts, without knowing which one.
Expected behavior
Callout requests for leafnodes accepted on the WebSocket listener include client_tls (version, cipher, certs, verified chains), the same as for WebSocket clients and native leafnode connections.
Server and client version
- nats-server v2.14.6 and v2.15.0
- Server-config mode (
authorization.auth_callout). The same gap is visible in operator mode (account-level callout).
Host environment
macOS / Linux amd64. Reproduced with embedded servers in a Go test; no special environment is needed.
Steps to reproduce
Self-contained Go test. The hub requires and verifies client certificates on both the leafnode and websocket listeners. The callout admits a connection only if the request carries a verified chain. The nats-leaf subtest passes; the wss subtest fails.
package repro
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/nats-io/jwt/v2"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nkeys"
)
func issue(t *testing.T, dir, name string, ca *x509.Certificate, caKey *ecdsa.PrivateKey, srv bool) (string, string, *x509.Certificate, *ecdsa.PrivateKey) {
t.Helper()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
parent, signer := tpl, key
switch {
case ca == nil:
tpl.IsCA, tpl.BasicConstraintsValid = true, true
tpl.KeyUsage |= x509.KeyUsageCertSign
case srv:
parent, signer = ca, caKey
tpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
tpl.IPAddresses = []net.IP{net.ParseIP("127.0.0.1")}
default:
parent, signer = ca, caKey
tpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
}
der, err := x509.CreateCertificate(rand.Reader, tpl, parent, &key.PublicKey, signer)
if err != nil {
t.Fatal(err)
}
cert, _ := x509.ParseCertificate(der)
kb, _ := x509.MarshalECPrivateKey(key)
c, k := filepath.Join(dir, name+".crt"), filepath.Join(dir, name+".key")
os.WriteFile(c, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
os.WriteFile(k, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600)
return c, k, cert, key
}
// The hub requires and verifies client certificates on both the leafnode and
// websocket listeners, and delegates authorization to an auth callout that
// admits a connection only if the request carries a verified client chain.
// A leaf presenting the same certificate is admitted over nats-leaf:// but not
// over wss://.../leafnode, because the callout request has no TLS section.
func TestAuthCalloutLeafnodeWebsocketTLS(t *testing.T) {
dir := t.TempDir()
caCrt, _, ca, caKey := issue(t, dir, "ca", nil, nil, false)
srvCrt, srvKey, _, _ := issue(t, dir, "server", ca, caKey, true)
leafCrt, leafKey, _, _ := issue(t, dir, "leaf", ca, caKey, false)
issuerKP, _ := nkeys.CreateAccount()
issuerPub, _ := issuerKP.PublicKey()
tlsBlock := fmt.Sprintf(`tls { cert_file: %q, key_file: %q, ca_file: %q, verify: true }`, srvCrt, srvKey, caCrt)
conf := filepath.Join(dir, "hub.conf")
os.WriteFile(conf, []byte(fmt.Sprintf(`
port: -1
accounts {
AUTH: { users: [ { user: auth, password: pwd } ] }
APP: {}
}
authorization { auth_callout { issuer: %q, account: AUTH, auth_users: [ auth ] } }
leafnodes { port: -1, %s }
websocket { port: -1, %s }
`, issuerPub, tlsBlock, tlsBlock)), 0o600)
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatal(err)
}
hub, err := server.NewServer(opts)
if err != nil {
t.Fatal(err)
}
go hub.Start()
defer hub.Shutdown()
if !hub.ReadyForConnections(5 * time.Second) {
t.Fatal("hub not ready")
}
svc, err := nats.Connect(fmt.Sprintf("nats://127.0.0.1:%d", opts.Port), nats.UserInfo("auth", "pwd"))
if err != nil {
t.Fatal(err)
}
defer svc.Close()
svc.Subscribe("$SYS.REQ.USER.AUTH", func(m *nats.Msg) {
req, _ := jwt.DecodeAuthorizationRequestClaims(string(m.Data))
t.Logf("callout: kind=%s type=%q tls_present=%v", req.ClientInformation.Kind, req.ClientInformation.Type, req.TLS != nil)
resp := jwt.NewAuthorizationResponseClaims(req.UserNkey)
resp.Audience = req.Server.ID
if req.TLS == nil || len(req.TLS.VerifiedChains) == 0 {
resp.Error = "no verified client certificate in callout request"
} else {
uc := jwt.NewUserClaims(req.UserNkey)
uc.Audience = "APP"
resp.Jwt, _ = uc.Encode(issuerKP)
}
tok, _ := resp.Encode(issuerKP)
m.Respond([]byte(tok))
})
svc.Flush()
for _, tc := range []struct{ name, url string }{
{"nats-leaf", fmt.Sprintf("nats-leaf://127.0.0.1:%d", opts.LeafNode.Port)},
{"wss", fmt.Sprintf("wss://127.0.0.1:%d/leafnode", opts.Websocket.Port)},
} {
t.Run(tc.name, func(t *testing.T) {
lconf := filepath.Join(dir, tc.name+".conf")
os.WriteFile(lconf, []byte(fmt.Sprintf(`
port: -1
leafnodes { remotes: [ { url: %q, tls { cert_file: %q, key_file: %q, ca_file: %q } } ] }
`, tc.url, leafCrt, leafKey, caCrt)), 0o600)
lo, err := server.ProcessConfigFile(lconf)
if err != nil {
t.Fatal(err)
}
leaf, err := server.NewServer(lo)
if err != nil {
t.Fatal(err)
}
go leaf.Start()
defer leaf.Shutdown()
deadline := time.Now().Add(5 * time.Second)
for leaf.NumLeafNodes() == 0 && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
if leaf.NumLeafNodes() == 0 {
t.Fatalf("leaf over %s was not admitted: callout request had no TLS info", tc.name)
}
})
}
}Output on v2.14.6:
callout: kind=Leafnode type="" tls_present=true <- nats-leaf://
callout: kind=Leafnode type="" tls_present=false <- wss://.../leafnode
--- FAIL: TestAuthCalloutLeafnodeWebsocketTLS/wss
leaf over wss was not admitted: callout request had no TLS info
### Submission acknowledgment
- [x] I am a human being writing in my own words and not an AI agent. I will not use an AI agent to communicate on my behalf in this issue, either directly or via copy-paste.Source: nats-io/nats-server