BearerMiddleware now emits a structured slog line on every rejection with the reason (no_token / static_token_mismatch / jwt_invalid / jwt_dex_unavailable), client IP (X-Forwarded-For aware), presented token type (jwt/opaque), and a truncated SHA-256 fingerprint — never the raw token, so no secret material reaches stdout/log aggregation. Validate now tags a JWKS/Dex fetch failure with the exported ErrUnavailable sentinel (distinct from a present-but-invalid token). BearerMiddleware maps it to HTTP 503 with `WWW-Authenticate: Bearer error="temporarily_unavailable"`, so a transient Dex outage is distinguishable from a bad token instead of a silent generic 401. No signature change — logging goes through slog.Default(); existing consumers are unaffected until they set a default logger. Behavior reaches a consumer only when it bumps the chassis version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
151 lines
5.3 KiB
Go
151 lines
5.3 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 {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// 2. Then Dex JWT, if configured.
|
|
if validator != nil {
|
|
switch _, err := validator.Validate(r.Context(), rawToken); {
|
|
case err == nil:
|
|
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. Static token was wrong and no JWT validator is configured.
|
|
deny(w, r, realm, resourceMetadataURL, http.StatusUnauthorized, "static_token_mismatch", rawToken)
|
|
})
|
|
}
|
|
|
|
// 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 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]
|
|
}
|