Files
mcp-chassis/auth/jwt.go
Mathias 67decddc8a 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>
2026-05-22 09:07:53 +02:00

121 lines
3.8 KiB
Go

// 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")