feat(auth): audit-log rejections + 503 on Dex outage (gitea-mcp#9, #6)

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>
This commit is contained in:
2026-07-03 23:16:32 +02:00
co-authored by Claude Opus 4.8
parent 69d389d3f7
commit d5aa5b992c
3 changed files with 154 additions and 6 deletions
+78 -5
View File
@@ -1,7 +1,12 @@
package auth
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"log/slog"
"net"
"net/http"
"strings"
)
@@ -44,7 +49,7 @@ func BearerMiddleware(
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || rawToken == "" {
unauthorized(w, realm, resourceMetadataURL)
deny(w, r, realm, resourceMetadataURL, http.StatusUnauthorized, "no_token", rawToken)
return
}
@@ -57,21 +62,89 @@ func BearerMiddleware(
// 2. Then Dex JWT, if configured.
if validator != nil {
if _, err := validator.Validate(r.Context(), rawToken); err == 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. Reject with an OAuth resource-metadata challenge if configured.
unauthorized(w, realm, resourceMetadataURL)
// 3. Static token was wrong and no JWT validator is configured.
deny(w, r, realm, resourceMetadataURL, http.StatusUnauthorized, "static_token_mismatch", rawToken)
})
}
func unauthorized(w http.ResponseWriter, realm, resourceMetadataURL string) {
// 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]
}