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)
}