Reorders BearerAuth so a valid BRAIN_MCP_TOKEN match wins instantly and never emits WWW-Authenticate. Adds RFC 9728 resource_metadata challenge header on 401 (only when MCP_RESOURCE_URL is configured) so claude.ai's OAuth-discovery path still works. Why: claude CLI on koala/flamingo with `.mcp.json` `Authorization: Bearer $BRAIN_MCP_TOKEN` was being kicked into RFC 7591 dynamic client registration against Dex (static-only) and dying. Cause was the auth middleware running JWT validation first and emitting an OAuth challenge on the fall-through 401 even when the caller had a valid static token. Inverting the precedence and gating the challenge on resourceMetadataURL keeps the LAN/Tailscale CLI path silent and only invites OAuth discovery on actually-unauthenticated requests. Regression guards in the test file: - valid static Bearer 200 has no WWW-Authenticate - 401 with resourceMetadataURL set carries the challenge - 401 with empty resourceMetadataURL emits no challenge Closes hyperguild#9 in code. Live verification (claude CLI on koala listing brain tools) blocked on ingestion image rebuild + redeploy.
203 lines
6.9 KiB
Go
203 lines
6.9 KiB
Go
package mcp_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const testResourceMetadataURL = "https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource"
|
|
|
|
func okHandler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
}
|
|
|
|
func TestBearerAuth_MissingHeader(t *testing.T) {
|
|
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
}
|
|
|
|
func TestBearerAuth_WrongToken(t *testing.T) {
|
|
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
req.Header.Set("Authorization", "Bearer wrong")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
}
|
|
|
|
func TestBearerAuth_CorrectToken(t *testing.T) {
|
|
called := false
|
|
handler := mcp.BearerAuth("secret", nil, "", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
req.Header.Set("Authorization", "Bearer secret")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
assert.True(t, called)
|
|
}
|
|
|
|
func TestBearerAuth_EmptyConfiguredToken(t *testing.T) {
|
|
handler := mcp.BearerAuth("", nil, "", okHandler())
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
}
|
|
|
|
// Issue #9: a valid static Bearer must never emit a WWW-Authenticate header,
|
|
// even when a resource-metadata URL is configured. The presence of that
|
|
// header on a 200 response would flip MCP CLI clients into OAuth-discovery
|
|
// mode and break static-Bearer auth from `.mcp.json` on Tailscale/LAN.
|
|
func TestBearerAuth_ValidStaticBearer_NoWWWAuthenticate(t *testing.T) {
|
|
handler := mcp.BearerAuth("secret", nil, testResourceMetadataURL, okHandler())
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
req.Header.Set("Authorization", "Bearer secret")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
assert.Empty(t, rr.Header().Get("WWW-Authenticate"), "static-Bearer 200 must not advertise OAuth")
|
|
}
|
|
|
|
// Issue #9: a 401 with resource-metadata configured must emit a
|
|
// WWW-Authenticate header so claude.ai discovers the protected-resource
|
|
// metadata document and continues the OAuth dance.
|
|
func TestBearerAuth_Unauthorized_EmitsResourceMetadataChallenge(t *testing.T) {
|
|
handler := mcp.BearerAuth("secret", nil, testResourceMetadataURL, okHandler())
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
got := rr.Header().Get("WWW-Authenticate")
|
|
assert.Contains(t, got, `Bearer realm="brain"`)
|
|
assert.Contains(t, got, `resource_metadata="`+testResourceMetadataURL+`"`)
|
|
}
|
|
|
|
// Static-Bearer-only deployment: no resource-metadata URL, no challenge
|
|
// header on 401 — matches pre-#9 behaviour for tests without Dex wired.
|
|
func TestBearerAuth_Unauthorized_NoChallengeWhenResourceUnset(t *testing.T) {
|
|
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
assert.Empty(t, rr.Header().Get("WWW-Authenticate"))
|
|
}
|
|
|
|
// JWT auth tests
|
|
|
|
func buildOIDCServer(t *testing.T) (*httptest.Server, jwk.Key) {
|
|
t.Helper()
|
|
raw, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
priv, err := jwk.FromRaw(raw)
|
|
require.NoError(t, err)
|
|
require.NoError(t, priv.Set(jwk.KeyIDKey, "k1"))
|
|
require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256))
|
|
pub, err := jwk.PublicKeyOf(priv)
|
|
require.NoError(t, err)
|
|
|
|
set := jwk.NewSet()
|
|
require.NoError(t, set.AddKey(pub))
|
|
jwksBytes, err := json.Marshal(set)
|
|
require.NoError(t, err)
|
|
|
|
muxSrv := http.NewServeMux()
|
|
var srv *httptest.Server
|
|
muxSrv.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"issuer": srv.URL,
|
|
"jwks_uri": srv.URL + "/jwks",
|
|
})
|
|
})
|
|
muxSrv.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write(jwksBytes)
|
|
})
|
|
srv = httptest.NewServer(muxSrv)
|
|
t.Cleanup(srv.Close)
|
|
return srv, priv
|
|
}
|
|
|
|
func signJWT(t *testing.T, priv jwk.Key, issuer, audience string, exp time.Time) string {
|
|
t.Helper()
|
|
tok, err := jwt.NewBuilder().
|
|
Issuer(issuer).Audience([]string{audience}).
|
|
Subject("s").Expiration(exp).
|
|
Build()
|
|
require.NoError(t, err)
|
|
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, priv))
|
|
require.NoError(t, err)
|
|
return string(signed)
|
|
}
|
|
|
|
func TestBearerAuth_ValidJWT(t *testing.T) {
|
|
oidcSrv, priv := buildOIDCServer(t)
|
|
v, err := auth.NewValidator(oidcSrv.URL, "brain")
|
|
require.NoError(t, err)
|
|
|
|
called := false
|
|
handler := mcp.BearerAuth("static-secret", v, "", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
token := signJWT(t, priv, oidcSrv.URL, "brain", time.Now().Add(time.Hour))
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
assert.True(t, called)
|
|
}
|
|
|
|
func TestBearerAuth_InvalidJWT_FallsBackToStaticToken(t *testing.T) {
|
|
oidcSrv, _ := buildOIDCServer(t)
|
|
v, err := auth.NewValidator(oidcSrv.URL, "brain")
|
|
require.NoError(t, err)
|
|
|
|
handler := mcp.BearerAuth("static-secret", v, "", okHandler())
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
req.Header.Set("Authorization", "Bearer static-secret")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
}
|
|
|
|
func TestBearerAuth_InvalidJWT_WrongStaticToken(t *testing.T) {
|
|
oidcSrv, priv := buildOIDCServer(t)
|
|
v, err := auth.NewValidator(oidcSrv.URL, "brain")
|
|
require.NoError(t, err)
|
|
|
|
handler := mcp.BearerAuth("static-secret", v, "", okHandler())
|
|
// Expired JWT — JWT fails, static token doesn't match either
|
|
token := signJWT(t, priv, oidcSrv.URL, "brain", time.Now().Add(-time.Hour))
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
_ = context.Background() // satisfies import
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
}
|