feat(ingestion): migrate to gitea.d-ma.be/mathias/mcp-chassis v0.1.0
Second port of the MCP chassis (gitea-mcp was first, commit 658f4ba). Closes the chassis-adoption loop on the two highest-LOC consumers. Changes: - Drop ingestion/internal/auth/ entirely (jwt.go + jwt_test.go + protected_resource.go + protected_resource_test.go) — chassis provides JWTValidator + ProtectedResourceHandler with identical semantics. - Drop ingestion/internal/mcp/auth.go (BearerAuth function, ~65 LOC) and the integration test auth_test.go (~200 LOC) — chassis BearerMiddleware replaces it. Static-Bearer-or-Dex-JWT precedence and RFC 9728 resource_metadata challenge behavior preserved 1:1. - cmd/server/main.go: import chassis as `chassisauth`, rewire the three call sites. Use realm="brain" in the BearerMiddleware call so a 401 challenge identifies the resource as the brain MCP. OAuth client_credentials handler (ingestion/internal/oauth) stays — chassis v0.1.0 covers only the JWT path; OAuth flow is a candidate for chassis v0.2.0 once a second MCP needs it (rule of three). Net delta: -~330 LOC of duplicated auth code; +1 import; +1 GOPRIVATE env requirement on dev machines (documented in the spike handoff 2026-05-22-mcp-chassis-spike.md). task check green (lint + test + vet + govulncheck). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,84 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
)
|
||||
|
||||
// Validator validates Bearer JWTs issued by a Dex (OIDC) authorization server.
|
||||
// Audience is optional; leave empty to skip audience validation.
|
||||
type Validator struct {
|
||||
issuer string
|
||||
audience string
|
||||
jwksURI string
|
||||
cache *jwk.Cache
|
||||
}
|
||||
|
||||
// NewValidator fetches the OIDC discovery document from issuerURL, extracts
|
||||
// jwks_uri, seeds the JWKS cache, and returns a ready Validator.
|
||||
// If DEX_ISSUER_URL is not set the caller should pass "" and skip construction.
|
||||
func NewValidator(issuerURL, audience string) (*Validator, error) {
|
||||
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration") //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
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")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
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 &Validator{
|
||||
issuer: issuerURL,
|
||||
audience: audience,
|
||||
jwksURI: doc.JWKSURI,
|
||||
cache: cache,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate parses and validates rawToken. Returns the subject claim on success.
|
||||
func (v *Validator) Validate(ctx context.Context, rawToken string) (string, error) {
|
||||
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
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package auth_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/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testKeys struct {
|
||||
priv jwk.Key
|
||||
pub jwk.Key
|
||||
}
|
||||
|
||||
func generateRSAKeys(t *testing.T) testKeys {
|
||||
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, "test-kid"))
|
||||
require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256))
|
||||
|
||||
pub, err := jwk.PublicKeyOf(priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
return testKeys{priv: priv, pub: pub}
|
||||
}
|
||||
|
||||
func mockOIDCServer(t *testing.T, keys testKeys) *httptest.Server {
|
||||
t.Helper()
|
||||
set := jwk.NewSet()
|
||||
require.NoError(t, set.AddKey(keys.pub))
|
||||
jwksBytes, err := json.Marshal(set)
|
||||
require.NoError(t, err)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": srv.URL,
|
||||
"jwks_uri": srv.URL + "/jwks",
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBytes)
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func signToken(t *testing.T, keys testKeys, issuer, audience, subject string, exp time.Time) string {
|
||||
t.Helper()
|
||||
b := jwt.NewBuilder().
|
||||
Issuer(issuer).
|
||||
Subject(subject).
|
||||
Expiration(exp)
|
||||
if audience != "" {
|
||||
b = b.Audience([]string{audience})
|
||||
}
|
||||
tok, err := b.Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
||||
require.NoError(t, err)
|
||||
return string(signed)
|
||||
}
|
||||
|
||||
func TestValidator(t *testing.T) {
|
||||
keys := generateRSAKeys(t)
|
||||
srv := mockOIDCServer(t, keys)
|
||||
ctx := context.Background()
|
||||
|
||||
v, err := auth.NewValidator(srv.URL, "brain")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
wantSub string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid jwt",
|
||||
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)),
|
||||
wantSub: "test-user",
|
||||
},
|
||||
{
|
||||
name: "expired jwt",
|
||||
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(-time.Hour)),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong issuer",
|
||||
token: signToken(t, keys, "https://evil.example.com", "brain", "test-user", time.Now().Add(time.Hour)),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong audience",
|
||||
token: signToken(t, keys, srv.URL, "other-service", "test-user", time.Now().Add(time.Hour)),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "tampered token",
|
||||
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)) + "tampered",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not a jwt",
|
||||
token: "not-a-jwt",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sub, err := v.Validate(ctx, tc.token)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, sub)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantSub, sub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidator_NoAudience(t *testing.T) {
|
||||
keys := generateRSAKeys(t)
|
||||
srv := mockOIDCServer(t, keys)
|
||||
ctx := context.Background()
|
||||
|
||||
v, err := auth.NewValidator(srv.URL, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Token without audience passes when audience validation is disabled.
|
||||
tok, err := jwt.NewBuilder().
|
||||
Issuer(srv.URL).
|
||||
Subject("sub").
|
||||
Expiration(time.Now().Add(time.Hour)).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
||||
require.NoError(t, err)
|
||||
|
||||
sub, err := v.Validate(ctx, string(signed))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "sub", sub)
|
||||
}
|
||||
|
||||
func TestNewValidator_BadDiscoveryURL(t *testing.T) {
|
||||
_, err := auth.NewValidator("http://127.0.0.1:1", "brain")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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).
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProtectedResourceHandler(t *testing.T) {
|
||||
h := auth.ProtectedResourceHandler("https://brain-mcp.d-ma.be", "https://auth.d-ma.be")
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
||||
assert.Equal(t, "https://brain-mcp.d-ma.be", body["resource"])
|
||||
servers := body["authorization_servers"].([]any)
|
||||
assert.Equal(t, "https://auth.d-ma.be", servers[0])
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
||||
)
|
||||
|
||||
// BearerAuth gates an HTTP handler behind dual-mode authentication.
|
||||
//
|
||||
// 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 Tailscale/LAN CLI callers that supply
|
||||
// `Authorization: Bearer $BRAIN_MCP_TOKEN` via `.mcp.json`. Returning
|
||||
// 200 without a WWW-Authenticate prevents the MCP client from
|
||||
// speculatively flipping into OAuth-discovery mode.
|
||||
// 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 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 any
|
||||
// JWT path runs, because a non-empty WWW-Authenticate emitted on the
|
||||
// fall-through 401 confuses static-Bearer-only clients into discarding
|
||||
// their header and starting an OAuth handshake instead.
|
||||
func BearerAuth(staticToken string, validator *auth.Validator, 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 {
|
||||
unauthorized(w, 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, resourceMetadataURL)
|
||||
})
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, resourceMetadataURL string) {
|
||||
if resourceMetadataURL != "" {
|
||||
w.Header().Set("WWW-Authenticate",
|
||||
`Bearer realm="brain", resource_metadata="`+resourceMetadataURL+`"`)
|
||||
}
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
const testResourceMetadataURL = "https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource"
|
||||
|
||||
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)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestBearerAuth_WrongToken(t *testing.T) {
|
||||
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestBearerAuth_CorrectToken(t *testing.T) {
|
||||
called := false
|
||||
handler := mcp.BearerAuth("secret", nil, "", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestBearerAuth_EmptyConfiguredToken(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
// Issue #9: a valid static Bearer must never emit a WWW-Authenticate header,
|
||||
// even when a resource-metadata URL is configured. The presence of that
|
||||
// header on a 200 response would flip MCP CLI clients into OAuth-discovery
|
||||
// mode and break static-Bearer auth from `.mcp.json` on Tailscale/LAN.
|
||||
func TestBearerAuth_ValidStaticBearer_NoWWWAuthenticate(t *testing.T) {
|
||||
handler := mcp.BearerAuth("secret", nil, testResourceMetadataURL, okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Empty(t, rr.Header().Get("WWW-Authenticate"), "static-Bearer 200 must not advertise OAuth")
|
||||
}
|
||||
|
||||
// Issue #9: a 401 with resource-metadata configured must emit a
|
||||
// WWW-Authenticate header so claude.ai discovers the protected-resource
|
||||
// metadata document and continues the OAuth dance.
|
||||
func TestBearerAuth_Unauthorized_EmitsResourceMetadataChallenge(t *testing.T) {
|
||||
handler := mcp.BearerAuth("secret", nil, testResourceMetadataURL, okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
got := rr.Header().Get("WWW-Authenticate")
|
||||
assert.Contains(t, got, `Bearer realm="brain"`)
|
||||
assert.Contains(t, got, `resource_metadata="`+testResourceMetadataURL+`"`)
|
||||
}
|
||||
|
||||
// Static-Bearer-only deployment: no resource-metadata URL, no challenge
|
||||
// header on 401 — matches pre-#9 behaviour for tests without Dex wired.
|
||||
func TestBearerAuth_Unauthorized_NoChallengeWhenResourceUnset(t *testing.T) {
|
||||
handler := mcp.BearerAuth("secret", nil, "", okHandler())
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
assert.Empty(t, rr.Header().Get("WWW-Authenticate"))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user