CI / Lint / Test / Vet (push) Successful in 5s
Closes #1. Closes gitea-mcp#58. Closes gitea-mcp#60. Three small changes to one decision point, kept together because they are the same surface and the same incident. 1. A JWT IS NEVER A STATIC-TOKEN MISMATCH (gitea-mcp#58) The fall-through reason was chosen by which comparison ran last, not by what the caller presented. So a JWT against a server with no JWT auth configured logged: reason=static_token_mismatch token_type=jwt status=401 The two fields contradict each other, and the sentence reads as "you presented the wrong token" when the truth is "this server could not have accepted any token of that kind". The misdirection reached the user — claude.ai surfaces "Authorization with gitea failed. You can check your credentials and permissions." — and a debugging session went into re-running the OAuth flow while the credentials were valid throughout. Now `jwt_auth_not_configured` for a JWT; `static_token_mismatch` stays, and is asserted to stay, for an actual static-token mismatch. Fixing the misleading case must not delete the accurate one. Note this is now the ONLY way to reach that branch with a JWT: since v0.6.0 a validator that merely could not reach its issuer is non-nil and answers ErrUnavailable -> 503. A nil validator means the server was never given an issuer. 2. SUCCESS IS LOGGED, WITH THE PATH THAT GRANTED IT (gitea-mcp#60) The middleware logged only on rejection. After re-authing a connector there was no way to tell whether a request used the static token or a JWT, only inference from which failures were absent. A log that proves a request failed, and nothing about how the successful ones succeeded, cannot answer the question anyone actually has during an auth incident. INFO mcp auth accepted auth_path=jwt caller=alice token_fp=... remote=... INFO mcp auth accepted auth_path=static token_fp=... remote=... Same disclosure rules as deny: a fingerprint, never the credential. The JWT subject is included because distinguishing WHO is the point of that path; `caller` is omitted rather than empty for the static path, which has no identity to report. 3. JWKS REFRESH INTERVAL IS CONFIGURABLE (#1) It was hardcoded at 1h, so an issuer rotating its signing key could leave every consumer rejecting valid JWTs for up to an hour. WithRefreshInterval, defaulting to 1h so nothing changes for existing callers. Variadic, per #1's own preferred option — a new positional parameter would force a coordinated bump across every consumer. Deliberately NOT read from the environment inside the library: a shared lib calling os.Getenv is a hidden dependency that surprises its consumers. Config ownership stays at the edge. The effective value is logged when an issuer resolves, which is #1's second acceptance criterion and the thing an operator needs during a key-rotation incident. The test asserts the configured value reaches the registration site via that line — a silently-ignored option being the failure mode worth pinning. Whether jwx then honours it is jwx's to test. One of my own assertions was wrong and the test caught it: tokenType() says "opaque", not "static", for a non-JWT credential. Corrected to the existing vocabulary rather than changing the vocabulary to match a new test. Verified: golangci-lint 0 issues; go vet clean; go test -race -count=1 ok. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
6.8 KiB
Go
188 lines
6.8 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 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]
|
|
}
|