feat: initial mcp-chassis with auth primitives

Shared Go library for Mathias-owned MCP servers, born from spike S3 of
the 2026-05 homelab architecture review (see
gitea.d-ma.be/mathias/infra/docs/superpowers/handoffs/2026-05-22-mcp-chassis-spike.md
for the viability assessment and abort-criterion check).

Provides three primitives every MCP server today re-implements:

- auth.JWTValidator — Dex OIDC JWT validation. nil-safe (nil = "JWT
  disabled"), audience-optional. Lifted from the identical
  ~80-LOC implementations in gitea-mcp and hyperguild/ingestion.
- auth.BearerMiddleware — dual-mode static-Bearer-or-Dex-JWT gate.
  Static wins first to avoid emitting a WWW-Authenticate challenge
  that would flip claude.ai's MCP client into OAuth discovery for
  static-only deployments. The fall-through 401 emits the RFC 9728
  resource_metadata header only when explicitly configured.
- auth.ProtectedResourceHandler — RFC 9728
  /.well-known/oauth-protected-resource metadata document handler.

Test coverage exercises every branch (static OK, JWT-disabled,
empty bearer, wrong static, with-challenge vs without-challenge,
nil-validator-Validate). go test -race clean.

Deps: github.com/lestrrat-go/jwx/v2 (already a dep of every consumer)
and testify (test-only). No new transitive deps.

First migration target: gitea-mcp. If that port lands in one PR
(abort criterion from spec), brain-mcp (ingestion) follows. Otherwise
chassis reverts per the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathias
2026-05-22 09:07:53 +02:00
commit 67decddc8a
8 changed files with 530 additions and 0 deletions

77
auth/bearer.go Normal file
View File

@@ -0,0 +1,77 @@
package auth
import (
"crypto/subtle"
"net/http"
"strings"
)
// BearerMiddleware gates next behind dual-mode authentication. It is the
// canonical pattern across every Mathias-owned MCP server.
//
// 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 CLI callers that supply
// `Authorization: Bearer $XXX_MCP_TOKEN` via `.mcp.json`. Returning
// 401 without a WWW-Authenticate prevents the MCP client from
// speculatively flipping into OAuth-discovery mode and discarding
// the static token.
// 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 realm="<realm>", 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 the
// JWT path runs, because the WWW-Authenticate emitted on the fall-through
// 401 confuses static-Bearer-only clients into discarding their header
// and starting an OAuth handshake instead.
//
// staticToken may be empty (static auth disabled — only JWT accepted).
// validator may be nil (JWT auth disabled — only static accepted).
// realm is a free-text identifier used in the WWW-Authenticate challenge;
// MCP servers conventionally use their service name (e.g. "brain", "gitea").
func BearerMiddleware(
staticToken string,
validator *JWTValidator,
realm string,
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 || rawToken == "" {
unauthorized(w, realm, 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, realm, resourceMetadataURL)
})
}
func unauthorized(w http.ResponseWriter, realm, resourceMetadataURL string) {
if resourceMetadataURL != "" {
w.Header().Set("WWW-Authenticate",
`Bearer realm="`+realm+`", resource_metadata="`+resourceMetadataURL+`"`)
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
}

114
auth/bearer_test.go Normal file
View File

@@ -0,0 +1,114 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestBearerMiddleware_StaticTokenWins(t *testing.T) {
t.Parallel()
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
})
h := BearerMiddleware("supersecret", nil, "brain", "", next)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer supersecret")
h.ServeHTTP(rec, req)
require.True(t, called, "next must be called on valid static token")
require.Equal(t, http.StatusNoContent, rec.Code)
}
func TestBearerMiddleware_NoHeader_401NoChallengeWhenMetadataEmpty(t *testing.T) {
t.Parallel()
h := BearerMiddleware("any", nil, "brain", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusUnauthorized, rec.Code)
require.Empty(t, rec.Header().Get("WWW-Authenticate"))
}
func TestBearerMiddleware_NoHeader_EmitsChallengeWhenMetadataSet(t *testing.T) {
t.Parallel()
h := BearerMiddleware("any", nil, "brain",
"https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource",
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusUnauthorized, rec.Code)
require.Equal(t,
`Bearer realm="brain", resource_metadata="https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource"`,
rec.Header().Get("WWW-Authenticate"),
)
}
func TestBearerMiddleware_WrongStaticToken_401(t *testing.T) {
t.Parallel()
h := BearerMiddleware("expected", nil, "brain", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("next must NOT be called on wrong token")
}))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer wrong")
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestBearerMiddleware_EmptyBearer_401(t *testing.T) {
t.Parallel()
h := BearerMiddleware("expected", nil, "brain", "",
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("next must NOT be called on empty bearer")
}))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer ")
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestBearerMiddleware_StaticOnly_NilValidator_OK(t *testing.T) {
t.Parallel()
// Verifies that JWT-disabled deployments (validator == nil) work end-to-end.
called := false
h := BearerMiddleware("tok", nil, "brain", "",
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer tok")
h.ServeHTTP(rec, req)
require.True(t, called)
}
func TestJWTValidator_NilReturnsError(t *testing.T) {
t.Parallel()
var v *JWTValidator
subj, err := v.Validate(t.Context(), "anything")
require.Empty(t, subj)
require.Error(t, err)
}

120
auth/jwt.go Normal file
View File

@@ -0,0 +1,120 @@
// Package auth provides the Dex-JWT + static-Bearer authentication primitives
// shared by every Mathias-owned MCP server (gitea-mcp, brain-mcp / ingestion,
// future MCPs spawned from template-go-agent).
//
// Replaces ~80 LOC of near-identical jwt.go in each consumer, ~50 LOC of
// Bearer middleware, and ~25 LOC of RFC 9728 protected-resource metadata
// handler. See `gitea.d-ma.be/mathias/infra` docs/superpowers/handoffs/
// 2026-05-22-mcp-chassis-spike.md for the design rationale and the
// abort-criterion check.
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// JWTValidator validates Bearer JWTs issued by a Dex (OIDC) authorization server.
// Audience is optional; leave empty to skip audience validation.
//
// A nil *JWTValidator behaves as "JWT auth disabled" — Validate returns an
// error without panicking. Callers can construct one validator at startup
// keyed on whether DEX_ISSUER_URL is set, and pass nil through the rest of
// the codebase without further branching.
type JWTValidator struct {
issuer string
audience string
jwksURI string
cache *jwk.Cache
}
// NewJWTValidator fetches the OIDC discovery document from issuerURL,
// extracts jwks_uri, warms the JWKS cache, and returns a ready validator.
// Empty issuerURL returns (nil, nil) so callers can use a single
// constructor regardless of whether Dex is configured.
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
if issuerURL == "" {
return nil, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
issuerURL+"/.well-known/openid-configuration", nil)
if err != nil {
return nil, fmt.Errorf("build oidc discovery request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
}
defer func() { _ = resp.Body.Close() }()
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")
}
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 &JWTValidator{
issuer: issuerURL,
audience: audience,
jwksURI: doc.JWKSURI,
cache: cache,
}, nil
}
// Validate parses and validates rawToken against the OIDC issuer. Returns
// the subject claim on success. A nil receiver returns
// (`""`, errDisabled) so callers can dispatch on `err != nil` without
// nil-checks at every call site.
func (v *JWTValidator) Validate(ctx context.Context, rawToken string) (string, error) {
if v == nil {
return "", errDisabled
}
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
}
// errDisabled is the sentinel returned by Validate on a nil receiver.
// Not exported because callers care about "not authorized" not "why";
// this lets consumers treat any err the same way without depending on
// the specific value.
var errDisabled = fmt.Errorf("jwt auth disabled")

View File

@@ -0,0 +1,29 @@
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).
//
// claude.ai's OAuth discovery flow fetches this endpoint when an MCP
// server's WWW-Authenticate challenge points at it via the
// `resource_metadata` parameter; the document points back at the Dex
// authorization server, completing the discovery loop.
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)
}
}

View File

@@ -0,0 +1,33 @@
package auth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestProtectedResourceHandler(t *testing.T) {
t.Parallel()
h := ProtectedResourceHandler(
"https://brain-mcp.d-ma.be",
"https://auth.d-ma.be",
)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
var got struct {
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, "https://brain-mcp.d-ma.be", got.Resource)
require.Equal(t, []string{"https://auth.d-ma.be"}, got.AuthorizationServers)
}