mathiasandClaude Opus 4.8 75eb8b831c feat(auth): multi-issuer JWT validation — accept k8s SA tokens (infra ADR-0011)
JWTValidator trusted a single issuer (iss matched exactly against DEX_ISSUER_URL),
which is why every in-cluster caller fell back to a static bearer. Add support
for a LIST of trusted issuers so one MCP server can accept Authentik JWTs
(interactive/web) AND k3s cluster ServiceAccount tokens (in-cluster, audience-
bound, kubelet-rotated) at once — the enabler for ADR-0011.

- Add IssuerConfig{IssuerURL, Audience} + NewMultiJWTValidator([]IssuerConfig).
- Refactor JWTValidator to hold []issuerEntry + a shared jwk.Cache; Validate
  tries each trusted issuer, returns the subject on first success. All-issuers-
  JWKS-unreachable -> ErrUnavailable (503); any definitive reject -> 401.
- NewJWTValidator (single issuer) and BearerMiddleware signatures UNCHANGED —
  existing consumers (gitea-mcp, ingestion) compile and behave identically.
- Add auth/jwt_test.go: an in-process OIDC issuer harness (discovery + JWKS +
  token minting) — the chassis previously had NO happy-path JWT test. Covers
  accept-either-trusted-issuer, reject-untrusted (401 not 503), per-issuer
  audience enforcement, single-issuer backward compat.

Proven viable by the ADR-0011 live-cluster spike (k3s OIDC/JWKS validates a
projected SA token). Follow-up: wire gitea-mcp/brain-mcp/ingestion to also trust
the k3s issuer + mount an audience-scoped projected SA token on one pod.

Refs infra ADR-0011; supersedes the sidecar in infra#183. (Tracked as
hyperguild#77, which was misfiled — the chassis is this repo, not hyperguild.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:14:56 +02:00

mcp-chassis

Shared Go library for Mathias-owned MCP servers. Provides the auth + middleware primitives that every MCP server needs.

Why

By 2026-05-22 there were three+ MCP servers (gitea-mcp, brain-mcp / ingestion, future ones from template-go-agent) each carrying their own near-identical:

  • Dex JWT validator (~80 LOC, identical jwx/v2 plumbing)
  • Bearer middleware (~50 LOC, dual-mode static + JWT)
  • RFC 9728 protected-resource metadata handler (~25 LOC)

The homelab architecture review's spike S3 (see gitea.d-ma.be/mathias/infra/docs/superpowers/handoffs/2026-05-22-mcp-chassis-spike.md) concluded a thin shared lib pays for itself within the first migration. This is that lib.

Non-goals

  • Replacing each MCP's tool registration / handler logic — that is per-domain.
  • Solving HTTP routing — consumers keep their own http.ServeMux.
  • Solving observability — see gitea.d-ma.be/mathias/hyperguild/ingestion/internal/metrics for the hand-rolled Prometheus pattern. May absorb a metrics subpackage here later, once a second consumer needs it.

Packages

auth

  • JWTValidator — Dex OIDC JWT validation. nil is a valid value meaning "JWT auth disabled".
  • BearerMiddleware — static-Bearer-or-Dex-JWT gate. Static wins first; only emits WWW-Authenticate: Bearer ... resource_metadata=... on 401 when resourceMetadataURL is non-empty (claude.ai OAuth discovery).
  • ProtectedResourceHandler — RFC 9728 metadata document for GET /.well-known/oauth-protected-resource.

Usage

package main

import (
	"context"
	"net/http"
	"os"

	"git.d-ma.be/mathias/mcp-chassis/auth"
)

func main() {
	staticToken := os.Getenv("BRAIN_MCP_TOKEN")
	dexIssuer := os.Getenv("DEX_ISSUER_URL")
	audience := os.Getenv("MCP_AUDIENCE")
	resourceURL := os.Getenv("MCP_RESOURCE_URL")

	validator, err := auth.NewJWTValidator(context.Background(), dexIssuer, audience)
	if err != nil {
		panic(err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /.well-known/oauth-protected-resource",
		auth.ProtectedResourceHandler(resourceURL, dexIssuer))
	mux.Handle("/mcp", auth.BearerMiddleware(
		staticToken,
		validator,
		"brain",
		resourceURL+"/.well-known/oauth-protected-resource",
		mcpHandler(),
	))

	_ = http.ListenAndServe(":3300", mux)
}

func mcpHandler() http.Handler { /* per-domain */ return nil }

Versioning

Trunk-based development on main. Tagged with semver. Consumers pin specific tags (go.mod require git.d-ma.be/mathias/mcp-chassis v0.x.y) and bump deliberately.

Migrations are documented per-consumer in the consumer's CHANGELOG / commits.

Dependencies

  • github.com/lestrrat-go/jwx/v2 — JWKS cache + JWT parsing. Same dep every MCP already had; no new transitive cost when adopting the chassis.
  • github.com/stretchr/testify — tests only.

stdlib otherwise.

S
Description
Shared Go library for Mathias-owned MCP servers — Dex JWT validator + Bearer middleware + RFC 9728 protected-resource metadata. Hand-rolled (stdlib + lestrrat-go/jwx) per the homelab architecture review.
Readme
60 KiB
Languages
Go 100%