Skip to content
Open
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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,38 @@
- Watch [a video overview](https://rethink.synadia.com/episodes/1/) of NATS.
- Watch [this video from SCALE 13x](https://www.youtube.com/watch?v=sm63oAVPqAM) to learn more about its origin story and design philosophy.

## HTTP Endpoint Authentication

The NATS server supports delegating username/password authentication to an external HTTP endpoint. When clients connect with credentials, the server validates them by POSTing to your auth service. This is useful when integrating with existing identity providers or custom auth backends.

**Configuration example:**

```conf
authorization {
auth_http {
url: "http://auth-service:8080/verify"
timeout: 5
}
}
```

The auth endpoint receives a POST request with JSON body `{"username": "...", "password": "..."}`.

**Response:**
- **2xx** = authentication success. The response body may optionally include permissions to restrict publish/subscribe access:
```json
{
"permissions": {
"publish": { "allow": ["foo.*", "bar.>"], "deny": ["secret.>"] },
"subscribe": { "allow": ["foo.*", "bar.>"], "deny": ["secret.>"] }
}
}
```
Omit `permissions` or leave the body empty for full access.
- **4xx/5xx** = authentication failure.

**Alternative: Auth Callout (NATS subject)** — For JWT-based or NATS-native auth, you can use `auth_callout` which subscribes to the `$SYS.REQ.USER.AUTH` subject. Your auth service responds to requests on that subject. See the [auth callout documentation](https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_callout) for details.

## Contact

- [Twitter](https://twitter.com/nats_io): Follow us on Twitter!
Expand Down
139 changes: 139 additions & 0 deletions server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@
package server

import (
"bytes"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"slices"
Expand Down Expand Up @@ -280,6 +284,8 @@ func (s *Server) configureAuthorization() {
s.info.AuthRequired = true
} else if opts.Username != _EMPTY_ || opts.Authorization != _EMPTY_ {
s.info.AuthRequired = true
} else if opts.AuthHTTP != nil {
s.info.AuthRequired = true
Comment on lines +287 to +288
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear stale local credentials when auth_http is configured

When configureAuthorization takes the new opts.AuthHTTP != nil branch, it marks auth as required but does not reset s.users/s.nkeys. After a config reload from static users/nkeys to auth_http-only, those old in-memory credentials remain populated and are evaluated before checkAuthHTTP in processClientOrLeafAuthentication, so previously valid local usernames can still authenticate and bypass the external HTTP verifier until restart.

Useful? React with 👍 / 👎.

} else {
s.users = nil
s.nkeys = nil
Expand Down Expand Up @@ -1123,6 +1129,25 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) (au
return ok
}

// Check for HTTP-based authentication when auth_http is configured.
if opts.AuthHTTP != nil && c.opts.Username != _EMPTY_ && c.opts.Password != _EMPTY_ {
if proxyRequired = opts.ProxyRequired; proxyRequired && !trustedProxy {
return setProxyAuthError(ErrAuthProxyRequired)
}
perms, ok := s.checkAuthHTTP(opts.AuthHTTP, c.opts.Username, c.opts.Password)
if ok {
authUser := &User{
Username: c.opts.Username,
Account: nil, // global account
Permissions: perms,
}
c.RegisterUser(authUser)
return true
}
c.Debugf("HTTP authentication failed for user %q", c.opts.Username)
return false
}

// Check for the use of simple auth.
if c.kind == CLIENT || c.kind == LEAF {
if proxyRequired = opts.ProxyRequired; proxyRequired && !trustedProxy {
Expand Down Expand Up @@ -1592,6 +1617,120 @@ func comparePasswords(serverPassword, clientPassword string) bool {
return true
}

// authHTTPResponse is the expected JSON structure from the auth HTTP endpoint.
// When the endpoint returns 2xx, the body may include permissions to restrict
// what subjects the user can publish to and subscribe to.
type authHTTPResponse struct {
Permissions *authHTTPPermissions `json:"permissions,omitempty"`
}

type authHTTPPermissions struct {
Publish *authHTTPSubjectPermission `json:"publish,omitempty"`
Subscribe *authHTTPSubjectPermission `json:"subscribe,omitempty"`
}

type authHTTPSubjectPermission struct {
Allow []string `json:"allow,omitempty"`
Deny []string `json:"deny,omitempty"`
}

// checkAuthHTTP validates username and password against an external HTTP endpoint.
// The server POSTs JSON {"username": "...", "password": "..."} to the URL.
// A 2xx response indicates success; 4xx/5xx or network errors indicate failure.
// On success, the response body may include a "permissions" object to restrict
// publish/subscribe access. If omitted or invalid, the user gets full access.
func (s *Server) checkAuthHTTP(ah *AuthHTTP, username, password string) (*Permissions, bool) {
timeout := ah.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
client := &http.Client{Timeout: timeout}

body, err := json.Marshal(map[string]string{
"username": username,
"password": password,
})
if err != nil {
s.Debugf("HTTP auth: failed to marshal request: %v", err)
return nil, false
}

req, err := http.NewRequest(http.MethodPost, ah.URL, bytes.NewReader(body))
if err != nil {
s.Debugf("HTTP auth: failed to create request: %v", err)
return nil, false
}
req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)
if err != nil {
s.Debugf("HTTP auth: request failed: %v", err)
return nil, false
}
defer resp.Body.Close()

// 4xx/5xx indicate authentication failure
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, false
}

// Parse optional permissions from response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
s.Debugf("HTTP auth: failed to read response: %v", err)
return nil, true // auth succeeded, use full access
}

if len(respBody) == 0 {
return nil, true // empty body, full access
}

var authResp authHTTPResponse
if err := json.Unmarshal(respBody, &authResp); err != nil {
s.Debugf("HTTP auth: failed to parse response (using full access): %v", err)
return nil, true // invalid JSON, backward compat: full access
}

if authResp.Permissions == nil {
return nil, true // no permissions in response, full access
}

perms := authHTTPPermissionsToPermissions(authResp.Permissions)
if perms != nil {
validateResponsePermissions(perms)
}
return perms, true
}

// authHTTPPermissionsToPermissions converts the HTTP response format to server Permissions.
func authHTTPPermissionsToPermissions(p *authHTTPPermissions) *Permissions {
if p == nil {
return nil
}
// If both are nil/empty, treat as full access
if (p.Publish == nil || (len(p.Publish.Allow) == 0 && len(p.Publish.Deny) == 0)) &&
(p.Subscribe == nil || (len(p.Subscribe.Allow) == 0 && len(p.Subscribe.Deny) == 0)) {
return nil
}
perms := &Permissions{}
if p.Publish != nil && (len(p.Publish.Allow) > 0 || len(p.Publish.Deny) > 0) {
perms.Publish = &SubjectPermission{
Allow: p.Publish.Allow,
Deny: p.Publish.Deny,
}
}
if p.Subscribe != nil && (len(p.Subscribe.Allow) > 0 || len(p.Subscribe.Deny) > 0) {
perms.Subscribe = &SubjectPermission{
Allow: p.Subscribe.Allow,
Deny: p.Subscribe.Deny,
}
}
if perms.Publish == nil && perms.Subscribe == nil {
return nil
}
return perms
}

func validateAuth(o *Options) error {
if err := validatePinnedCerts(o.TLSPinnedCerts); err != nil {
return err
Expand Down
97 changes: 97 additions & 0 deletions server/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"reflect"
Expand Down Expand Up @@ -760,3 +762,98 @@ func TestAuthProxyRequired(t *testing.T) {
s.Shutdown()
drainLog()
}

func TestAuthHTTPWithPermissions(t *testing.T) {
// Mock auth endpoint that validates user/pass and returns permissions
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if req.Username != "alice" || req.Password != "secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Return permissions: allow publish/subscribe to "allowed.>", deny "denied.>"
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"permissions": map[string]any{
"publish": map[string]any{"allow": []string{"allowed.>"}, "deny": []string{"denied.>"}},
"subscribe": map[string]any{"allow": []string{"allowed.>"}, "deny": []string{"denied.>"}},
},
})
}))
defer ts.Close()

conf := createConfFile(t, []byte(fmt.Sprintf(`
listen: 127.0.0.1:-1
authorization {
auth_http {
url: "%s"
timeout: 5
}
}
`, ts.URL)))
s, _ := RunServerWithConfig(conf)
defer s.Shutdown()

errChan := make(chan error, 1)
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo("alice", "secret"),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
if err != nil {
select {
case errChan <- err:
default:
}
}
}))
if err != nil {
t.Fatalf("Expected to connect, got %v", err)
}
defer nc.Close()

