package auth import ( "crypto/sha256" "crypto/subtle" "encoding/hex" "errors" "log/slog" "net" "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="", 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 == "" { deny(w, r, realm, resourceMetadataURL, http.StatusUnauthorized, "no_token", rawToken) return } // 1. Static Bearer wins first — never emits a challenge. if staticToken != "" && subtle.ConstantTimeCompare([]byte(rawToken), []byte(staticToken)) == 1 { accept(r, "static", "", rawToken) next.ServeHTTP(w, r) return } // 2. Then Dex JWT, if configured. if validator != nil { switch sub, err := validator.Validate(r.Context(), rawToken); { case err == nil: accept(r, "jwt", sub, rawToken) next.ServeHTTP(w, r) return case errors.Is(err, ErrUnavailable): // Dex/JWKS is down — the token might be fine, we just can't // check. Answer 503 so the caller retries rather than treating // it as a hard auth failure (gitea-mcp#6). deny(w, r, realm, resourceMetadataURL, http.StatusServiceUnavailable, "jwt_dex_unavailable", rawToken) return default: deny(w, r, realm, resourceMetadataURL, http.StatusUnauthorized, "jwt_invalid", rawToken) return } } // 3. Nothing matched. Which reason depends on what was PRESENTED, not on // which comparison happened to run last (gitea-mcp#58). // // `static_token_mismatch` for a JWT reads as "the caller presented the // wrong token" when the truth is "this server has no JWT auth // configured, so a correct token had no chance". The misdirection // reached the user — claude.ai surfaced "check your credentials" while // the credentials were valid — and a debugging session went into // re-running the OAuth flow before anyone read the log. reason := "static_token_mismatch" if tokenType(rawToken) == "jwt" { reason = "jwt_auth_not_configured" } deny(w, r, realm, resourceMetadataURL, http.StatusUnauthorized, reason, rawToken) }) } // accept audit-logs a SUCCESSFUL authentication, naming the path that granted it // (gitea-mcp#60). // // The middleware used to log only on rejection, so after re-authing a connector // there was no way to tell whether a request had used the static token or a JWT // — only inference from which failures were absent. A log that can prove a // request failed, and nothing about how the successful ones succeeded, cannot // answer the question anyone actually has during an auth incident. // // Same disclosure rules as deny: a fingerprint, never the credential. func accept(r *http.Request, authPath, caller, rawToken string) { attrs := []any{ "auth_path", authPath, "remote", clientIP(r), "token_fp", fingerprint(rawToken), "path", r.URL.Path, } if caller != "" { attrs = append(attrs, "caller", caller) } slog.Info("mcp auth accepted", attrs...) } // deny writes the auth rejection and emits a structured audit log line // (gitea-mcp#9). The log records the reason, client IP, presented token type, // and a non-reversible token fingerprint — never the raw token, so nothing // secret lands in stdout/log aggregation. slog adds the timestamp. func deny(w http.ResponseWriter, r *http.Request, realm, resourceMetadataURL string, status int, reason, rawToken string) { slog.Warn("mcp auth rejected", "reason", reason, "status", status, "remote", clientIP(r), "token_type", tokenType(rawToken), "token_fp", fingerprint(rawToken), "path", r.URL.Path, ) if status == http.StatusServiceUnavailable { // RFC 6750 §3.1: temporarily_unavailable signals a transient failure. w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`", error="temporarily_unavailable"`) http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) return } if resourceMetadataURL != "" { w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`", resource_metadata="`+resourceMetadataURL+`"`) } http.Error(w, "unauthorized", http.StatusUnauthorized) } // clientIP prefers the left-most X-Forwarded-For entry (the original client // behind the reverse proxy) and falls back to the transport peer. func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if i := strings.IndexByte(xff, ','); i > 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return host } return r.RemoteAddr } // tokenType classifies the presented credential without revealing it: a // three-segment dotted value is a JWT, anything else an opaque/static token. func tokenType(raw string) string { switch { case raw == "": return "none" case strings.Count(raw, ".") == 2: return "jwt" default: return "opaque" } } // Fingerprint is the exported form, for servers that authenticate on a path this // middleware does not own — gitea-mcp's PAT pass-through, which runs ahead of // BearerMiddleware and emits its own `mcp auth accepted` line. // // Exported rather than reimplemented per consumer: two different fingerprints // for the same token across two log lines cannot be correlated, which is the // entire purpose of the field. func Fingerprint(raw string) string { return fingerprint(raw) } // fingerprint is a short, non-reversible correlator (truncated SHA-256) so ops // can group repeated attempts from one client without ever logging token bytes. func fingerprint(raw string) string { if raw == "" { return "" } sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:])[:12] }