feat(auth): add Dex JWT middleware to supervisor, routing pod, and brain MCP
All checks were successful
CI / Lint / Test / Vet (push) Successful in 13s
CI / Mirror to GitHub (push) Successful in 3s

Closes #6 on gitea.d-ma.be/mathias/hyperguild.

Dex is deployed at auth.d-ma.be. All three MCP servers now accept JWTs
issued by Dex in addition to static bearer tokens, enabling claude.ai
OAuth 2.0 integration without abandoning backward-compat CLI auth.

Changes:
- internal/auth/: new Validator (JWKS auto-refresh via lestrrat-go/jwx/v2),
  ProtectedResourceHandler (RFC 9728 /.well-known/oauth-protected-resource)
- internal/mcp/Server: adds optional *auth.Validator; checkAuth tries JWT
  first, then static token fallback; both-nil = auth disabled (unchanged default)
- cmd/supervisor, cmd/routing: construct Validator from DEX_ISSUER_URL +
  MCP_AUDIENCE env vars; register protected-resource handler when set
- ingestion/internal/auth/: same Validator + handler (separate module)
- ingestion/internal/mcp/BearerAuth: same JWT-or-static chain
- ingestion/cmd/server: same wiring pattern

New env vars (all optional; absent = static-token-only, same as before):
  DEX_ISSUER_URL   — Dex issuer URL (e.g. https://auth.d-ma.be)
  MCP_AUDIENCE     — expected aud claim (e.g. brain, supervisor)
  MCP_RESOURCE_URL — resource identifier for RFC 9728 metadata response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mathias Bergqvist
2026-05-11 20:10:05 +02:00
parent 1c3c9de550
commit c7e0192486
19 changed files with 934 additions and 53 deletions

View File

@@ -1,23 +1,36 @@
package mcp
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
)
// BearerAuth returns a middleware that enforces a static bearer token on every
// request. token must be non-empty; if it is empty, every request is rejected.
func BearerAuth(token string, next http.Handler) http.Handler {
// BearerAuth returns a middleware that enforces authentication on every request.
// It tries a valid Dex JWT first (when v is non-nil), then falls back to the
// static token. Rejects if token is empty and no valid JWT is presented.
func BearerAuth(token string, v *auth.Validator, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token == "" {
rawToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || got != token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
if v != nil {
if _, err := v.Validate(r.Context(), rawToken); err == nil {
next.ServeHTTP(w, r)
return
}
}
if token != "" && subtle.ConstantTimeCompare([]byte(rawToken), []byte(token)) == 1 {
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
http.Error(w, "unauthorized", http.StatusUnauthorized)
})
}

View File

@@ -1,18 +1,32 @@
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"
)
func TestBearerAuth_MissingHeader(t *testing.T) {
handler := mcp.BearerAuth("secret", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
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)
@@ -20,9 +34,7 @@ func TestBearerAuth_MissingHeader(t *testing.T) {
}
func TestBearerAuth_WrongToken(t *testing.T) {
handler := mcp.BearerAuth("secret", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
handler := mcp.BearerAuth("secret", nil, okHandler())
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("Authorization", "Bearer wrong")
rr := httptest.NewRecorder()
@@ -32,7 +44,7 @@ func TestBearerAuth_WrongToken(t *testing.T) {
func TestBearerAuth_CorrectToken(t *testing.T) {
called := false
handler := mcp.BearerAuth("secret", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := mcp.BearerAuth("secret", nil, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
@@ -45,12 +57,105 @@ func TestBearerAuth_CorrectToken(t *testing.T) {
}
func TestBearerAuth_EmptyConfiguredToken(t *testing.T) {
// Server started without a token configured — every request must fail.
handler := mcp.BearerAuth("", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
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)
}
// 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)
}