fix(brain-mcp): static Bearer short-circuits before OAuth challenge
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s

Reorders BearerAuth so a valid BRAIN_MCP_TOKEN match wins instantly and
never emits WWW-Authenticate. Adds RFC 9728 resource_metadata challenge
header on 401 (only when MCP_RESOURCE_URL is configured) so claude.ai's
OAuth-discovery path still works.

Why: claude CLI on koala/flamingo with `.mcp.json` `Authorization: Bearer
$BRAIN_MCP_TOKEN` was being kicked into RFC 7591 dynamic client
registration against Dex (static-only) and dying. Cause was the auth
middleware running JWT validation first and emitting an OAuth challenge
on the fall-through 401 even when the caller had a valid static token.
Inverting the precedence and gating the challenge on resourceMetadataURL
keeps the LAN/Tailscale CLI path silent and only invites OAuth discovery
on actually-unauthenticated requests.

Regression guards in the test file:
- valid static Bearer 200 has no WWW-Authenticate
- 401 with resourceMetadataURL set carries the challenge
- 401 with empty resourceMetadataURL emits no challenge

Closes hyperguild#9 in code. Live verification (claude CLI on koala
listing brain tools) blocked on ingestion image rebuild + redeploy.
This commit is contained in:
Mathias
2026-05-18 21:00:05 +02:00
parent 75685e7b67
commit 61b6247df9
3 changed files with 101 additions and 23 deletions

View File

@@ -8,29 +8,58 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
)
// 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 {
// 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 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
unauthorized(w, resourceMetadataURL)
return
}
if v != nil {
if _, err := v.Validate(r.Context(), rawToken); err == nil {
// 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
}
}
if token != "" && subtle.ConstantTimeCompare([]byte(rawToken), []byte(token)) == 1 {
next.ServeHTTP(w, r)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
// 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)
}