From ca22df2d6a427b09d911f43f7663dff1b0ec8635 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 22 May 2026 10:43:11 +0200 Subject: [PATCH] feat(ingestion): migrate to gitea.d-ma.be/mathias/mcp-chassis v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second port of the MCP chassis (gitea-mcp was first, commit 658f4ba). Closes the chassis-adoption loop on the two highest-LOC consumers. Changes: - Drop ingestion/internal/auth/ entirely (jwt.go + jwt_test.go + protected_resource.go + protected_resource_test.go) — chassis provides JWTValidator + ProtectedResourceHandler with identical semantics. - Drop ingestion/internal/mcp/auth.go (BearerAuth function, ~65 LOC) and the integration test auth_test.go (~200 LOC) — chassis BearerMiddleware replaces it. Static-Bearer-or-Dex-JWT precedence and RFC 9728 resource_metadata challenge behavior preserved 1:1. - cmd/server/main.go: import chassis as `chassisauth`, rewire the three call sites. Use realm="brain" in the BearerMiddleware call so a 401 challenge identifies the resource as the brain MCP. OAuth client_credentials handler (ingestion/internal/oauth) stays — chassis v0.1.0 covers only the JWT path; OAuth flow is a candidate for chassis v0.2.0 once a second MCP needs it (rule of three). Net delta: -~330 LOC of duplicated auth code; +1 import; +1 GOPRIVATE env requirement on dev machines (documented in the spike handoff 2026-05-22-mcp-chassis-spike.md). task check green (lint + test + vet + govulncheck). Co-Authored-By: Claude Opus 4.7 (1M context) --- ingestion/cmd/server/main.go | 24 +-- ingestion/go.mod | 1 + ingestion/go.sum | 2 + ingestion/internal/auth/jwt.go | 84 -------- ingestion/internal/auth/jwt_test.go | 169 --------------- ingestion/internal/auth/protected_resource.go | 23 -- .../internal/auth/protected_resource_test.go | 28 --- ingestion/internal/mcp/auth.go | 65 ------ ingestion/internal/mcp/auth_test.go | 202 ------------------ 9 files changed, 14 insertions(+), 584 deletions(-) delete mode 100644 ingestion/internal/auth/jwt.go delete mode 100644 ingestion/internal/auth/jwt_test.go delete mode 100644 ingestion/internal/auth/protected_resource.go delete mode 100644 ingestion/internal/auth/protected_resource_test.go delete mode 100644 ingestion/internal/mcp/auth.go delete mode 100644 ingestion/internal/mcp/auth_test.go diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 1effd4d..226f598 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -12,8 +12,9 @@ import ( "strings" "time" + chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth" + "github.com/mathiasbq/hyperguild/ingestion/internal/api" - "github.com/mathiasbq/hyperguild/ingestion/internal/auth" "github.com/mathiasbq/hyperguild/ingestion/internal/llm" "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" "github.com/mathiasbq/hyperguild/ingestion/internal/embed" @@ -181,16 +182,13 @@ func main() { mux.HandleFunc("POST /backfill-refs", h.BackfillRefs) mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings) mux.HandleFunc("GET /pass-rate", h.PassRate) - var jwtValidator *auth.Validator - if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" { - audience := os.Getenv("MCP_AUDIENCE") - v, err := auth.NewValidator(dexURL, audience) - if err != nil { - logger.Error("build jwt validator", "err", err) - os.Exit(1) - } - jwtValidator = v - logger.Info("jwt auth enabled", "issuer", dexURL) + jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE")) + if err != nil { + logger.Error("build jwt validator", "err", err) + os.Exit(1) + } + if jwtValidator != nil { + logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL")) } // Resource-metadata URL is only emitted on 401 when Dex OAuth is @@ -200,13 +198,13 @@ func main() { if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" { resourceURL := os.Getenv("MCP_RESOURCE_URL") mux.HandleFunc("GET /.well-known/oauth-protected-resource", - auth.ProtectedResourceHandler(resourceURL, dexURL)) + chassisauth.ProtectedResourceHandler(resourceURL, dexURL)) if resourceURL != "" { resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource" } } - mux.Handle("/mcp", mcp.BearerAuth(mcpToken, jwtValidator, resourceMetadataURL, mcpSrv)) + mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)) // Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP // integration UI, which has no static-Bearer field. Setting both diff --git a/ingestion/go.mod b/ingestion/go.mod index faaf4ad..1b0b334 100644 --- a/ingestion/go.mod +++ b/ingestion/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/goccy/go-json v0.10.3 // indirect diff --git a/ingestion/go.sum b/ingestion/go.sum index 2ff65f3..4d4658b 100644 --- a/ingestion/go.sum +++ b/ingestion/go.sum @@ -1,3 +1,5 @@ +gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY= +gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/ingestion/internal/auth/jwt.go b/ingestion/internal/auth/jwt.go deleted file mode 100644 index 36af6ed..0000000 --- a/ingestion/internal/auth/jwt.go +++ /dev/null @@ -1,84 +0,0 @@ -package auth - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" -) - -// Validator validates Bearer JWTs issued by a Dex (OIDC) authorization server. -// Audience is optional; leave empty to skip audience validation. -type Validator struct { - issuer string - audience string - jwksURI string - cache *jwk.Cache -} - -// NewValidator fetches the OIDC discovery document from issuerURL, extracts -// jwks_uri, seeds the JWKS cache, and returns a ready Validator. -// If DEX_ISSUER_URL is not set the caller should pass "" and skip construction. -func NewValidator(issuerURL, audience string) (*Validator, error) { - resp, err := http.Get(issuerURL + "/.well-known/openid-configuration") //nolint:noctx - if err != nil { - return nil, fmt.Errorf("fetch oidc discovery: %w", err) - } - defer resp.Body.Close() //nolint:errcheck - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("oidc discovery: status %d", resp.StatusCode) - } - - var doc struct { - JWKSURI string `json:"jwks_uri"` - } - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { - return nil, fmt.Errorf("decode oidc discovery: %w", err) - } - if doc.JWKSURI == "" { - return nil, fmt.Errorf("oidc discovery: empty jwks_uri") - } - - ctx := context.Background() - cache := jwk.NewCache(ctx) - if err := cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil { - return nil, fmt.Errorf("register jwks cache: %w", err) - } - if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil { - return nil, fmt.Errorf("initial jwks fetch: %w", err) - } - - return &Validator{ - issuer: issuerURL, - audience: audience, - jwksURI: doc.JWKSURI, - cache: cache, - }, nil -} - -// Validate parses and validates rawToken. Returns the subject claim on success. -func (v *Validator) Validate(ctx context.Context, rawToken string) (string, error) { - keySet, err := v.cache.Get(ctx, v.jwksURI) - if err != nil { - return "", fmt.Errorf("get jwks: %w", err) - } - - opts := []jwt.ParseOption{ - jwt.WithKeySet(keySet), - jwt.WithValidate(true), - jwt.WithIssuer(v.issuer), - } - if v.audience != "" { - opts = append(opts, jwt.WithAudience(v.audience)) - } - - tok, err := jwt.ParseString(rawToken, opts...) - if err != nil { - return "", fmt.Errorf("validate jwt: %w", err) - } - return tok.Subject(), nil -} diff --git a/ingestion/internal/auth/jwt_test.go b/ingestion/internal/auth/jwt_test.go deleted file mode 100644 index 59e3d77..0000000 --- a/ingestion/internal/auth/jwt_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package auth_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/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type testKeys struct { - priv jwk.Key - pub jwk.Key -} - -func generateRSAKeys(t *testing.T) testKeys { - 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, "test-kid")) - require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256)) - - pub, err := jwk.PublicKeyOf(priv) - require.NoError(t, err) - - return testKeys{priv: priv, pub: pub} -} - -func mockOIDCServer(t *testing.T, keys testKeys) *httptest.Server { - t.Helper() - set := jwk.NewSet() - require.NoError(t, set.AddKey(keys.pub)) - jwksBytes, err := json.Marshal(set) - require.NoError(t, err) - - mux := http.NewServeMux() - var srv *httptest.Server - mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "issuer": srv.URL, - "jwks_uri": srv.URL + "/jwks", - }) - }) - mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBytes) - }) - srv = httptest.NewServer(mux) - t.Cleanup(srv.Close) - return srv -} - -func signToken(t *testing.T, keys testKeys, issuer, audience, subject string, exp time.Time) string { - t.Helper() - b := jwt.NewBuilder(). - Issuer(issuer). - Subject(subject). - Expiration(exp) - if audience != "" { - b = b.Audience([]string{audience}) - } - tok, err := b.Build() - require.NoError(t, err) - signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv)) - require.NoError(t, err) - return string(signed) -} - -func TestValidator(t *testing.T) { - keys := generateRSAKeys(t) - srv := mockOIDCServer(t, keys) - ctx := context.Background() - - v, err := auth.NewValidator(srv.URL, "brain") - require.NoError(t, err) - - tests := []struct { - name string - token string - wantSub string - wantErr bool - }{ - { - name: "valid jwt", - token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)), - wantSub: "test-user", - }, - { - name: "expired jwt", - token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(-time.Hour)), - wantErr: true, - }, - { - name: "wrong issuer", - token: signToken(t, keys, "https://evil.example.com", "brain", "test-user", time.Now().Add(time.Hour)), - wantErr: true, - }, - { - name: "wrong audience", - token: signToken(t, keys, srv.URL, "other-service", "test-user", time.Now().Add(time.Hour)), - wantErr: true, - }, - { - name: "tampered token", - token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)) + "tampered", - wantErr: true, - }, - { - name: "not a jwt", - token: "not-a-jwt", - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - sub, err := v.Validate(ctx, tc.token) - if tc.wantErr { - assert.Error(t, err) - assert.Empty(t, sub) - } else { - require.NoError(t, err) - assert.Equal(t, tc.wantSub, sub) - } - }) - } -} - -func TestNewValidator_NoAudience(t *testing.T) { - keys := generateRSAKeys(t) - srv := mockOIDCServer(t, keys) - ctx := context.Background() - - v, err := auth.NewValidator(srv.URL, "") - require.NoError(t, err) - - // Token without audience passes when audience validation is disabled. - tok, err := jwt.NewBuilder(). - Issuer(srv.URL). - Subject("sub"). - Expiration(time.Now().Add(time.Hour)). - Build() - require.NoError(t, err) - signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv)) - require.NoError(t, err) - - sub, err := v.Validate(ctx, string(signed)) - require.NoError(t, err) - assert.Equal(t, "sub", sub) -} - -func TestNewValidator_BadDiscoveryURL(t *testing.T) { - _, err := auth.NewValidator("http://127.0.0.1:1", "brain") - assert.Error(t, err) -} diff --git a/ingestion/internal/auth/protected_resource.go b/ingestion/internal/auth/protected_resource.go deleted file mode 100644 index fb86e23..0000000 --- a/ingestion/internal/auth/protected_resource.go +++ /dev/null @@ -1,23 +0,0 @@ -package auth - -import ( - "encoding/json" - "net/http" -) - -// ProtectedResourceHandler returns an RFC 9728 oauth-protected-resource metadata -// handler. Mount at GET /.well-known/oauth-protected-resource (no auth required). -func ProtectedResourceHandler(resourceURL, issuerURL string) http.HandlerFunc { - type metadata struct { - Resource string `json:"resource"` - AuthorizationServers []string `json:"authorization_servers"` - } - body, _ := json.Marshal(metadata{ - Resource: resourceURL, - AuthorizationServers: []string{issuerURL}, - }) - return func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - } -} diff --git a/ingestion/internal/auth/protected_resource_test.go b/ingestion/internal/auth/protected_resource_test.go deleted file mode 100644 index ba54ae0..0000000 --- a/ingestion/internal/auth/protected_resource_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package auth_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/mathiasbq/hyperguild/ingestion/internal/auth" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestProtectedResourceHandler(t *testing.T) { - h := auth.ProtectedResourceHandler("https://brain-mcp.d-ma.be", "https://auth.d-ma.be") - req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil) - rr := httptest.NewRecorder() - h(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) - - var body map[string]any - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body)) - assert.Equal(t, "https://brain-mcp.d-ma.be", body["resource"]) - servers := body["authorization_servers"].([]any) - assert.Equal(t, "https://auth.d-ma.be", servers[0]) -} diff --git a/ingestion/internal/mcp/auth.go b/ingestion/internal/mcp/auth.go deleted file mode 100644 index d053ede..0000000 --- a/ingestion/internal/mcp/auth.go +++ /dev/null @@ -1,65 +0,0 @@ -package mcp - -import ( - "crypto/subtle" - "net/http" - "strings" - - "github.com/mathiasbq/hyperguild/ingestion/internal/auth" -) - -// BearerAuth gates an HTTP handler behind dual-mode authentication. -// -// Auth precedence: -// -// 1. Static Bearer match (constant-time compare against staticToken). -// Wins immediately and never emits a WWW-Authenticate header. This is -// the path used by internal Tailscale/LAN CLI callers that supply -// `Authorization: Bearer $BRAIN_MCP_TOKEN` via `.mcp.json`. Returning -// 200 without a WWW-Authenticate prevents the MCP client from -// speculatively flipping into OAuth-discovery mode. -// 2. Dex JWT validation (when validator is non-nil). Used by claude.ai -// custom MCP connectors that finished the OAuth handshake. -// 3. Otherwise 401. When resourceMetadataURL is non-empty, a -// `WWW-Authenticate: Bearer resource_metadata="…"` header is emitted -// per RFC 9728 §6.2 so claude.ai's OAuth discovery flow can find the -// server's protected-resource metadata document. -// -// The order matters: a valid static Bearer must short-circuit BEFORE any -// JWT path runs, because a non-empty WWW-Authenticate emitted on the -// fall-through 401 confuses static-Bearer-only clients into discarding -// their header and starting an OAuth handshake instead. -func BearerAuth(staticToken string, validator *auth.Validator, resourceMetadataURL string, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rawToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") - if !ok { - unauthorized(w, resourceMetadataURL) - return - } - - // 1. Static Bearer wins first — never emits a challenge. - if staticToken != "" && subtle.ConstantTimeCompare([]byte(rawToken), []byte(staticToken)) == 1 { - next.ServeHTTP(w, r) - return - } - - // 2. Then Dex JWT, if configured. - if validator != nil { - if _, err := validator.Validate(r.Context(), rawToken); err == nil { - next.ServeHTTP(w, r) - return - } - } - - // 3. Reject with an OAuth resource-metadata challenge if configured. - unauthorized(w, resourceMetadataURL) - }) -} - -func unauthorized(w http.ResponseWriter, resourceMetadataURL string) { - if resourceMetadataURL != "" { - w.Header().Set("WWW-Authenticate", - `Bearer realm="brain", resource_metadata="`+resourceMetadataURL+`"`) - } - http.Error(w, "unauthorized", http.StatusUnauthorized) -} diff --git a/ingestion/internal/mcp/auth_test.go b/ingestion/internal/mcp/auth_test.go deleted file mode 100644 index fa6e548..0000000 --- a/ingestion/internal/mcp/auth_test.go +++ /dev/null @@ -1,202 +0,0 @@ -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) -}