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>
78 lines
2.8 KiB
Go
78 lines
2.8 KiB
Go
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)
|
|
}
|