Files
mcp-chassis/auth/bearer.go
T
mathiasandClaude Opus 5 c0b8b26d03
CI / Lint / Test / Vet (push) Successful in 5s
feat(auth): export Fingerprint for servers with their own accepted path
gitea-mcp's PAT pass-through authenticates ahead of BearerMiddleware and emits
its own `mcp auth accepted` line (gitea-mcp#60). It needs the same fingerprint,
not an equivalent one: two different fingerprints for the same token across two
log lines cannot be correlated, which is the entire purpose of the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:58:32 +02:00

197 lines
7.2 KiB
Go

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="<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]
}