// Publish to allowed subject should succeed
if err := nc.Publish("allowed.foo", []byte("ok")); err != nil {
t.Fatalf("Expected publish to allowed.foo to succeed, got %v", err)
}
nc.Flush()

// Publish to denied subject should fail with permission violation (error arrives async)
if err := nc.Publish("denied.foo", []byte("nope")); err != nil {
t.Fatalf("Publish buffers, should not return error: %v", err)
}
nc.Flush()
select {
case err := <-errChan:
if !strings.Contains(err.Error(), "Permissions Violation") {
t.Fatalf("Expected permission violation error, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Expected permission violation error for publish to denied.foo")
}

// Subscribe to allowed subject should succeed
sub, err := nc.SubscribeSync("allowed.bar")
if err != nil {
t.Fatalf("Expected subscribe to allowed.bar to succeed, got %v", err)
}
sub.Unsubscribe()

// Subscribe to denied subject should fail
_, err = nc.SubscribeSync("denied.bar")
if err != nil {
t.Fatalf("SubscribeSync may not return error immediately: %v", err)
}
nc.Flush()
if err := nc.LastError(); err == nil || !strings.Contains(err.Error(), "Permissions Violation") {
t.Fatalf("Expected permission violation for subscribe to denied.bar, got %v", err)
}
}
Loading