From 6eb1749eb39356b509ee7e7d3650db38b59f6d6d Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 18 Aug 2026 18:11:26 +0000 Subject: [PATCH 01/26] feat(go-validate): Go fast-path sidecar for the /validate hot path Adds a stdlib-only Go sidecar that fronts the auth-server's /validate auth_request endpoint. It verifies the configured-IdP RS256 bearer path (cached JWKS, atomic keyset, last-good retention), maps claims to identity, mints the HS256 X-Internal-Token-Registry, and writes the identity headers nginx consumes. Everything it does not recognize (cookies, other IdPs, opaque tokens, unknown kid, or an unconfigured fast path) is reverse-proxied to the unchanged Python auth-server, so it is byte-identical where it answers and zero-risk where it does not. Security invariants baked in: strips client-supplied identity/trust headers before minting; validates SECRET_KEY for missing/weak at startup (fail closed); enforces iss/aud from config, never from the token; recognized-invalid -> 401, unrecognized -> fallback (never a silent allow). Refs #1652 --- go-validate/.gitignore | 2 + go-validate/Dockerfile | 12 +++ go-validate/README.md | 58 +++++++++++ go-validate/config.go | 91 ++++++++++++++++++ go-validate/go.mod | 3 + go-validate/jwks.go | 120 +++++++++++++++++++++++ go-validate/jwt.go | 177 ++++++++++++++++++++++++++++++++++ go-validate/main.go | 211 +++++++++++++++++++++++++++++++++++++++++ go-validate/util.go | 8 ++ 9 files changed, 682 insertions(+) create mode 100644 go-validate/.gitignore create mode 100644 go-validate/Dockerfile create mode 100644 go-validate/README.md create mode 100644 go-validate/config.go create mode 100644 go-validate/go.mod create mode 100644 go-validate/jwks.go create mode 100644 go-validate/jwt.go create mode 100644 go-validate/main.go create mode 100644 go-validate/util.go diff --git a/go-validate/.gitignore b/go-validate/.gitignore new file mode 100644 index 000000000..4631408bd --- /dev/null +++ b/go-validate/.gitignore @@ -0,0 +1,2 @@ +# compiled binary +/go-validate diff --git a/go-validate/Dockerfile b/go-validate/Dockerfile new file mode 100644 index 000000000..ba32280fc --- /dev/null +++ b/go-validate/Dockerfile @@ -0,0 +1,12 @@ +# Build a static, dependency-free binary and ship it on a minimal base. +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/go-validate . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/go-validate /go-validate +EXPOSE 8899 +USER nonroot:nonroot +ENTRYPOINT ["/go-validate"] diff --git a/go-validate/README.md b/go-validate/README.md new file mode 100644 index 000000000..2d6148649 --- /dev/null +++ b/go-validate/README.md @@ -0,0 +1,58 @@ +# go-validate + +A Go **fast-path sidecar** for the auth-server's `GET /validate` endpoint. + +nginx fires `auth_request /validate` on every proxied request through the gateway. +The Python handler runs on a single uvicorn worker and is the throughput ceiling of +the authenticated data path. `go-validate` serves the steady-state RS256 bearer path +in Go and **reverse-proxies everything else to the unchanged Python auth-server**, so +it is byte-identical where it answers and zero-risk where it does not. + +Design, testing plan, and expert review: `.scratchpad/ant-hackathon-aug-2026/final/` +(lld.md, testing.md, review.md). Issue: agentic-community/mcp-gateway-registry#1652. + +## What it does + +- **Fast path:** verifies an RS256 JWT from the configured IdP against a cached JWKS, + maps claims to identity, mints the HS256 `X-Internal-Token-Registry`, and writes the + identity headers nginx consumes. +- **Fallback:** cookies, other IdPs, opaque tokens, unknown `kid`, or an unset fast + path are reverse-proxied to the Python auth-server. +- **Fail closed:** recognized-but-invalid token -> 401; unrecognized -> Python. + +## Configuration (environment) + +| Variable | Default | Notes | +|----------|---------|-------| +| `GOVALIDATE_LISTEN` | `:8899` | listen address | +| `SECRET_KEY` | (required for fast path) | shared HS256 key; validated for missing/weak at startup | +| `JWKS_URL` | (required for fast path) | configured IdP JWKS endpoint | +| `VALIDATE_ISSUER` | (required for fast path) | expected `iss` (config-driven, not from the token) | +| `VALIDATE_AUDIENCE` | (required for fast path) | expected `aud` (enforced against config) | +| `AUTH_FALLBACK_URL` | `http://auth-server:8888` | Python auth-server for fallback | +| `JWKS_REFRESH_SECONDS` | `300` | background JWKS refresh interval | + +If `SECRET_KEY` / `JWKS_URL` / `VALIDATE_ISSUER` / `VALIDATE_AUDIENCE` are not all set, +the sidecar runs in **fallback-only** mode (every request proxied to Python) - still +correct, just not accelerated. + +## Endpoints + +- `GET /validate` - the auth_request handler +- `GET /health` - readiness (degraded when JWKS is unhealthy) +- `GET /metrics` - plaintext counters (fastpath_ok / unauthorized / fallback) + +## Build & run + +```bash +go build -o go-validate . +SECRET_KEY=... JWKS_URL=... VALIDATE_ISSUER=... VALIDATE_AUDIENCE=... \ + AUTH_FALLBACK_URL=http://127.0.0.1:8888 ./go-validate +``` + +## Status + +Hackathon scope: RS256 fast path + fallback. Not yet ported (stays on Python via +fallback): federation/admin static tokens, session cookies, OBO exchange, per-tool +ACL, rate limiting, audit. See the LLD "Non-Goals" and review blockers before +production use. diff --git a/go-validate/config.go b/go-validate/config.go new file mode 100644 index 000000000..5b9247b3b --- /dev/null +++ b/go-validate/config.go @@ -0,0 +1,91 @@ +package main + +import ( + "log" + "os" + "strings" +) + +// Config holds the sidecar's runtime settings, all sourced from the environment. +// The fast path is enabled only when SecretKey, JWKSURL, Issuer and Audience are +// all present; otherwise the sidecar runs in fallback-only mode (every request is +// reverse-proxied to the Python auth-server). This fails closed: when we cannot +// safely verify a token ourselves, Python remains authoritative. +type Config struct { + Listen string + SecretKey string + JWKSURL string + Issuer string + Audience string + FallbackURL string + JWKSRefreshSec int + FastPathReady bool +} + +// knownWeakSecrets are literals that must never be accepted as a signing key. +var knownWeakSecrets = map[string]bool{ + "secret": true, + "changeme": true, + "change-me": true, + "password": true, + "your-secret-key": true, + "your_secret_key": true, + "test": true, + "dev": true, + "mcp-secret-key": true, + "default": true, +} + +// validateSecretKey enforces the signing-secret invariant: reject missing AND weak +// keys (weak-check before length). Returns an error string when invalid. +func validateSecretKey(key string) string { + stripped := strings.TrimSpace(key) + if stripped == "" { + return "SECRET_KEY is empty or whitespace" + } + if knownWeakSecrets[strings.ToLower(stripped)] { + return "SECRET_KEY is a known-weak literal" + } + if len(stripped) < 32 { + return "SECRET_KEY is too short (need >= 32 stripped chars)" + } + return "" +} + +// getenv returns the env value or a default. +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// loadConfig reads configuration from the environment and validates the signing key. +// It exits the process (fail closed) when SECRET_KEY is present but weak/invalid. +func loadConfig() Config { + cfg := Config{ + Listen: getenv("GOVALIDATE_LISTEN", ":8899"), + SecretKey: os.Getenv("SECRET_KEY"), + JWKSURL: os.Getenv("JWKS_URL"), + Issuer: os.Getenv("VALIDATE_ISSUER"), + Audience: os.Getenv("VALIDATE_AUDIENCE"), + FallbackURL: getenv("AUTH_FALLBACK_URL", "http://auth-server:8888"), + JWKSRefreshSec: atoiDefault(os.Getenv("JWKS_REFRESH_SECONDS"), 300), + } + + // B3: validate the signing secret. If a secret is provided at all it must be + // strong; a weak secret is a hard failure (never fall open to a bad key). + if cfg.SecretKey != "" { + if msg := validateSecretKey(cfg.SecretKey); msg != "" { + log.Fatalf("startup refused: %s", msg) + } + } + + // Fast path requires everything needed to verify AND mint safely. + cfg.FastPathReady = cfg.SecretKey != "" && + cfg.JWKSURL != "" && + cfg.Issuer != "" && + cfg.Audience != "" + + return cfg +} diff --git a/go-validate/go.mod b/go-validate/go.mod new file mode 100644 index 000000000..f236580f2 --- /dev/null +++ b/go-validate/go.mod @@ -0,0 +1,3 @@ +module go-validate + +go 1.24 diff --git a/go-validate/jwks.go b/go-validate/jwks.go new file mode 100644 index 000000000..8306d9230 --- /dev/null +++ b/go-validate/jwks.go @@ -0,0 +1,120 @@ +package main + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "log" + "math/big" + "net/http" + "sync/atomic" + "time" +) + +// jwk is one key in a JWKS document (RSA only). +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +type jwksDoc struct { + Keys []jwk `json:"keys"` +} + +// keysetCache holds kid -> *rsa.PublicKey behind an atomic.Pointer so request +// handlers read lock-free. On refresh failure it retains the last-good keyset (B4). +type keysetCache struct { + url string + keys atomic.Pointer[map[string]*rsa.PublicKey] + client *http.Client + healthy atomic.Bool +} + +// key returns the public key for a kid, or nil if absent. +func (k *keysetCache) key(kid string) *rsa.PublicKey { + m := k.keys.Load() + if m == nil { + return nil + } + return (*m)[kid] +} + +// parseKey converts a JWK to an *rsa.PublicKey. +func parseKey(j jwk) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(j.N) + if err != nil { + return nil, err + } + eBytes, err := base64.RawURLEncoding.DecodeString(j.E) + if err != nil { + return nil, err + } + e := 0 + for _, b := range eBytes { + e = e<<8 | int(b) + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}, nil +} + +// refresh fetches the JWKS once and swaps the keyset wholesale. On any error it +// keeps the previous keyset and marks the cache unhealthy (never clears keys). +func (k *keysetCache) refresh() error { + resp, err := k.client.Get(k.url) + if err != nil { + k.healthy.Store(false) + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + k.healthy.Store(false) + return errNotJWT + } + var doc jwksDoc + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + k.healthy.Store(false) + return err + } + m := make(map[string]*rsa.PublicKey, len(doc.Keys)) + for _, j := range doc.Keys { + if j.Kty != "RSA" { + continue + } + pub, err := parseKey(j) + if err != nil { + continue + } + m[j.Kid] = pub + } + if len(m) == 0 { + k.healthy.Store(false) + return errNotJWT + } + k.keys.Store(&m) + k.healthy.Store(true) + return nil +} + +// newKeysetCache loads the keyset once at startup and refreshes it in the +// background every refreshSec seconds. A startup failure is logged but not fatal: +// the sidecar still serves via fallback until the keyset loads. +func newKeysetCache(url string, refreshSec int) *keysetCache { + k := &keysetCache{ + url: url, + client: &http.Client{Timeout: 5 * time.Second}, + } + if err := k.refresh(); err != nil { + log.Printf("WARN initial JWKS load failed (serving via fallback until it loads): %v", err) + } + go func() { + ticker := time.NewTicker(time.Duration(refreshSec) * time.Second) + defer ticker.Stop() + for range ticker.C { + if err := k.refresh(); err != nil { + log.Printf("WARN JWKS refresh failed, keeping last-good keyset: %v", err) + } + } + }() + return k +} diff --git a/go-validate/jwt.go b/go-validate/jwt.go new file mode 100644 index 000000000..5f28da569 --- /dev/null +++ b/go-validate/jwt.go @@ -0,0 +1,177 @@ +package main + +import ( + "crypto" + "crypto/hmac" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strconv" + "strings" + "time" +) + +// Sentinel errors let the handler decide: fall back to Python (unrecognized) vs +// return 401 (recognized but invalid). This encodes the fail-closed boundary. +var ( + errNotJWT = errors.New("not a JWT") // -> fallback + errUnknownKey = errors.New("unknown kid/issuer") // -> fallback + errInvalidToken = errors.New("invalid token") // -> 401 + errWrongAlg = errors.New("unexpected alg") // -> fallback (could be HS/none from elsewhere) +) + +const clockLeewaySeconds = 30 + +// atoiDefault parses an int, returning def on failure/empty. +func atoiDefault(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +// b64urlDecode decodes a base64url segment (no padding). +func b64urlDecode(seg string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(seg) +} + +// b64urlEncode encodes to base64url (no padding). +func b64urlEncode(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +type jwtHeader struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + Typ string `json:"typ"` +} + +// Claims is the subset of RS256 claims the fast path reads. Groups and scope are +// decoded permissively because IdPs vary in shape. +type Claims struct { + Iss string `json:"iss"` + Aud json.RawMessage `json:"aud"` + Exp int64 `json:"exp"` + Sub string `json:"sub"` + Username string `json:"preferred_username"` + Azp string `json:"azp"` + ClientID string `json:"client_id"` + Scope string `json:"scope"` + Groups []string `json:"groups"` + raw map[string]any +} + +// audContains reports whether the token audience matches want (aud may be a string +// or an array of strings per RFC 7519). +func (c *Claims) audContains(want string) bool { + var single string + if err := json.Unmarshal(c.Aud, &single); err == nil { + return single == want + } + var many []string + if err := json.Unmarshal(c.Aud, &many); err == nil { + for _, a := range many { + if a == want { + return true + } + } + } + return false +} + +// verifyRS256 verifies an RS256 JWT against the cached keyset and enforces +// iss/aud/exp from config (never from the token). It returns the parsed claims on +// success, or a sentinel error telling the caller whether to fall back or 401. +func verifyRS256(token string, ks *keysetCache, issuer, audience string) (*Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, errNotJWT + } + headerBytes, err := b64urlDecode(parts[0]) + if err != nil { + return nil, errNotJWT + } + var h jwtHeader + if err := json.Unmarshal(headerBytes, &h); err != nil { + return nil, errNotJWT + } + if h.Alg != "RS256" { + return nil, errWrongAlg + } + pub := ks.key(h.Kid) + if pub == nil { + return nil, errUnknownKey + } + + // Verify signature over "header.payload". + signingInput := parts[0] + "." + parts[1] + sig, err := b64urlDecode(parts[2]) + if err != nil { + return nil, errInvalidToken + } + digest := sha256.Sum256([]byte(signingInput)) + if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig); err != nil { + return nil, errInvalidToken + } + + // Decode claims. + payloadBytes, err := b64urlDecode(parts[1]) + if err != nil { + return nil, errInvalidToken + } + var c Claims + if err := json.Unmarshal(payloadBytes, &c); err != nil { + return nil, errInvalidToken + } + _ = json.Unmarshal(payloadBytes, &c.raw) + + // Enforce iss/aud/exp (config-driven, fail closed). + if c.Iss != issuer { + return nil, errUnknownKey // different issuer -> let Python handle it + } + if !c.audContains(audience) { + return nil, errInvalidToken + } + now := time.Now().Unix() + if c.Exp != 0 && now > c.Exp+clockLeewaySeconds { + return nil, errInvalidToken + } + return &c, nil +} + +// mintInternalToken produces the HS256 X-Internal-Token-Registry that downstream +// services expect, signed with the shared SECRET_KEY. TTL is 30s to match Python. +func mintInternalToken(ident identity, audience, secret string) (string, error) { + header := map[string]string{"alg": "HS256", "typ": "JWT"} + now := time.Now().Unix() + claims := map[string]any{ + "iss": "mcp-auth-server", + "aud": audience, + "sub": ident.Sub, + "username": ident.Username, + "client_id": ident.ClientID, + "scopes": ident.Scopes, + "token_use": "internal", + "iat": now, + "exp": now + 30, + } + hb, err := json.Marshal(header) + if err != nil { + return "", err + } + cb, err := json.Marshal(claims) + if err != nil { + return "", err + } + signingInput := b64urlEncode(hb) + "." + b64urlEncode(cb) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + sig := b64urlEncode(mac.Sum(nil)) + return signingInput + "." + sig, nil +} diff --git a/go-validate/main.go b/go-validate/main.go new file mode 100644 index 000000000..5d501e3b2 --- /dev/null +++ b/go-validate/main.go @@ -0,0 +1,211 @@ +// Command go-validate is a fast-path sidecar for the auth-server's /validate +// endpoint. It verifies the RS256 bearer tokens that carry the bulk of gateway +// traffic and reverse-proxies everything else to the unchanged Python auth-server. +// Design: .scratchpad/ant-hackathon-aug-2026/final/lld.md +package main + +import ( + "log" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync/atomic" +) + +// identity is the resolved caller identity written into the response headers. +type identity struct { + Sub string + Username string + ClientID string + Scopes string + Groups string + Method string +} + +// trustHeaders are identity/trust headers a client must never be able to inject +// (B2). They are stripped from every inbound request before verification. +var trustHeaders = []string{ + "X-User", "X-Username", "X-Client-Id", "X-Scopes", + "X-Groups", "X-Auth-Method", "X-Internal-Token-Registry", +} + +// counters is a tiny lock-free metrics surface exposed at /metrics. +type counters struct { + fastOK atomic.Int64 + unauth atomic.Int64 + fallback atomic.Int64 +} + +type server struct { + cfg Config + ks *keysetCache + fallback http.Handler + stats counters +} + +// stripClientTrustHeaders removes any client-supplied identity headers (B2). +func stripClientTrustHeaders(r *http.Request) { + for _, h := range trustHeaders { + r.Header.Del(h) + } +} + +// extractBearer returns the bearer token, honoring X-Authorization over +// Authorization. The A2A rule: an /agent/... path must NOT fall back from +// X-Authorization to Authorization (that header carries the target agent's +// credential and is forwarded end-to-end). +func extractBearer(r *http.Request) (string, bool) { + isAgentPath := strings.Contains(r.Header.Get("X-Original-URL"), "/agent/") + if xa := r.Header.Get("X-Authorization"); xa != "" { + return parseBearer(xa) + } + if isAgentPath { + return "", false + } + if a := r.Header.Get("Authorization"); a != "" { + return parseBearer(a) + } + return "", false +} + +func parseBearer(v string) (string, bool) { + const p = "Bearer " + if len(v) > len(p) && strings.EqualFold(v[:len(p)], p) { + return strings.TrimSpace(v[len(p):]), true + } + return "", false +} + +// mapClaims turns verified claims into an identity. Keycloak claim shape today; +// additional IdPs add a case here (config + claim map, not a rewrite). +func mapClaims(c *Claims) identity { + clientID := c.Azp + if clientID == "" { + clientID = c.ClientID + } + username := c.Username + if username == "" { + username = c.Sub + } + return identity{ + Sub: c.Sub, + Username: username, + ClientID: clientID, + Scopes: c.Scope, + Groups: strings.Join(c.Groups, " "), + Method: "go-fastpath", + } +} + +// writeIdentityHeaders sets the headers nginx consumes via auth_request_set. +func writeIdentityHeaders(w http.ResponseWriter, ident identity, internal string) { + h := w.Header() + h.Set("X-User", ident.Username) + h.Set("X-Username", ident.Username) + h.Set("X-Client-Id", ident.ClientID) + h.Set("X-Scopes", ident.Scopes) + h.Set("X-Groups", ident.Groups) + h.Set("X-Auth-Method", ident.Method) + h.Set("X-Internal-Token-Registry", internal) +} + +// handleValidate is the hot path: strip trust headers, verify the RS256 bearer, +// and either answer 200 with identity headers, 401 for a recognized-invalid +// token, or fall back to Python for anything unrecognized. +func (s *server) handleValidate(w http.ResponseWriter, r *http.Request) { + stripClientTrustHeaders(r) // B2: never trust client-supplied identity headers + + if !s.cfg.FastPathReady { + s.stats.fallback.Add(1) + s.fallback.ServeHTTP(w, r) + return + } + + tok, ok := extractBearer(r) + if !ok { + s.stats.fallback.Add(1) + s.fallback.ServeHTTP(w, r) // cookie / no-bearer -> Python + return + } + + claims, err := verifyRS256(tok, s.ks, s.cfg.Issuer, s.cfg.Audience) + switch err { + case nil: + // verified below + case errInvalidToken: + s.stats.unauth.Add(1) + w.Header().Set("WWW-Authenticate", "Bearer") + w.WriteHeader(http.StatusUnauthorized) // recognized but invalid -> 401 (fail closed) + return + default: + // errNotJWT / errUnknownKey / errWrongAlg -> other IdP / opaque / unknown kid + s.stats.fallback.Add(1) + s.fallback.ServeHTTP(w, r) + return + } + + ident := mapClaims(claims) + internal, err := mintInternalToken(ident, s.cfg.Audience, s.cfg.SecretKey) + if err != nil { + // Minting should never fail; if it does, defer to Python rather than 500. + s.stats.fallback.Add(1) + s.fallback.ServeHTTP(w, r) + return + } + writeIdentityHeaders(w, ident, internal) + s.stats.fastOK.Add(1) + w.WriteHeader(http.StatusOK) +} + +// handleHealth reports readiness (B5). Degraded when the fast path is enabled but +// the JWKS keyset is not currently healthy. +func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) { + if s.cfg.FastPathReady && !s.ks.healthy.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("degraded: jwks unhealthy\n")) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) +} + +func (s *server) handleMetrics(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + _, _ = w.Write([]byte( + "govalidate_fastpath_ok " + itoa(s.stats.fastOK.Load()) + "\n" + + "govalidate_unauthorized " + itoa(s.stats.unauth.Load()) + "\n" + + "govalidate_fallback " + itoa(s.stats.fallback.Load()) + "\n")) +} + +func itoa(n int64) string { + return strconvFormat(n) +} + +func main() { + cfg := loadConfig() + + target, err := url.Parse(cfg.FallbackURL) + if err != nil { + log.Fatalf("invalid AUTH_FALLBACK_URL %q: %v", cfg.FallbackURL, err) + } + s := &server{ + cfg: cfg, + fallback: httputil.NewSingleHostReverseProxy(target), + } + if cfg.JWKSURL != "" { + s.ks = newKeysetCache(cfg.JWKSURL, cfg.JWKSRefreshSec) + } + + mux := http.NewServeMux() + mux.HandleFunc("/validate", s.handleValidate) + mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/metrics", s.handleMetrics) + + mode := "fast-path" + if !cfg.FastPathReady { + mode = "FALLBACK-ONLY (SECRET_KEY/JWKS_URL/VALIDATE_ISSUER/VALIDATE_AUDIENCE not all set)" + } + log.Printf("go-validate listening on %s | mode=%s | fallback=%s", cfg.Listen, mode, cfg.FallbackURL) + log.Fatal(http.ListenAndServe(cfg.Listen, mux)) +} diff --git a/go-validate/util.go b/go-validate/util.go new file mode 100644 index 000000000..bb491f3c9 --- /dev/null +++ b/go-validate/util.go @@ -0,0 +1,8 @@ +package main + +import "strconv" + +// strconvFormat formats an int64 as a decimal string. +func strconvFormat(n int64) string { + return strconv.FormatInt(n, 10) +} From 8873c449666253ddc6ad3f0bbc539df73ca98c9d Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 18 Aug 2026 18:23:16 +0000 Subject: [PATCH 02/26] feat(go-validate): wire sidecar into docker-compose so build_and_run runs it Makes the fast path self-contained: build_and_run.sh now brings up the go-validate sidecar and routes the nginx /validate auth_request through it, with no separate step. - nginx templates: give /validate its own {{VALIDATE_UPSTREAM_HOST/PORT}} placeholder (oauth2/* stay on the auth-server). - nginx_service.py: resolve /validate upstream from VALIDATE_UPSTREAM_URL, defaulting to the auth-server when unset (backward compatible for Terraform/ECS, Helm, and podman/prebuilt compose). - docker-compose.yml: add the go-validate service (build, env, healthcheck via a new -healthcheck self-check flag) and point the registry's VALIDATE_UPSTREAM_URL at it. - .env.example: document VALIDATE_UPSTREAM_URL / VALIDATE_JWKS_URL / VALIDATE_ISSUER / VALIDATE_AUDIENCE / JWKS_REFRESH_SECONDS. - go-validate: audience mismatch now falls back to Python (authoritative) instead of 401, so inline-by-default never rejects a token Python would accept; only bad signature/expiry -> 401. - tests: cover the VALIDATE_UPSTREAM_URL set + default-to-auth-server paths. Refs #1652 --- .env.example | 25 +++++++ docker-compose.yml | 38 ++++++++++ docker/nginx_rev_proxy_http_and_https.conf | 4 +- docker/nginx_rev_proxy_http_only.conf | 2 +- go-validate/jwt.go | 5 +- go-validate/main.go | 23 ++++++ registry/core/nginx_service.py | 31 ++++++++ tests/unit/core/test_nginx_service.py | 83 ++++++++++++++++++++++ 8 files changed, 207 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 183a0163f..64bc53deb 100644 --- a/.env.example +++ b/.env.example @@ -1915,3 +1915,28 @@ OPENBAO_ROLE= # or let build_and_run.sh generate one. Do NOT reuse the old "dev-root-token" # literal — the preflight validator rejects it. OPENBAO_TOKEN= + +# --------------------------------------------------------------------------- +# Go /validate fast-path sidecar (go-validate) - issue #1652, PR #1653 +# --------------------------------------------------------------------------- +# nginx routes the /validate auth_request subrequest to this sidecar. It fast-paths +# the configured-IdP RS256 bearer path and reverse-proxies everything else to the +# Python auth-server, so it is a SAFE transparent proxy until the fast-path vars +# below are set. Leave VALIDATE_UPSTREAM_URL unset on deployments without the sidecar +# (Terraform/ECS, Helm, podman/prebuilt compose): /validate then defaults to the +# auth-server, i.e. unchanged behavior. +# +# Route /validate to the sidecar (docker-compose default). Set to +# http://auth-server:8888 to bypass the sidecar entirely. +VALIDATE_UPSTREAM_URL=http://go-validate:8899 +# JWKS endpoint the sidecar fetches signing keys from (must be reachable from the +# sidecar container). Defaults to the internal Keycloak realm certs. +VALIDATE_JWKS_URL=http://keycloak:8080/realms/mcp-gateway/protocol/openid-connect/certs +# Expected token issuer (the `iss` claim, usually the EXTERNAL Keycloak URL). Leave +# empty to run fallback-only (safe: every /validate proxied to Python, not accelerated). +VALIDATE_ISSUER= +# Expected token audience (the `aud` claim). Empty -> fallback-only. A mismatch is +# treated as "not our fast path" and deferred to Python, never a 401. +VALIDATE_AUDIENCE= +# JWKS cache refresh interval (seconds). +JWKS_REFRESH_SECONDS=300 diff --git a/docker-compose.yml b/docker-compose.yml index 6dfc4689b..24ec3b108 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -230,6 +230,11 @@ services: - OPENBAO_TOKEN=${OPENBAO_TOKEN:?Set OPENBAO_TOKEN in .env (a strong random value; do not reuse the old dev-root-token default)} - AUTH_SERVER_URL=${AUTH_SERVER_URL} - AUTH_SERVER_EXTERNAL_URL=${AUTH_SERVER_EXTERNAL_URL} + # Dedicated upstream for the nginx /validate auth_request subrequest. Points + # at the go-validate fast-path sidecar by default; override to + # http://auth-server:8888 to route /validate straight to Python. Only + # /validate is affected; /oauth2/* always go to the auth-server. + - VALIDATE_UPSTREAM_URL=${VALIDATE_UPSTREAM_URL:-http://go-validate:8899} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} - GITHUB_ENABLED=${GITHUB_ENABLED:-false} @@ -530,6 +535,8 @@ services: depends_on: auth-server: condition: service_started + go-validate: + condition: service_started metrics-service: condition: service_healthy mongodb-init: @@ -595,6 +602,37 @@ services: retries: 3 # Auth service (separate and scalable) + # Go fast-path sidecar for the auth-server /validate endpoint (issue #1652, PR #1653). + # nginx routes /validate here via the registry's VALIDATE_UPSTREAM_URL. It fast-paths + # the configured-IdP RS256 bearer path and reverse-proxies everything else to the + # auth-server, so it is a safe transparent proxy until the fast-path vars + # (VALIDATE_ISSUER + VALIDATE_AUDIENCE) are set. Stdlib-only, ~10 MB static binary. + go-validate: + build: + context: go-validate + dockerfile: Dockerfile + environment: + - GOVALIDATE_LISTEN=:8899 + - SECRET_KEY=${SECRET_KEY} + - AUTH_FALLBACK_URL=http://auth-server:8888 + # Fast path engages only when JWKS_URL + VALIDATE_ISSUER + VALIDATE_AUDIENCE are + # all set and match the tokens; otherwise every /validate request is transparently + # proxied to the auth-server (correct, just not accelerated). + - JWKS_URL=${VALIDATE_JWKS_URL:-http://keycloak:8080/realms/${KEYCLOAK_REALM:-mcp-gateway}/protocol/openid-connect/certs} + - VALIDATE_ISSUER=${VALIDATE_ISSUER:-} + - VALIDATE_AUDIENCE=${VALIDATE_AUDIENCE:-} + - JWKS_REFRESH_SECONDS=${JWKS_REFRESH_SECONDS:-300} + healthcheck: + test: ["CMD", "/go-validate", "-healthcheck"] + interval: 15s + timeout: 3s + retries: 3 + start_period: 5s + depends_on: + auth-server: + condition: service_started + restart: unless-stopped + auth-server: build: context: . diff --git a/docker/nginx_rev_proxy_http_and_https.conf b/docker/nginx_rev_proxy_http_and_https.conf index 68001d451..f913098e8 100644 --- a/docker/nginx_rev_proxy_http_and_https.conf +++ b/docker/nginx_rev_proxy_http_and_https.conf @@ -297,7 +297,7 @@ server { location = /validate { internal; - proxy_pass http://{{AUTH_SERVER_HOST}}:{{AUTH_SERVER_PORT}}/validate; + proxy_pass http://{{VALIDATE_UPSTREAM_HOST}}:{{VALIDATE_UPSTREAM_PORT}}/validate; # Pass original request info proxy_set_header X-Original-URI $request_uri; @@ -1119,7 +1119,7 @@ server { location = /validate { internal; - proxy_pass http://{{AUTH_SERVER_HOST}}:{{AUTH_SERVER_PORT}}/validate; + proxy_pass http://{{VALIDATE_UPSTREAM_HOST}}:{{VALIDATE_UPSTREAM_PORT}}/validate; # Pass original request info proxy_set_header X-Original-URI $request_uri; diff --git a/docker/nginx_rev_proxy_http_only.conf b/docker/nginx_rev_proxy_http_only.conf index a3b550407..c48ff684f 100644 --- a/docker/nginx_rev_proxy_http_only.conf +++ b/docker/nginx_rev_proxy_http_only.conf @@ -519,7 +519,7 @@ server { location = /validate { internal; - proxy_pass http://{{AUTH_SERVER_HOST}}:{{AUTH_SERVER_PORT}}/validate; + proxy_pass http://{{VALIDATE_UPSTREAM_HOST}}:{{VALIDATE_UPSTREAM_PORT}}/validate; # Pass original request info proxy_set_header X-Original-URI $request_uri; diff --git a/go-validate/jwt.go b/go-validate/jwt.go index 5f28da569..53c2ed401 100644 --- a/go-validate/jwt.go +++ b/go-validate/jwt.go @@ -136,7 +136,10 @@ func verifyRS256(token string, ks *keysetCache, issuer, audience string) (*Claim return nil, errUnknownKey // different issuer -> let Python handle it } if !c.audContains(audience) { - return nil, errInvalidToken + // Audience is a policy decision, not proof of forgery. Defer to Python + // (authoritative) rather than 401 so we never reject a token the full + // handler would have accepted. Only a bad signature or expiry -> 401. + return nil, errUnknownKey } now := time.Now().Unix() if c.Exp != 0 && now > c.Exp+clockLeewaySeconds { diff --git a/go-validate/main.go b/go-validate/main.go index 5d501e3b2..b25c9de68 100644 --- a/go-validate/main.go +++ b/go-validate/main.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "os" "strings" "sync/atomic" ) @@ -182,7 +183,29 @@ func itoa(n int64) string { return strconvFormat(n) } +// runHealthcheck is invoked via `go-validate -healthcheck` (used by the container +// healthcheck, since the distroless image has no shell/curl). It GETs the local +// /health endpoint and exits 0 on 200, 1 otherwise. +func runHealthcheck(listen string) { + addr := listen + if strings.HasPrefix(addr, ":") { + addr = "127.0.0.1" + addr + } + resp, err := http.Get("http://" + addr + "/health") + if err != nil { + log.Fatalf("healthcheck failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Fatalf("healthcheck: status %d", resp.StatusCode) + } +} + func main() { + if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { + runHealthcheck(getenv("GOVALIDATE_LISTEN", ":8899")) + return + } cfg := loadConfig() target, err := url.Parse(cfg.FallbackURL) diff --git a/registry/core/nginx_service.py b/registry/core/nginx_service.py index 7dbe2f3c6..a991ac0bf 100644 --- a/registry/core/nginx_service.py +++ b/registry/core/nginx_service.py @@ -1307,6 +1307,37 @@ async def _render_config_impl( config_content = config_content.replace("{{AUTH_SERVER_HOST}}", auth_host) config_content = config_content.replace("{{AUTH_SERVER_PORT}}", auth_port) + # Dedicated upstream for the /validate auth_request subrequest. Defaults + # to the auth-server (backward compatible when unset) but can point at the + # go-validate fast-path sidecar via VALIDATE_UPSTREAM_URL. Only /validate is + # routed here; the /oauth2/* locations always stay on the auth-server. + validate_host = auth_host + validate_port = auth_port + validate_upstream_url = os.environ.get("VALIDATE_UPSTREAM_URL", "").strip() + if validate_upstream_url: + try: + parsed_validate = urlparse(validate_upstream_url) + validate_host = parsed_validate.hostname or auth_host + if parsed_validate.port: + validate_port = str(parsed_validate.port) + else: + validate_scheme = parsed_validate.scheme or "http" + validate_port = "443" if validate_scheme == "https" else auth_port + logger.info( + f"Routing /validate to dedicated upstream from " + f"VALIDATE_UPSTREAM_URL '{validate_upstream_url}': " + f"{validate_host}:{validate_port}" + ) + except Exception as e: + logger.warning( + f"Failed to parse VALIDATE_UPSTREAM_URL " + f"'{validate_upstream_url}': {e}. Using auth-server for /validate." + ) + validate_host = auth_host + validate_port = auth_port + config_content = config_content.replace("{{VALIDATE_UPSTREAM_HOST}}", validate_host) + config_content = config_content.replace("{{VALIDATE_UPSTREAM_PORT}}", validate_port) + # Real client-IP recovery (TRUSTED_REAL_IP_CIDRS). Empty by default so # edge deployments emit nothing; when trusted proxy CIDRs are set, the # audited client IP becomes the end user instead of the load balancer. diff --git a/tests/unit/core/test_nginx_service.py b/tests/unit/core/test_nginx_service.py index 44e9fe5c0..4785688de 100644 --- a/tests/unit/core/test_nginx_service.py +++ b/tests/unit/core/test_nginx_service.py @@ -2605,3 +2605,86 @@ async def test_proxy_ssl_placeholder_is_substituted_end_to_end( f"{conf_path.name} (pingfederate): {{{{PINGFEDERATE_PROXY_SSL}}}} placeholder was NOT " "substituted -- the .replace() wiring in _render_config_impl is broken." ) + + +# ============================================================================= +# VALIDATE_UPSTREAM_URL PLACEHOLDER SUBSTITUTION (go-validate sidecar, #1652) +# ============================================================================= + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_generate_config_async_validate_upstream_set( + nginx_service, sample_servers, mock_health_service, mock_atomic_write +): + """VALIDATE_UPSTREAM_URL routes only /validate to the go-validate sidecar.""" + template_content = """ +server { + proxy_pass http://{{VALIDATE_UPSTREAM_HOST}}:{{VALIDATE_UPSTREAM_PORT}}/validate; + proxy_pass http://{{AUTH_SERVER_HOST}}:{{AUTH_SERVER_PORT}}/oauth2/login/keycloak; +{{LOCATION_BLOCKS}} +} +""" + with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): + with patch("builtins.open", mock_open(read_data=template_content)): + with patch("registry.health.service.health_service", mock_health_service): + mock_health_service.server_health_status = {} + with patch.object(nginx_service, "get_additional_server_names", return_value=""): + with patch.object(nginx_service, "reload_nginx", return_value=True): + env_values = { + "AUTH_PROVIDER": "keycloak", + "KEYCLOAK_URL": "http://keycloak:8080", + "AUTH_SERVER_URL": "http://auth-server:8888", + "VALIDATE_UPSTREAM_URL": "http://go-validate:8899", + "NGINX_DISABLE_API_AUTH_REQUEST": "false", + } + with patch( + "os.environ.get", + side_effect=lambda key, default=None: env_values.get(key, default), + ): + result = await nginx_service.generate_config_async(sample_servers) + + assert result is True + written = mock_atomic_write.call_args_list[0][0][1] + # /validate goes to the sidecar... + assert "http://go-validate:8899/validate;" in written + # ...while /oauth2/* still goes to the auth-server. + assert "http://auth-server:8888/oauth2/login/keycloak;" in written + assert "{{VALIDATE_UPSTREAM_HOST}}" not in written + assert "{{VALIDATE_UPSTREAM_PORT}}" not in written + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_generate_config_async_validate_upstream_defaults_to_auth( + nginx_service, sample_servers, mock_health_service, mock_atomic_write +): + """When VALIDATE_UPSTREAM_URL is unset, /validate defaults to the auth-server.""" + template_content = """ +server { + proxy_pass http://{{VALIDATE_UPSTREAM_HOST}}:{{VALIDATE_UPSTREAM_PORT}}/validate; +{{LOCATION_BLOCKS}} +} +""" + with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): + with patch("builtins.open", mock_open(read_data=template_content)): + with patch("registry.health.service.health_service", mock_health_service): + mock_health_service.server_health_status = {} + with patch.object(nginx_service, "get_additional_server_names", return_value=""): + with patch.object(nginx_service, "reload_nginx", return_value=True): + env_values = { + "AUTH_PROVIDER": "keycloak", + "KEYCLOAK_URL": "http://keycloak:8080", + "AUTH_SERVER_URL": "http://auth-server:8888", + "NGINX_DISABLE_API_AUTH_REQUEST": "false", + } + with patch( + "os.environ.get", + side_effect=lambda key, default=None: env_values.get(key, default), + ): + result = await nginx_service.generate_config_async(sample_servers) + + assert result is True + written = mock_atomic_write.call_args_list[0][0][1] + assert "http://auth-server:8888/validate;" in written + assert "{{VALIDATE_UPSTREAM_HOST}}" not in written From 5c9176ec0632cf3d3e6e6f4cd8cdde889330e914 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 18 Aug 2026 18:55:16 +0000 Subject: [PATCH 03/26] fix(go-validate): make fallback verbatim + audience-mismatch defers to Python Two correctness fixes found during live testing behind nginx: - Fallback must not mutate the request. The handler previously stripped identity headers (incl. X-Client-Id) before every fallback, but nginx sets X-Client-Id / X-Original-URL / X-Registry-Api-Auth as legitimate INPUTS on the /validate subrequest. Stripping them made the fallback diverge from a direct nginx->Python call and broke POST /api/tokens/generate (401). Fallback is now byte-identical; identity injection on the fast path is prevented by Set-ting the response headers, not by mutating the request. - Audience mismatch now defers to Python (fallback) instead of 401, so the fast path never rejects a token the full handler would accept. Refs #1652 --- go-validate/main.go | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/go-validate/main.go b/go-validate/main.go index b25c9de68..3634e3e17 100644 --- a/go-validate/main.go +++ b/go-validate/main.go @@ -24,13 +24,6 @@ type identity struct { Method string } -// trustHeaders are identity/trust headers a client must never be able to inject -// (B2). They are stripped from every inbound request before verification. -var trustHeaders = []string{ - "X-User", "X-Username", "X-Client-Id", "X-Scopes", - "X-Groups", "X-Auth-Method", "X-Internal-Token-Registry", -} - // counters is a tiny lock-free metrics surface exposed at /metrics. type counters struct { fastOK atomic.Int64 @@ -45,13 +38,6 @@ type server struct { stats counters } -// stripClientTrustHeaders removes any client-supplied identity headers (B2). -func stripClientTrustHeaders(r *http.Request) { - for _, h := range trustHeaders { - r.Header.Del(h) - } -} - // extractBearer returns the bearer token, honoring X-Authorization over // Authorization. The A2A rule: an /agent/... path must NOT fall back from // X-Authorization to Authorization (that header carries the target agent's @@ -111,12 +97,15 @@ func writeIdentityHeaders(w http.ResponseWriter, ident identity, internal string h.Set("X-Internal-Token-Registry", internal) } -// handleValidate is the hot path: strip trust headers, verify the RS256 bearer, -// and either answer 200 with identity headers, 401 for a recognized-invalid -// token, or fall back to Python for anything unrecognized. +// handleValidate is the hot path: verify the RS256 bearer and either answer 200 +// with identity headers, 401 for a recognized-invalid token, or fall back to +// Python for anything unrecognized (cookies, other IdPs, opaque tokens). func (s *server) handleValidate(w http.ResponseWriter, r *http.Request) { - stripClientTrustHeaders(r) // B2: never trust client-supplied identity headers - + // NOTE: do NOT strip request headers before falling back. nginx sets + // legitimate inputs on the /validate subrequest (e.g. X-Client-Id from + // $http_x_client_id, X-Original-URL, X-Registry-Api-Auth). The fallback must + // be byte-identical to a direct nginx->Python /validate call, and Python is + // authoritative for identity there (exactly as today, sidecar or not). if !s.cfg.FastPathReady { s.stats.fallback.Add(1) s.fallback.ServeHTTP(w, r) @@ -146,6 +135,9 @@ func (s *server) handleValidate(w http.ResponseWriter, r *http.Request) { return } + // Fast path: we answer authoritatively. Identity in the RESPONSE is fully + // controlled by writeIdentityHeaders (Set overwrites), so a client cannot + // inject identity even though we no longer mutate the request (B2 preserved). ident := mapClaims(claims) internal, err := mintInternalToken(ident, s.cfg.Audience, s.cfg.SecretKey) if err != nil { From 2decd71a92fb44014d0bd8b4236f4b8ba9eaa5b7 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 18 Aug 2026 19:23:38 +0000 Subject: [PATCH 04/26] feat(go-validate): group->scope parity + per-hop internal tokens The fast path now produces byte-identical /validate output to Python for the RS256 bearer path, verified live against auth-server: - Scope resolution: load mcp_scopes + idp_m2m_clients into TTL-refreshed in-memory snapshots (Mongo driver) and resolve X-Scopes exactly as Python's map_groups_to_scopes + M2M group enrichment, honoring the enabled flag (fail closed) and the user-generated sentinel. Falls back to Python for cases it cannot replicate (e.g. user tokens needing idp_user_groups). - Per-hop internal tokens: mint the registry-ui token (thin identity, no scopes) only when X-Registry-Api-Auth is set, and the mcp-proxy token (scopes + resolved upstream, gated on the source-secret marker) only when X-Resolved-Upstream is set - matching internal_request_token.py audiences, claim shapes, and canonical auth_method (per-user IdP -> oauth2). - Headers: X-User/Username/Client-Id/Scopes/Auth-Method/Server-Name/Tool-Name/ Groups all match; verified byte-identical on the /api/ path. Adds the official Go MongoDB driver (read-only snapshots). Compose wires the DOCUMENTDB_* env into the sidecar. Refs #1652 --- docker-compose.yml | 14 +++ go-validate/Dockerfile | 3 +- go-validate/config.go | 26 +++-- go-validate/go.mod | 15 +++ go-validate/go.sum | 50 +++++++++ go-validate/jwt.go | 108 ++++++++++++++++---- go-validate/main.go | 126 ++++++++++++++++++----- go-validate/scopes.go | 223 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 508 insertions(+), 57 deletions(-) create mode 100644 go-validate/go.sum create mode 100644 go-validate/scopes.go diff --git a/docker-compose.yml b/docker-compose.yml index 24ec3b108..3fb5fb10b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -622,6 +622,20 @@ services: - VALIDATE_ISSUER=${VALIDATE_ISSUER:-} - VALIDATE_AUDIENCE=${VALIDATE_AUDIENCE:-} - JWKS_REFRESH_SECONDS=${JWKS_REFRESH_SECONDS:-300} + # Scope parity: read-only snapshots of mcp_scopes + idp_m2m_clients so + # X-Scopes matches Python's group->scope resolution. Same DB the registry uses. + - DOCUMENTDB_HOST=${DOCUMENTDB_HOST:-mongodb} + - DOCUMENTDB_PORT=${DOCUMENTDB_PORT:-27017} + - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} + - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME:-} + - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD:-} + - DOCUMENTDB_USE_TLS=${DOCUMENTDB_USE_TLS:-false} + - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} + - DOCUMENTDB_DIRECT_CONNECTION=${DOCUMENTDB_DIRECT_CONNECTION:-true} + - SCOPE_SNAPSHOT_TTL_SECONDS=${SCOPE_SNAPSHOT_TTL_SECONDS:-60} + # Match the nginx source-secret marker so the mcp-proxy token mints only + # for genuine nginx subrequests (same gate as Python). + - AUTH_SERVER_NGINX_MARKER_SECRET=${AUTH_SERVER_NGINX_MARKER_SECRET:-} healthcheck: test: ["CMD", "/go-validate", "-healthcheck"] interval: 15s diff --git a/go-validate/Dockerfile b/go-validate/Dockerfile index ba32280fc..a268ae082 100644 --- a/go-validate/Dockerfile +++ b/go-validate/Dockerfile @@ -1,7 +1,8 @@ # Build a static, dependency-free binary and ship it on a minimal base. FROM golang:1.24-alpine AS build WORKDIR /src -COPY go.mod ./ +COPY go.mod go.sum ./ +RUN go mod download COPY *.go ./ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/go-validate . diff --git a/go-validate/config.go b/go-validate/config.go index 5b9247b3b..f9742c4ea 100644 --- a/go-validate/config.go +++ b/go-validate/config.go @@ -19,21 +19,24 @@ type Config struct { Audience string FallbackURL string JWKSRefreshSec int + ScopeTTLSec int + AuthMethod string + MarkerSecret string FastPathReady bool } // knownWeakSecrets are literals that must never be accepted as a signing key. var knownWeakSecrets = map[string]bool{ - "secret": true, - "changeme": true, - "change-me": true, - "password": true, - "your-secret-key": true, - "your_secret_key": true, - "test": true, - "dev": true, - "mcp-secret-key": true, - "default": true, + "secret": true, + "changeme": true, + "change-me": true, + "password": true, + "your-secret-key": true, + "your_secret_key": true, + "test": true, + "dev": true, + "mcp-secret-key": true, + "default": true, } // validateSecretKey enforces the signing-secret invariant: reject missing AND weak @@ -71,6 +74,9 @@ func loadConfig() Config { Audience: os.Getenv("VALIDATE_AUDIENCE"), FallbackURL: getenv("AUTH_FALLBACK_URL", "http://auth-server:8888"), JWKSRefreshSec: atoiDefault(os.Getenv("JWKS_REFRESH_SECONDS"), 300), + ScopeTTLSec: atoiDefault(os.Getenv("SCOPE_SNAPSHOT_TTL_SECONDS"), 60), + AuthMethod: getenv("VALIDATE_AUTH_METHOD", "keycloak"), + MarkerSecret: os.Getenv("AUTH_SERVER_NGINX_MARKER_SECRET"), } // B3: validate the signing secret. If a secret is provided at all it must be diff --git a/go-validate/go.mod b/go-validate/go.mod index f236580f2..2a4288084 100644 --- a/go-validate/go.mod +++ b/go-validate/go.mod @@ -1,3 +1,18 @@ module go-validate go 1.24 + +require go.mongodb.org/mongo-driver v1.17.9 + +require ( + github.com/golang/snappy v0.0.4 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.17.0 // indirect +) diff --git a/go-validate/go.sum b/go-validate/go.sum new file mode 100644 index 000000000..90d6454ca --- /dev/null +++ b/go-validate/go.sum @@ -0,0 +1,50 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/go-validate/jwt.go b/go-validate/jwt.go index 53c2ed401..ddcb0b649 100644 --- a/go-validate/jwt.go +++ b/go-validate/jwt.go @@ -16,10 +16,10 @@ import ( // Sentinel errors let the handler decide: fall back to Python (unrecognized) vs // return 401 (recognized but invalid). This encodes the fail-closed boundary. var ( - errNotJWT = errors.New("not a JWT") // -> fallback - errUnknownKey = errors.New("unknown kid/issuer") // -> fallback - errInvalidToken = errors.New("invalid token") // -> 401 - errWrongAlg = errors.New("unexpected alg") // -> fallback (could be HS/none from elsewhere) + errNotJWT = errors.New("not a JWT") // -> fallback + errUnknownKey = errors.New("unknown kid/issuer") // -> fallback + errInvalidToken = errors.New("invalid token") // -> 401 + errWrongAlg = errors.New("unexpected alg") // -> fallback (could be HS/none from elsewhere) ) const clockLeewaySeconds = 30 @@ -148,22 +148,43 @@ func verifyRS256(token string, ks *keysetCache, issuer, audience string) (*Claim return &c, nil } -// mintInternalToken produces the HS256 X-Internal-Token-Registry that downstream -// services expect, signed with the shared SECRET_KEY. TTL is 30s to match Python. -func mintInternalToken(ident identity, audience, secret string) (string, error) { - header := map[string]string{"alg": "HS256", "typ": "JWT"} +// Internal-token issuer + audiences (mirror auth_server/internal_request_token.py). +const ( + internalIssuer = "mcp-auth-server" + mcpProxyAudience = "mcp-proxy" + mcpProxyTokenUse = "mcp-proxy" + mcpRegistryUIAudience = "mcp-registry-ui" + mcpRegistryUITokenUse = "mcp-registry-ui" + internalTokenTTLSecond = 30 +) + +// mintInternal signs an HS256 internal JWT with the shared SECRET_KEY. It refuses +// an empty subject (fail closed), exactly like _mint_internal_token: an anonymous +// but valid token must never be issued. +func mintInternal( + secret, audience, subject string, + scopes []string, + extra map[string]any, +) (string, error) { + if subject == "" { + return "", errors.New("cannot mint internal token with empty subject") + } + if scopes == nil { + scopes = []string{} + } now := time.Now().Unix() claims := map[string]any{ - "iss": "mcp-auth-server", - "aud": audience, - "sub": ident.Sub, - "username": ident.Username, - "client_id": ident.ClientID, - "scopes": ident.Scopes, - "token_use": "internal", - "iat": now, - "exp": now + 30, + "iss": internalIssuer, + "aud": audience, + "sub": subject, + "scopes": scopes, + "iat": now, + "exp": now + internalTokenTTLSecond, } + for k, v := range extra { + claims[k] = v + } + header := map[string]string{"alg": "HS256", "typ": "JWT"} hb, err := json.Marshal(header) if err != nil { return "", err @@ -175,6 +196,55 @@ func mintInternalToken(ident identity, audience, secret string) (string, error) signingInput := b64urlEncode(hb) + "." + b64urlEncode(cb) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signingInput)) - sig := b64urlEncode(mac.Sum(nil)) - return signingInput + "." + sig, nil + return signingInput + "." + b64urlEncode(mac.Sum(nil)), nil +} + +// mintRegistryUIToken mirrors mint_registry_ui_token: a thin identity assertion +// for the registry /api/ hop (no scopes encoded; registry derives them). +func mintRegistryUIToken( + secret, subject, sessionID string, + groups []string, + authMethod, clientID, egressUser string, +) (string, error) { + if groups == nil { + groups = []string{} + } + return mintInternal(secret, mcpRegistryUIAudience, subject, []string{}, map[string]any{ + "session_id": sessionID, + "groups": groups, + "auth_method": authMethod, + "client_id": clientID, + "egress_user": egressUser, + "token_use": mcpRegistryUITokenUse, + }) +} + +// mintMCPProxyToken mirrors mint_mcp_proxy_token: binds scopes + resolved upstream +// for the /mcp-proxy hop. server is the first path segment (traversal guard). +func mintMCPProxyToken( + secret, subject string, + scopes []string, + serverName, upstreamURL, authMethod, egressUser string, +) (string, error) { + server := serverName + if i := indexByte(server, '/'); i >= 0 { + server = server[:i] + } + return mintInternal(secret, mcpProxyAudience, subject, scopes, map[string]any{ + "server": server, + "upstream_url": upstreamURL, + "auth_method": authMethod, + "egress_user": egressUser, + "token_use": mcpProxyTokenUse, + }) +} + +// indexByte returns the index of the first b in s, or -1. +func indexByte(s string, b byte) int { + for i := 0; i < len(s); i++ { + if s[i] == b { + return i + } + } + return -1 } diff --git a/go-validate/main.go b/go-validate/main.go index 3634e3e17..2248900d4 100644 --- a/go-validate/main.go +++ b/go-validate/main.go @@ -5,6 +5,7 @@ package main import ( + "crypto/hmac" "log" "net/http" "net/http/httputil" @@ -19,9 +20,6 @@ type identity struct { Sub string Username string ClientID string - Scopes string - Groups string - Method string } // counters is a tiny lock-free metrics surface exposed at /metrics. @@ -34,10 +32,46 @@ type counters struct { type server struct { cfg Config ks *keysetCache + scopes *scopeResolver fallback http.Handler stats counters } +// perUserIdPMethods fold to the single canonical egress bucket "oauth2" in the +// internal tokens (mirrors _canonical_auth_method / egress_auth.canonical_auth_method). +// The per-user egress vault keys on this, so consent-write and vend-read must agree. +var perUserIdPMethods = map[string]bool{ + "session_cookie": true, "self_signed": true, "keycloak": true, "entra": true, + "cognito": true, "okta": true, "auth0": true, "pingfederate": true, + "jwt": true, "boto3": true, +} + +// canonicalAuthMethod returns the egress-principal bucket stamped into the internal +// tokens. Per-user IdP methods canonicalize to "oauth2"; others pass through. +func canonicalAuthMethod(method string) string { + if perUserIdPMethods[method] { + return "oauth2" + } + return method +} + +// serverNameFromOriginalURL extracts the first path segment of X-Original-URL +// (the MCP server name / traversal-guard segment). Empty for /api/ and root. +func serverNameFromOriginalURL(original string) string { + u, err := url.Parse(original) + if err != nil { + return "" + } + p := strings.Trim(u.Path, "/") + if p == "" || strings.HasPrefix(p, "api/") || p == "api" { + return "" + } + if i := strings.IndexByte(p, '/'); i >= 0 { + return p[:i] + } + return p +} + // extractBearer returns the bearer token, honoring X-Authorization over // Authorization. The A2A rule: an /agent/... path must NOT fall back from // X-Authorization to Authorization (that header carries the target agent's @@ -64,8 +98,8 @@ func parseBearer(v string) (string, bool) { return "", false } -// mapClaims turns verified claims into an identity. Keycloak claim shape today; -// additional IdPs add a case here (config + claim map, not a rewrite). +// mapClaims turns verified claims into a caller identity. Keycloak claim shape +// today; additional IdPs add a case here (config + claim map, not a rewrite). func mapClaims(c *Claims) identity { clientID := c.Azp if clientID == "" { @@ -79,24 +113,9 @@ func mapClaims(c *Claims) identity { Sub: c.Sub, Username: username, ClientID: clientID, - Scopes: c.Scope, - Groups: strings.Join(c.Groups, " "), - Method: "go-fastpath", } } -// writeIdentityHeaders sets the headers nginx consumes via auth_request_set. -func writeIdentityHeaders(w http.ResponseWriter, ident identity, internal string) { - h := w.Header() - h.Set("X-User", ident.Username) - h.Set("X-Username", ident.Username) - h.Set("X-Client-Id", ident.ClientID) - h.Set("X-Scopes", ident.Scopes) - h.Set("X-Groups", ident.Groups) - h.Set("X-Auth-Method", ident.Method) - h.Set("X-Internal-Token-Registry", internal) -} - // handleValidate is the hot path: verify the RS256 bearer and either answer 200 // with identity headers, 401 for a recognized-invalid token, or fall back to // Python for anything unrecognized (cookies, other IdPs, opaque tokens). @@ -135,18 +154,68 @@ func (s *server) handleValidate(w http.ResponseWriter, r *http.Request) { return } - // Fast path: we answer authoritatively. Identity in the RESPONSE is fully - // controlled by writeIdentityHeaders (Set overwrites), so a client cannot - // inject identity even though we no longer mutate the request (B2 preserved). + // Fast path. Resolve scopes exactly as Python does (group->scope mapping, + // M2M enrichment). If we cannot resolve them safely (no DB snapshot, or a + // user token that would need idp_user_groups enrichment), fall back. ident := mapClaims(claims) - internal, err := mintInternalToken(ident, s.cfg.Audience, s.cfg.SecretKey) - if err != nil { - // Minting should never fail; if it does, defer to Python rather than 500. + if s.scopes == nil { s.stats.fallback.Add(1) s.fallback.ServeHTTP(w, r) return } - writeIdentityHeaders(w, ident, internal) + scopes, ok := s.scopes.resolve(claims.Groups, ident.ClientID) + if !ok { + s.stats.fallback.Add(1) + s.fallback.ServeHTTP(w, r) + return + } + + // Identity in the RESPONSE is fully controlled below (Set overwrites), so a + // client cannot inject identity even though we never mutate the request. + serverName := serverNameFromOriginalURL(r.Header.Get("X-Original-URL")) + egressUser := ident.Sub // canonical egress vault id = OIDC sub (bearer callers) + canonMethod := canonicalAuthMethod(s.cfg.AuthMethod) // internal-token auth_method claim + + h := w.Header() + h.Set("X-User", ident.Username) + h.Set("X-Username", ident.Username) + h.Set("X-Client-Id", ident.ClientID) + h.Set("X-Scopes", scopesToHeader(scopes)) + h.Set("X-Auth-Method", s.cfg.AuthMethod) + h.Set("X-Server-Name", serverName) + h.Set("X-Tool-Name", "") + h.Set("X-Groups", strings.Join(claims.Groups, " ")) + + // Registry /api/ hop: thin identity token, minted only when nginx set the marker. + if r.Header.Get("X-Registry-Api-Auth") != "" { + if tok, err := mintRegistryUIToken( + s.cfg.SecretKey, ident.Username, "", claims.Groups, + canonMethod, ident.ClientID, egressUser, + ); err == nil { + h.Set("X-Internal-Token-Registry", tok) + } else { + log.Printf("could not mint registry-ui token: %v", err) + } + } + + // /mcp-proxy hop: scope+upstream-bound token, minted only when nginx forwarded + // the resolved upstream AND (if configured) the matching source-secret marker. + if up := r.Header.Get("X-Resolved-Upstream"); up != "" { + if s.cfg.MarkerSecret == "" || + hmac.Equal([]byte(r.Header.Get("X-Validate-Source-Secret")), []byte(s.cfg.MarkerSecret)) { + if tok, err := mintMCPProxyToken( + s.cfg.SecretKey, ident.Username, scopes, serverName, up, + canonMethod, egressUser, + ); err == nil { + h.Set("X-Internal-Token", tok) + } else { + log.Printf("could not mint mcp-proxy token: %v", err) + } + } else { + log.Printf("X-Resolved-Upstream present but source-secret marker mismatch; not minting") + } + } + s.stats.fastOK.Add(1) w.WriteHeader(http.StatusOK) } @@ -211,6 +280,9 @@ func main() { if cfg.JWKSURL != "" { s.ks = newKeysetCache(cfg.JWKSURL, cfg.JWKSRefreshSec) } + // Scope resolver loads mcp_scopes + idp_m2m_clients snapshots for group->scope + // parity with Python. Nil when DB is unconfigured -> handler falls back. + s.scopes = newScopeResolver(cfg.ScopeTTLSec) mux := http.NewServeMux() mux.HandleFunc("/validate", s.handleValidate) diff --git a/go-validate/scopes.go b/go-validate/scopes.go new file mode 100644 index 000000000..51cb0df45 --- /dev/null +++ b/go-validate/scopes.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/url" + "os" + "strings" + "sync/atomic" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// userGeneratedClientID is the sentinel client_id on self-signed user tokens; +// it must never be treated as an M2M client for group enrichment (mirrors +// auth_server/mongodb_groups_enrichment.py). +const userGeneratedClientID = "user-generated" + +// scopeDoc is one mcp_scopes document: _id is the scope name, group_mappings is +// the list of IdP groups that grant it. +type scopeDoc struct { + ID string `bson:"_id"` + GroupMappings []string `bson:"group_mappings"` +} + +// m2mClient is one idp_m2m_clients document (the M2M group-enrichment source). +type m2mClient struct { + ClientID string `bson:"client_id"` + Groups []string `bson:"groups"` + Enabled interface{} `bson:"enabled"` +} + +// scopeSnapshot is an immutable, atomically-swapped view of the two collections +// the scope resolution needs. Per-request resolution reads it lock-free. +type scopeSnapshot struct { + scopes []scopeDoc // natural order (matches Python's cursor iteration) + m2mGroups map[string][]string // client_id -> enriched groups (enabled records only) +} + +// scopeResolver loads mcp_scopes + idp_m2m_clients into a TTL-refreshed snapshot +// and resolves a token's groups/client into the same scope set the Python +// /validate handler would produce. +type scopeResolver struct { + client *mongo.Client + db *mongo.Database + scopesC string + clientsC string + snap atomic.Pointer[scopeSnapshot] + ready atomic.Bool +} + +// recordEnabled mirrors _is_record_enabled: active only when `enabled` is absent +// (backward compat) or the boolean true. Any other value is disabled (fail closed). +func recordEnabled(enabled interface{}) bool { + if enabled == nil { + return true // field absent decodes to nil + } + b, isBool := enabled.(bool) + if !isBool { + return false + } + return b +} + +// buildMongoURI assembles the connection string the way the registry does +// (username/password -> SCRAM-SHA-256 against authSource=admin for non-DocumentDB). +func buildMongoURI() (string, bool) { + host := os.Getenv("DOCUMENTDB_HOST") + if host == "" { + return "", false // scope resolution disabled when DB not configured + } + port := getenv("DOCUMENTDB_PORT", "27017") + dbName := getenv("DOCUMENTDB_DATABASE", "mcp_registry") + user := os.Getenv("DOCUMENTDB_USERNAME") + pass := os.Getenv("DOCUMENTDB_PASSWORD") + params := "authSource=admin&authMechanism=SCRAM-SHA-256" + if getenv("DOCUMENTDB_DIRECT_CONNECTION", "true") == "true" { + params += "&directConnection=true" + } + if getenv("DOCUMENTDB_USE_TLS", "false") == "true" { + params += "&tls=true" + } + if user != "" && pass != "" { + return fmt.Sprintf("mongodb://%s:%s@%s:%s/%s?%s", + url.QueryEscape(user), url.QueryEscape(pass), host, port, dbName, params), true + } + return fmt.Sprintf("mongodb://%s:%s/%s", host, port, dbName), true +} + +// newScopeResolver connects to Mongo and starts a background snapshot refresh. +// Returns nil when the DB is not configured (caller then runs fast-path without +// scope parity, which means it must fall back for scope-bearing requests). +func newScopeResolver(refreshSec int) *scopeResolver { + uri, ok := buildMongoURI() + if !ok { + log.Printf("scope resolver: DOCUMENTDB_HOST unset; scope parity disabled") + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + if err != nil { + log.Printf("scope resolver: connect failed (%v); scope parity disabled until it loads", err) + return nil + } + ns := getenv("DOCUMENTDB_NAMESPACE", "default") + r := &scopeResolver{ + client: client, + db: client.Database(getenv("DOCUMENTDB_DATABASE", "mcp_registry")), + scopesC: "mcp_scopes_" + ns, + clientsC: "idp_m2m_clients_" + ns, + } + if err := r.refresh(); err != nil { + log.Printf("scope resolver: initial load failed (%v); will retry", err) + } + go func() { + ticker := time.NewTicker(time.Duration(refreshSec) * time.Second) + defer ticker.Stop() + for range ticker.C { + if err := r.refresh(); err != nil { + log.Printf("scope resolver: refresh failed, keeping last-good snapshot: %v", err) + } + } + }() + return r +} + +// refresh reloads both collections into a new snapshot and swaps it in. +func (r *scopeResolver) refresh() error { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + scur, err := r.db.Collection(r.scopesC).Find(ctx, bson.D{}) + if err != nil { + return err + } + var scopes []scopeDoc + if err := scur.All(ctx, &scopes); err != nil { + return err + } + + ccur, err := r.db.Collection(r.clientsC).Find(ctx, bson.D{}) + if err != nil { + return err + } + var clients []m2mClient + if err := ccur.All(ctx, &clients); err != nil { + return err + } + m2m := make(map[string][]string, len(clients)) + for _, c := range clients { + if c.ClientID == "" || !recordEnabled(c.Enabled) { + continue // fail-closed: disabled records never grant groups + } + m2m[c.ClientID] = c.Groups + } + + r.snap.Store(&scopeSnapshot{scopes: scopes, m2mGroups: m2m}) + r.ready.Store(true) + return nil +} + +// mapGroupsToScopes returns the scope names whose group_mappings intersect the +// given groups, in scope-document order, de-duplicated (mirrors +// get_group_mappings_bulk + map_groups_to_scopes dedupe). +func (snap *scopeSnapshot) mapGroupsToScopes(groups []string) []string { + if len(groups) == 0 { + return nil + } + want := make(map[string]bool, len(groups)) + for _, g := range groups { + if g != "" { + want[g] = true + } + } + seen := make(map[string]bool) + var out []string + for _, doc := range snap.scopes { + for _, gm := range doc.GroupMappings { + if want[gm] { + if !seen[doc.ID] { + seen[doc.ID] = true + out = append(out, doc.ID) + } + break + } + } + } + return out +} + +// resolve returns the scope set for a token, matching Python's /validate, and a +// bool for whether the fast path could resolve it. It returns ok=false (caller +// must fall back to Python) for cases it cannot replicate exactly -- e.g. a user +// token with empty groups that would need idp_user_groups enrichment. +func (r *scopeResolver) resolve(tokenGroups []string, clientID string) (scopes []string, ok bool) { + snap := r.snap.Load() + if snap == nil { + return nil, false + } + // Case A: token carries groups -> map directly (same for user and M2M). + if len(tokenGroups) > 0 { + return snap.mapGroupsToScopes(tokenGroups), true + } + // Case B: empty groups + a real M2M client -> enrich from idp_m2m_clients. + if clientID != "" && clientID != userGeneratedClientID { + if groups, found := snap.m2mGroups[clientID]; found { + return snap.mapGroupsToScopes(groups), true + } + } + // Case C: empty groups, not a known M2M client -> may need idp_user_groups + // enrichment, which we do not replicate here. Fall back to Python. + return nil, false +} + +// scopesToHeader joins scopes with a space (the X-Scopes wire format). +func scopesToHeader(scopes []string) string { + return strings.Join(scopes, " ") +} From 61c252b4a6114b71e829020b72eb5706b575c605 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 18 Aug 2026 20:06:40 +0000 Subject: [PATCH 05/26] fix(go-validate): neutralize phantom request body on /validate subrequest nginx's auth_request subrequest for a POST/PUT/PATCH origin forwards the original Content-Length with NO body. httputil.ReverseProxy then blocked copying that phantom body to the auth-server, hanging the subrequest until nginx timed out (504 -> auth_request collapsed to 500). Every mutating request through the gateway (e.g. POST /api/tokens/generate) failed; GETs were unaffected. Python never hit this because uvicorn does not block reading a body for /validate. /validate authenticates from headers/cookies only and never reads the body, so reset r.Body to http.NoBody and zero Content-Length at the top of the handler. Fixes the 'Failed to generate token' 500 with the sidecar inline. Refs #1652 --- go-validate/main.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/go-validate/main.go b/go-validate/main.go index 2248900d4..003391272 100644 --- a/go-validate/main.go +++ b/go-validate/main.go @@ -120,6 +120,21 @@ func mapClaims(c *Claims) identity { // with identity headers, 401 for a recognized-invalid token, or fall back to // Python for anything unrecognized (cookies, other IdPs, opaque tokens). func (s *server) handleValidate(w http.ResponseWriter, r *http.Request) { + // nginx's auth_request subrequest never carries a usable body, but for a + // POST/PUT/PATCH origin request nginx forwards the original Content-Length + // with NO body. httputil.ReverseProxy would then block copying that phantom + // body to the auth-server, hanging the subrequest until nginx times out + // (504 -> auth_request collapses it to 500). Every mutating request (e.g. + // POST /api/tokens/generate) hit this; GETs did not. Neutralize the body so + // both the fast path and the fallback operate on headers only. /validate + // reads identity from headers/cookies, never from the body. + if r.Body != nil { + _ = r.Body.Close() + } + r.Body = http.NoBody + r.ContentLength = 0 + r.Header.Del("Content-Length") + // NOTE: do NOT strip request headers before falling back. nginx sets // legitimate inputs on the /validate subrequest (e.g. X-Client-Id from // $http_x_client_id, X-Original-URL, X-Registry-Api-Auth). The fallback must From 5845e9f81305bdd58a8eb766e12d80a55113a57b Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Tue, 18 Aug 2026 20:11:06 +0000 Subject: [PATCH 06/26] test(go-validate): regression + unit tests - TestHandleValidate_DoesNotBlockOnPhantomBody: reproduces the gateway 500 (a /validate subrequest with a declared Content-Length but a body that never arrives must not hang the fallback proxy). Fails without the http.NoBody fix. - Unit tests for extractBearer precedence + A2A rule, canonicalAuthMethod, serverNameFromOriginalURL, validateSecretKey (weak/strong), recordEnabled (fail-closed), scope snapshot group->scope mapping, and the scope resolver's A/B/C cases incl. the user-generated sentinel. Refs #1652 --- go-validate/main_test.go | 200 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 go-validate/main_test.go diff --git a/go-validate/main_test.go b/go-validate/main_test.go new file mode 100644 index 000000000..7da401113 --- /dev/null +++ b/go-validate/main_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "testing" + "time" +) + +// blockingReader blocks forever on Read until unblocked. It stands in for the +// phantom body nginx declares (Content-Length) but never sends on an +// auth_request subrequest for a POST/PUT/PATCH origin request. +type blockingReader struct{ ch chan struct{} } + +func (b blockingReader) Read(p []byte) (int, error) { <-b.ch; return 0, nil } +func (b blockingReader) Close() error { return nil } + +// TestHandleValidate_DoesNotBlockOnPhantomBody is the regression test for the +// gateway 500: a /validate subrequest that declares Content-Length but whose +// body never arrives must be answered from headers alone, not hang while the +// fallback proxy waits to copy a body that never comes. +func TestHandleValidate_DoesNotBlockOnPhantomBody(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-User", "alice") + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + target, _ := url.Parse(backend.URL) + + // FastPathReady=false -> handler goes straight to the fallback proxy, which is + // exactly where the phantom body used to block. + s := &server{ + cfg: Config{FastPathReady: false}, + fallback: httputil.NewSingleHostReverseProxy(target), + } + + body := blockingReader{ch: make(chan struct{})} // never unblocked + req := httptest.NewRequest(http.MethodGet, "/validate", body) + req.ContentLength = 40 + req.Header.Set("Content-Length", "40") + req.Header.Set("X-Original-Method", "POST") + rr := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + s.handleValidate(rr, req) + close(done) + }() + + select { + case <-done: + if rr.Code != http.StatusOK { + t.Fatalf("want 200 from fallback, got %d", rr.Code) + } + case <-time.After(3 * time.Second): + t.Fatal("handleValidate blocked on a phantom request body (regression: #1652)") + } +} + +func TestExtractBearer_Precedence(t *testing.T) { + tests := []struct { + name string + xAuth string + auth string + origURL string + wantTok string + wantOK bool + }{ + {"x-authorization wins", "Bearer AAA", "Bearer BBB", "http://h/api/x", "AAA", true}, + {"authorization fallback", "", "Bearer BBB", "http://h/api/x", "BBB", true}, + {"agent path no fallback to Authorization", "", "Bearer BBB", "http://h/agent/foo", "", false}, + {"agent path uses x-authorization", "Bearer AAA", "Bearer BBB", "http://h/agent/foo", "AAA", true}, + {"no creds", "", "", "http://h/api/x", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/validate", nil) + if tc.xAuth != "" { + r.Header.Set("X-Authorization", tc.xAuth) + } + if tc.auth != "" { + r.Header.Set("Authorization", tc.auth) + } + r.Header.Set("X-Original-URL", tc.origURL) + tok, ok := extractBearer(r) + if tok != tc.wantTok || ok != tc.wantOK { + t.Fatalf("got (%q,%v) want (%q,%v)", tok, ok, tc.wantTok, tc.wantOK) + } + }) + } +} + +func TestCanonicalAuthMethod(t *testing.T) { + for _, m := range []string{"keycloak", "cognito", "entra", "okta", "auth0", "pingfederate", "self_signed", "session_cookie", "jwt", "boto3"} { + if got := canonicalAuthMethod(m); got != "oauth2" { + t.Errorf("canonicalAuthMethod(%q)=%q, want oauth2", m, got) + } + } + for _, m := range []string{"federation-static", "network-trusted", "weird"} { + if got := canonicalAuthMethod(m); got != m { + t.Errorf("canonicalAuthMethod(%q)=%q, want passthrough", m, got) + } + } +} + +func TestServerNameFromOriginalURL(t *testing.T) { + cases := map[string]string{ + "http://localhost/currenttime/mcp": "currenttime", + "http://localhost/api/tokens/generate": "", + "http://localhost/api": "", + "http://localhost/": "", + "http://localhost/onlyserver": "onlyserver", + "": "", + } + for in, want := range cases { + if got := serverNameFromOriginalURL(in); got != want { + t.Errorf("serverNameFromOriginalURL(%q)=%q, want %q", in, got, want) + } + } +} + +func TestValidateSecretKey(t *testing.T) { + if validateSecretKey("this-is-a-strong-32char-secret-value!!") != "" { + t.Error("strong key should pass") + } + for _, weak := range []string{"", " ", "secret", "changeme", "short"} { + if validateSecretKey(weak) == "" { + t.Errorf("weak/empty key %q should be rejected", weak) + } + } +} + +func TestRecordEnabled_FailClosed(t *testing.T) { + if !recordEnabled(nil) { + t.Error("absent enabled -> active (backward compat)") + } + if !recordEnabled(true) { + t.Error("enabled=true -> active") + } + for _, v := range []any{false, "true", "false", 0, 1, ""} { + if recordEnabled(v) { + t.Errorf("enabled=%v (non-true) must be treated as disabled", v) + } + } +} + +func TestScopeSnapshot_MapGroupsToScopes(t *testing.T) { + snap := &scopeSnapshot{ + scopes: []scopeDoc{ + {ID: "registry-admins", GroupMappings: []string{"admins"}}, + {ID: "mcp-servers-unrestricted/read", GroupMappings: []string{"admins", "users"}}, + {ID: "mcp-servers-unrestricted/execute", GroupMappings: []string{"admins"}}, + {ID: "unrelated", GroupMappings: []string{"nobody"}}, + }, + } + got := snap.mapGroupsToScopes([]string{"admins"}) + want := []string{"registry-admins", "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("order mismatch: got %v want %v", got, want) + } + } + if len(snap.mapGroupsToScopes(nil)) != 0 { + t.Error("empty groups -> no scopes") + } +} + +func TestScopeResolver_Resolve(t *testing.T) { + snap := &scopeSnapshot{ + scopes: []scopeDoc{ + {ID: "s-read", GroupMappings: []string{"g1"}}, + {ID: "s-admin", GroupMappings: []string{"m2m-grp"}}, + }, + m2mGroups: map[string][]string{"svc-client": {"m2m-grp"}}, + } + r := &scopeResolver{} + r.snap.Store(snap) + + // Case A: token has groups -> map directly. + if got, ok := r.resolve([]string{"g1"}, "anyclient"); !ok || len(got) != 1 || got[0] != "s-read" { + t.Fatalf("case A got (%v,%v)", got, ok) + } + // Case B: empty groups + known M2M client -> enrich then map. + if got, ok := r.resolve(nil, "svc-client"); !ok || len(got) != 1 || got[0] != "s-admin" { + t.Fatalf("case B got (%v,%v)", got, ok) + } + // Case C: empty groups + unknown client -> fall back to Python. + if _, ok := r.resolve(nil, "unknown"); ok { + t.Fatal("case C: unknown client must not resolve (fallback)") + } + // user-generated sentinel is never treated as M2M. + if _, ok := r.resolve(nil, userGeneratedClientID); ok { + t.Fatal("user-generated sentinel must not resolve as M2M") + } +} From 159e14fd59112f39d91f8b5a20162f643460531a Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 00:10:52 +0000 Subject: [PATCH 07/26] test(bench): add pingmcp fast MCP upstream for end-to-end /validate load tests Adds a tiny, fast Go streamable-http MCP server (single 'echo' tool) as an opt-in benchmark upstream so end-to-end gateway load tests are bounded by the /validate auth check, not by a slow upstream (a heavy app endpoint hides the auth-check cost; a fast MCP server exposes it). - servers/pingmcp/: vendored source (canonical repo: https://github.com/aarora79/pingmcp) - docker-compose.yml: pingmcp-server service behind the 'benchmark' profile (does not start by default); reached in-cluster as pingmcp-server:8100. - cli/examples/pingmcp.json: registration config. Registering it also requires adding pingmcp-server to SSRF_ALLOWED_HOSTS. Refs #1652 --- cli/examples/pingmcp.json | 12 +++ docker-compose.yml | 18 ++++ servers/pingmcp/Dockerfile | 11 +++ servers/pingmcp/README.md | 17 ++++ servers/pingmcp/go.mod | 3 + servers/pingmcp/main.go | 189 +++++++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+) create mode 100644 cli/examples/pingmcp.json create mode 100644 servers/pingmcp/Dockerfile create mode 100644 servers/pingmcp/README.md create mode 100644 servers/pingmcp/go.mod create mode 100644 servers/pingmcp/main.go diff --git a/cli/examples/pingmcp.json b/cli/examples/pingmcp.json new file mode 100644 index 000000000..3be4c90e2 --- /dev/null +++ b/cli/examples/pingmcp.json @@ -0,0 +1,12 @@ +{ + "server_name": "Ping MCP (Go)", + "description": "Minimal, fast streamable-http MCP server written in Go with a single 'echo' tool. Used as a fast upstream so end-to-end gateway load tests are bounded by the /validate auth check, not by the upstream.", + "path": "/pingmcp/", + "proxy_pass_url": "http://pingmcp-server:8100/", + "auth_scheme": "none", + "tags": ["test", "benchmark", "echo", "go"], + "num_tools": 1, + "license": "MIT-0", + "status": "active", + "visibility": "public" +} diff --git a/docker-compose.yml b/docker-compose.yml index 3fb5fb10b..91e13d5b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -860,6 +860,24 @@ services: - "${HOST_BIND_IP:-127.0.0.1}:8000:8000" restart: unless-stopped + # Benchmark MCP server (opt-in): a tiny, fast Go streamable-http server exposing a + # single `echo` tool. Used as a FAST upstream for end-to-end /validate load tests so + # the measurement is bounded by the gateway, not the upstream (issue #1652, PR #1653). + # Canonical source: https://github.com/aarora79/pingmcp + # Opt-in only (does not start by default): `docker compose --profile benchmark up -d pingmcp-server`. + # To register + health-check it, add "pingmcp-server" to SSRF_ALLOWED_HOSTS. + pingmcp-server: + profiles: ["benchmark"] + build: + context: servers/pingmcp + dockerfile: Dockerfile + environment: + - PORT=8100 + ports: + # Loopback-only host publish for direct testing; reached in-cluster as pingmcp-server:8100. + - "${HOST_BIND_IP:-127.0.0.1}:8100:8100" + restart: unless-stopped + # MCP Gateway Server mcpgw-server: build: diff --git a/servers/pingmcp/Dockerfile b/servers/pingmcp/Dockerfile new file mode 100644 index 000000000..f30040852 --- /dev/null +++ b/servers/pingmcp/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/pingmcp . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/pingmcp /pingmcp +EXPOSE 8100 +USER nonroot:nonroot +ENTRYPOINT ["/pingmcp"] diff --git a/servers/pingmcp/README.md b/servers/pingmcp/README.md new file mode 100644 index 000000000..4df30ed8d --- /dev/null +++ b/servers/pingmcp/README.md @@ -0,0 +1,17 @@ +# pingmcp (vendored) + +A tiny, fast Go streamable-http MCP server with a single `echo` tool. Used here as a +**fast upstream** for end-to-end load testing of the gateway's per-request auth check +(`/validate`), so the measurement is bounded by the gateway, not the upstream (issue #1652). + +Canonical, standalone source: **https://github.com/aarora79/pingmcp** +This copy is vendored so the `pingmcp-server` docker-compose service can build locally. + +## Run in the stack (opt-in benchmark profile) + +```bash +docker compose --profile benchmark up -d pingmcp-server +# add pingmcp-server to SSRF_ALLOWED_HOSTS in .env so the registry health check allows it +uv run python api/registry_management.py --registry-url http://localhost --token-file .token \ + register --config cli/examples/pingmcp.json --overwrite +``` diff --git a/servers/pingmcp/go.mod b/servers/pingmcp/go.mod new file mode 100644 index 000000000..2d305f6b6 --- /dev/null +++ b/servers/pingmcp/go.mod @@ -0,0 +1,3 @@ +module pingmcp + +go 1.24 diff --git a/servers/pingmcp/main.go b/servers/pingmcp/main.go new file mode 100644 index 000000000..811a62a72 --- /dev/null +++ b/servers/pingmcp/main.go @@ -0,0 +1,189 @@ +// Command pingmcp is a tiny, fast MCP server that speaks the streamable-http +// transport and exposes a single `echo` tool. It exists to be a FAST upstream +// behind the gateway so an end-to-end load test is bounded by the /validate +// auth check (Python vs the Go sidecar), not by the upstream. +// +// Design notes: stdlib only. It answers each JSON-RPC POST with a single +// application/json response (the streamable-http spec permits this for a POST +// carrying one request), which keeps it trivial and extremely fast. +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "io" + "log" + "net/http" + "os" +) + +const ( + serverName = "pingmcp" + serverVersion = "1.0.0" + defaultProtoc = "2025-06-18" + sessionHeader = "Mcp-Session-Id" +) + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` // absent/null => notification + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// newSessionID returns a random opaque session id for the initialize response. +func newSessionID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// writeResult sends a JSON-RPC success response. +func writeResult(w http.ResponseWriter, id json.RawMessage, result any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": rawOrNull(id), + "result": result, + }) +} + +// writeError sends a JSON-RPC error response. +func writeError(w http.ResponseWriter, id json.RawMessage, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": rawOrNull(id), + "error": rpcError{Code: code, Message: msg}, + }) +} + +func rawOrNull(id json.RawMessage) any { + if len(id) == 0 { + return nil + } + return id +} + +// toolsList is the static tool catalog (one tool: echo). +func toolsList() any { + return map[string]any{ + "tools": []any{ + map[string]any{ + "name": "echo", + "description": "Echo back the provided message. Minimal tool for load testing.", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{ + "type": "string", + "description": "Text to echo back", + }, + }, + "required": []string{"message"}, + }, + }, + }, + } +} + +// callEcho handles tools/call for the echo tool. +func callEcho(params json.RawMessage) any { + var p struct { + Name string `json:"name"` + Arguments struct { + Message string `json:"message"` + } `json:"arguments"` + } + _ = json.Unmarshal(params, &p) + msg := p.Arguments.Message + if msg == "" { + msg = "pong" + } + return map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": msg}, + }, + "isError": false, + } +} + +// handleMCP is the streamable-http endpoint: one JSON-RPC request per POST. +func handleMCP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + // This server does not offer a standalone SSE stream; per the + // streamable-http spec a server MAY reject GET. Clients that only POST + // (and read the direct JSON response) work fine. + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeError(w, nil, -32700, "parse error") + return + } + var req rpcRequest + if err := json.Unmarshal(body, &req); err != nil { + writeError(w, nil, -32700, "parse error") + return + } + + switch req.Method { + case "initialize": + // Echo the client's requested protocol version when present. + proto := defaultProtoc + var p struct { + ProtocolVersion string `json:"protocolVersion"` + } + if json.Unmarshal(req.Params, &p) == nil && p.ProtocolVersion != "" { + proto = p.ProtocolVersion + } + w.Header().Set(sessionHeader, newSessionID()) + writeResult(w, req.ID, map[string]any{ + "protocolVersion": proto, + "capabilities": map[string]any{"tools": map[string]any{"listChanged": false}}, + "serverInfo": map[string]any{"name": serverName, "version": serverVersion}, + }) + case "notifications/initialized": + // A notification: acknowledge with 202 and no body. + w.WriteHeader(http.StatusAccepted) + case "ping": + writeResult(w, req.ID, map[string]any{}) + case "tools/list": + writeResult(w, req.ID, toolsList()) + case "tools/call": + writeResult(w, req.ID, callEcho(req.Params)) + default: + writeError(w, req.ID, -32601, "method not found: "+req.Method) + } +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8100" + } + mux := http.NewServeMux() + // The gateway proxies /pingmcp/ -> http://pingmcp-server:PORT/, so the + // MCP endpoint arrives as /mcp. Handle /mcp, /mcp/, and / for robustness. + mux.HandleFunc("/mcp", handleMCP) + mux.HandleFunc("/mcp/", handleMCP) + mux.HandleFunc("/", handleMCP) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + }) + addr := ":" + port + log.Printf("pingmcp listening on %s (streamable-http, tool=echo)", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} From 75567e55429c5873c17f7844dc5c577eea98b46a Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 00:49:57 +0000 Subject: [PATCH 08/26] feat(go-validate): wire the sidecar into ECS (Terraform), EKS (Helm), and CI Productionizes the go-validate /validate fast-path sidecar across all deployment surfaces. OPT-IN everywhere and non-breaking: when disabled (the default), VALIDATE_UPSTREAM_URL is unset so nginx /validate stays on the Python auth-server exactly as before, and no sidecar container is created. Terraform/ECS (terraform/aws-ecs): - go_validate_enabled / go_validate_image_uri / go_validate_audience / validate_upstream_url vars (root + module, wired via main.tf). - go-validate as a sidecar container in the auth-server task (opt-in), sharing the task namespace (AUTH_FALLBACK_URL=localhost:18888); reuses SECRET_KEY, nginx marker, and DocumentDB creds/secrets; JWKS/issuer derived from Keycloak. - Service Connect alias + SG ingress on 8899; registry VALIDATE_UPSTREAM_URL env. - terraform validate passes. Helm/EKS (charts): - goValidate sidecar container + Service port 8899 on the auth-server chart (guarded by goValidate.enabled), reusing the same secretRefs. - registry VALIDATE_UPSTREAM_URL secret (only when app.validateUpstreamUrl set, so env[] indices don't shift); reserved-env-names updated; stack guidance. - New helm-unittest suites (sidecar on/off, service port, secret emission); full suite 193 tests pass. CI/ECR: - buildspec.yml + build-and-push-all.sh + build-config.yaml + release-images.yml build and push mcp-gateway-go-validate (context go-validate/). Refs #1652 --- .github/workflows/release-images.yml | 8 +- build-config.yaml | 11 +++ buildspec.yml | 3 +- charts/auth-server/templates/deployment.yaml | 44 ++++++++++ charts/auth-server/templates/service.yaml | 6 ++ .../auth-server/tests/go_validate_test.yaml | 64 ++++++++++++++ charts/auth-server/values.yaml | 20 +++++ charts/mcp-gateway-registry-stack/values.yaml | 12 +++ charts/registry/reserved-env-names.txt | 1 + charts/registry/templates/secret.yaml | 3 + .../tests/validate_upstream_test.yaml | 26 ++++++ charts/registry/values.yaml | 4 + terraform/aws-ecs/build-and-push-all.sh | 1 + terraform/aws-ecs/main.tf | 4 + .../modules/mcp-gateway/ecs-services.tf | 83 ++++++++++++++++++- .../aws-ecs/modules/mcp-gateway/variables.tf | 28 +++++++ terraform/aws-ecs/terraform.tfvars.example | 14 ++++ terraform/aws-ecs/variables.tf | 25 ++++++ 18 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 charts/auth-server/tests/go_validate_test.yaml create mode 100644 charts/registry/tests/validate_upstream_test.yaml diff --git a/.github/workflows/release-images.yml b/.github/workflows/release-images.yml index b0525c972..8e4087e55 100644 --- a/.github/workflows/release-images.yml +++ b/.github/workflows/release-images.yml @@ -37,12 +37,18 @@ jobs: include: - service: auth-server dockerfile: docker/Dockerfile.auth + context: . - service: registry dockerfile: docker/Dockerfile.registry + context: . - service: mcpgw dockerfile: docker/Dockerfile.mcp-server + context: . extra_build_args: |- SERVER_DIR=servers/mcpgw + - service: go-validate + dockerfile: go-validate/Dockerfile + context: go-validate steps: - name: Checkout code @@ -86,7 +92,7 @@ jobs: id: push uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: - context: . + context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} push: true platforms: linux/amd64,linux/arm64 diff --git a/build-config.yaml b/build-config.yaml index be5fff74a..c74b7c3f0 100644 --- a/build-config.yaml +++ b/build-config.yaml @@ -42,6 +42,17 @@ images: - latest build_args: {} + # Go /validate fast-path sidecar (issue #1652). Deployed as a sidecar in the + # auth-server task/pod; opt-in. + go_validate: + repo_name: "mcp-gateway-go-validate" + dockerfile: "go-validate/Dockerfile" + context: "go-validate" + description: "Go fast-path sidecar for the auth /validate hot path" + tags: + - latest + build_args: {} + # Keycloak Identity Provider keycloak: repo_name: "keycloak" diff --git a/buildspec.yml b/buildspec.yml index cb653fdea..6c752b33f 100644 --- a/buildspec.yml +++ b/buildspec.yml @@ -21,7 +21,7 @@ phases: - docker pull quay.io/keycloak/keycloak:23.0 || true - docker pull grafana/grafana:12.3.1 || true - echo "Pulling existing images for cache..." - - for repo in mcp-gateway-registry mcp-gateway-auth-server keycloak mcp-gateway-currenttime mcp-gateway-mcpgw mcp-gateway-realserverfaketools mcp-gateway-flight-booking-agent mcp-gateway-travel-assistant-agent mcp-gateway-metrics-service mcp-gateway-grafana; do docker pull $ECR_REGISTRY/$repo:latest 2>/dev/null || true; done + - for repo in mcp-gateway-registry mcp-gateway-auth-server keycloak mcp-gateway-currenttime mcp-gateway-mcpgw mcp-gateway-realserverfaketools mcp-gateway-flight-booking-agent mcp-gateway-travel-assistant-agent mcp-gateway-metrics-service mcp-gateway-grafana mcp-gateway-go-validate; do docker pull $ECR_REGISTRY/$repo:latest 2>/dev/null || true; done - echo "Setting up A2A agent dependencies..." - mkdir -p agents/a2a/src/flight-booking-agent/.tmp agents/a2a/src/travel-assistant-agent/.tmp - cp agents/a2a/pyproject.toml agents/a2a/uv.lock agents/a2a/src/flight-booking-agent/.tmp/ 2>/dev/null || true @@ -52,6 +52,7 @@ phases: # Core services build_and_push mcp-gateway-registry docker/Dockerfile.registry-cpu . & build_and_push mcp-gateway-auth-server docker/Dockerfile.auth . & + build_and_push mcp-gateway-go-validate go-validate/Dockerfile go-validate & # MCP servers build_and_push mcp-gateway-currenttime docker/Dockerfile.mcp-server servers/currenttime & diff --git a/charts/auth-server/templates/deployment.yaml b/charts/auth-server/templates/deployment.yaml index ef5ac618e..014e12a29 100644 --- a/charts/auth-server/templates/deployment.yaml +++ b/charts/auth-server/templates/deployment.yaml @@ -247,3 +247,47 @@ spec: periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 + + {{- if .Values.goValidate.enabled }} + # Go /validate fast-path sidecar (issue #1652). Shares the pod network with + # auth-server, so AUTH_FALLBACK_URL is localhost:8888. Reuses the same + # secrets (SECRET_KEY, DOCUMENTDB_*, AUTH_SERVER_NGINX_MARKER_SECRET). + - name: go-validate + image: "{{ $imgRegistry }}/{{ default "go-validate" .Values.goValidate.image.repository }}:{{ $imgTag }}" + imagePullPolicy: {{ $imgPullPolicy }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + ports: + - containerPort: 8899 + name: govalidate + envFrom: + - secretRef: + name: {{ .Values.app.existingSecret | default .Values.app.envSecretName }} + - secretRef: + name: {{ .Values.global.existingMongoCredentialsSecret | default "mongo-credentials" }} + {{- if .Values.global.sharedSecretName }} + - secretRef: + name: {{ .Values.global.existingSharedSecret | default .Values.global.sharedSecretName }} + {{- end }} + {{- if .Values.global.oauthProviderSecretName }} + - secretRef: + name: {{ .Values.global.existingOauthProviderSecret | default .Values.global.oauthProviderSecretName }} + {{- end }} + env: + - name: GOVALIDATE_LISTEN + value: ":8899" + - name: AUTH_FALLBACK_URL + value: "http://localhost:8888" + - name: JWKS_URL + value: {{ .Values.goValidate.jwksUrl | quote }} + - name: VALIDATE_ISSUER + value: {{ .Values.goValidate.issuer | quote }} + - name: VALIDATE_AUDIENCE + value: {{ .Values.goValidate.audience | quote }} + resources: + {{- toYaml .Values.goValidate.resources | nindent 12 }} + {{- end }} diff --git a/charts/auth-server/templates/service.yaml b/charts/auth-server/templates/service.yaml index b8e5b76d6..c0cce0349 100644 --- a/charts/auth-server/templates/service.yaml +++ b/charts/auth-server/templates/service.yaml @@ -14,6 +14,12 @@ spec: targetPort: http protocol: TCP name: http + {{- if .Values.goValidate.enabled }} + - port: 8899 + targetPort: govalidate + protocol: TCP + name: govalidate + {{- end }} selector: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} diff --git a/charts/auth-server/tests/go_validate_test.yaml b/charts/auth-server/tests/go_validate_test.yaml new file mode 100644 index 000000000..70423f6e2 --- /dev/null +++ b/charts/auth-server/tests/go_validate_test.yaml @@ -0,0 +1,64 @@ +suite: go-validate sidecar +templates: + - templates/deployment.yaml + - templates/service.yaml +tests: + - it: no go-validate sidecar container by default + template: templates/deployment.yaml + set: + app: + secretKey: test-key-32-bytes-aaaaaaaaaaaaaaaaaa + asserts: + - lengthEqual: + path: spec.template.spec.containers + count: 1 + + - it: adds the go-validate sidecar container when enabled + template: templates/deployment.yaml + set: + app: + secretKey: test-key-32-bytes-aaaaaaaaaaaaaaaaaa + goValidate: + enabled: true + audience: account + asserts: + - lengthEqual: + path: spec.template.spec.containers + count: 2 + - equal: + path: spec.template.spec.containers[1].name + value: go-validate + - equal: + path: spec.template.spec.containers[1].ports[0].containerPort + value: 8899 + - contains: + path: spec.template.spec.containers[1].env + content: + name: GOVALIDATE_LISTEN + value: ":8899" + - contains: + path: spec.template.spec.containers[1].env + content: + name: AUTH_FALLBACK_URL + value: "http://localhost:8888" + + - it: no extra Service port by default + template: templates/service.yaml + asserts: + - lengthEqual: + path: spec.ports + count: 1 + + - it: exposes port 8899 on the Service when enabled + template: templates/service.yaml + set: + goValidate: + enabled: true + asserts: + - contains: + path: spec.ports + content: + port: 8899 + targetPort: govalidate + protocol: TCP + name: govalidate diff --git a/charts/auth-server/values.yaml b/charts/auth-server/values.yaml index d50dd07c2..a86a8ccc6 100644 --- a/charts/auth-server/values.yaml +++ b/charts/auth-server/values.yaml @@ -12,6 +12,26 @@ image: repository: auth-server # Image name within the registry tag: "" # Overrides global.image.tag when set pullPolicy: "" # Overrides global.image.pullPolicy when set + +# Go /validate fast-path sidecar (issue #1652). OPT-IN: when enabled, a tiny Go +# container runs alongside auth-server and the registry routes nginx /validate to +# it. Leave disabled to keep /validate on the Python auth-server (unchanged). +# Fast path engages only when jwksUrl + issuer + audience are all set; otherwise +# it transparently proxies to the auth-server (safe fallback). +goValidate: + enabled: false + image: + repository: go-validate # image name within the registry (shares global registry/tag) + jwksUrl: "" # e.g. https://keycloak.example.com/realms/mcp-gateway/protocol/openid-connect/certs + issuer: "" # e.g. https://keycloak.example.com/realms/mcp-gateway + audience: "" # the aud claim your tokens carry, e.g. "account" + resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 250m + memory: 64Mi # Application configuration # ServiceAccount the auth-server pod runs as. Created by default so roles can be # attached to a stable identity. Set create=false + name to bring your own. diff --git a/charts/mcp-gateway-registry-stack/values.yaml b/charts/mcp-gateway-registry-stack/values.yaml index 04fcd11c4..7d9af7e91 100644 --- a/charts/mcp-gateway-registry-stack/values.yaml +++ b/charts/mcp-gateway-registry-stack/values.yaml @@ -296,6 +296,9 @@ mongodb-configure: # Registry service configuration registry: app: + # Point nginx /validate at the go-validate sidecar (must match the auth-server + # goValidate.enabled switch above). Empty -> auth-server (unchanged). (issue #1652) + # validateUpstreamUrl: "http://auth-server..svc.cluster.local:8899" replicas: 2 # set to > 1 replica for high availability # Session cookie Domain attribute. Leave empty to default to # "." (cookie valid across all subdomains of the @@ -545,6 +548,15 @@ mcpgw: extraEnvFrom: [] # Auth server configuration auth-server: + # Go /validate fast-path sidecar (issue #1652). OPT-IN. To enable end-to-end: + # 1. set goValidate.enabled: true (+ jwksUrl/issuer/audience for the fast path) + # 2. set registry.app.validateUpstreamUrl below so nginx routes /validate to it. + # Left disabled, /validate stays on the Python auth-server (unchanged). + goValidate: + enabled: false + # jwksUrl: "https:///realms/mcp-gateway/protocol/openid-connect/certs" + # issuer: "https:///realms/mcp-gateway" + # audience: "account" app: replicas: 2 # set to > 1 replica for high availability # Session cookie Domain attribute. See registry.app.sessionCookieDomain diff --git a/charts/registry/reserved-env-names.txt b/charts/registry/reserved-env-names.txt index e1710cdfd..3a9d39455 100644 --- a/charts/registry/reserved-env-names.txt +++ b/charts/registry/reserved-env-names.txt @@ -72,6 +72,7 @@ ANS_VERIFICATION_CACHE_TTL_SECONDS AUTH_PROVIDER AUTH_SERVER_EXTERNAL_URL AUTH_SERVER_URL +VALIDATE_UPSTREAM_URL AWS_REGION COGNITO_CLIENT_ID COGNITO_CLIENT_SECRET diff --git a/charts/registry/templates/secret.yaml b/charts/registry/templates/secret.yaml index fdb75d902..f2872eafc 100644 --- a/charts/registry/templates/secret.yaml +++ b/charts/registry/templates/secret.yaml @@ -116,6 +116,9 @@ metadata: data: AUTH_SERVER_EXTERNAL_URL: {{ $authServerExternalUrl | b64enc | quote }} AUTH_SERVER_URL: {{ printf "http://auth-server.%s.svc.cluster.local:8888" .Release.Namespace | b64enc | quote }} + {{- if .Values.app.validateUpstreamUrl }} + VALIDATE_UPSTREAM_URL: {{ .Values.app.validateUpstreamUrl | b64enc | quote }} + {{- end }} {{- if eq (.Values.global.authProvider.type | default "keycloak") "keycloak" }} KEYCLOAK_ADMIN: {{ (.Values.global.authProvider.keycloak.adminUsername | default "user") | b64enc | quote }} {{- end }} diff --git a/charts/registry/tests/validate_upstream_test.yaml b/charts/registry/tests/validate_upstream_test.yaml new file mode 100644 index 000000000..047000be2 --- /dev/null +++ b/charts/registry/tests/validate_upstream_test.yaml @@ -0,0 +1,26 @@ +suite: registry VALIDATE_UPSTREAM_URL +templates: + - templates/secret.yaml +tests: + - it: omits VALIDATE_UPSTREAM_URL by default (nginx /validate stays on auth-server) + template: templates/secret.yaml + set: + app: + secretKey: "test-key-not-for-prod" + egressAuth: + markerSecret: "test-marker-not-for-prod" + asserts: + - isNull: + path: data.VALIDATE_UPSTREAM_URL + + - it: emits VALIDATE_UPSTREAM_URL when app.validateUpstreamUrl is set + template: templates/secret.yaml + set: + app: + secretKey: "test-key-not-for-prod" + validateUpstreamUrl: http://auth-server.default.svc.cluster.local:8899 + egressAuth: + markerSecret: "test-marker-not-for-prod" + asserts: + - isNotNullOrEmpty: + path: data.VALIDATE_UPSTREAM_URL diff --git a/charts/registry/values.yaml b/charts/registry/values.yaml index 3bdbb566e..8491b8cf2 100644 --- a/charts/registry/values.yaml +++ b/charts/registry/values.yaml @@ -49,6 +49,10 @@ app: replicas: 2 envSecretName: registry-secret existingSecret: "" # If set, use this existing secret instead of creating one + # nginx /validate upstream. Empty -> auth-server (default). Set to + # http://auth-server..svc.cluster.local:8899 when the go-validate sidecar + # is enabled on the auth-server chart. (issue #1652) + validateUpstreamUrl: "" # Security settings # secretKey: If not provided, a random 64-character key is auto-generated. # When deployed via mcp-gateway-registry-stack, the key is shared with auth-server. diff --git a/terraform/aws-ecs/build-and-push-all.sh b/terraform/aws-ecs/build-and-push-all.sh index 9ea1a3ff9..67810617e 100755 --- a/terraform/aws-ecs/build-and-push-all.sh +++ b/terraform/aws-ecs/build-and-push-all.sh @@ -23,6 +23,7 @@ aws ecr get-login-password --region "${REGION}" | docker login --username AWS -- IMAGES=( "mcp-gateway-registry|docker/Dockerfile.registry|." "mcp-gateway-auth-server|docker/Dockerfile.auth|." + "mcp-gateway-go-validate|go-validate/Dockerfile|go-validate" "mcp-gateway-currenttime|docker/Dockerfile.mcp-server|servers/currenttime" "mcp-gateway-mcpgw|docker/Dockerfile.mcp-server|servers/mcpgw" "mcp-gateway-realserverfaketools|docker/Dockerfile.mcp-server|servers/realserverfaketools" diff --git a/terraform/aws-ecs/main.tf b/terraform/aws-ecs/main.tf index c79d6d33b..c3170e2e2 100755 --- a/terraform/aws-ecs/main.tf +++ b/terraform/aws-ecs/main.tf @@ -75,6 +75,10 @@ module "mcp_gateway" { # Container images (core services default to public ECR) registry_image_uri = var.registry_image_uri auth_server_image_uri = var.auth_server_image_uri + go_validate_image_uri = var.go_validate_image_uri + go_validate_enabled = var.go_validate_enabled + go_validate_audience = var.go_validate_audience + validate_upstream_url = var.validate_upstream_url mcpgw_image_uri = var.mcpgw_image_uri # Demo servers (disabled by default) diff --git a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf index 68a0b48b6..4c11a2942 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf @@ -66,21 +66,28 @@ module "ecs_service_auth" { # Enable Service Connect service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn - service = [{ + service = concat([{ client_alias = { port = 8888 dns_name = "auth-server" } port_name = "auth-server" discovery_name = "auth-server" - }] + }], var.go_validate_enabled ? [{ + client_alias = { + port = 8899 + dns_name = "go-validate" + } + port_name = "go-validate" + discovery_name = "go-validate" + }] : []) } # Container definitions container_definitions = merge({ auth-server = { - cpu = var.enable_observability ? tonumber(var.cpu) - local.adot_sidecar_cpu : tonumber(var.cpu) - memory = var.enable_observability ? tonumber(var.memory) - local.adot_sidecar_memory : tonumber(var.memory) + cpu = tonumber(var.cpu) - (var.enable_observability ? local.adot_sidecar_cpu : 0) - (var.go_validate_enabled ? 128 : 0) + memory = tonumber(var.memory) - (var.enable_observability ? local.adot_sidecar_memory : 0) - (var.go_validate_enabled ? 128 : 0) essential = true image = var.auth_server_image_uri versionConsistency = "disabled" @@ -715,6 +722,54 @@ module "ecs_service_auth" { cloudwatch_log_group_name = "/ecs/${local.name_prefix}-auth-adot" cloudwatch_log_group_retention_in_days = 30 + dependencies = [{ + containerName = "auth-server" + condition = "START" + }] + } + } : {}, + var.go_validate_enabled ? { + go-validate = { + cpu = 128 + memory = 128 + essential = false + image = var.go_validate_image_uri + versionConsistency = "disabled" + readonlyRootFilesystem = true + + portMappings = [{ + name = "go-validate" + containerPort = 8899 + protocol = "tcp" + }] + + # Fast path engages only when JWKS_URL + VALIDATE_ISSUER + VALIDATE_AUDIENCE + # are all set (and match the tokens); otherwise it reverse-proxies to the + # auth-server (same task, loopback), which is correct but not accelerated. + environment = [ + { name = "GOVALIDATE_LISTEN", value = ":8899" }, + { name = "AUTH_FALLBACK_URL", value = "http://localhost:18888" }, + { name = "JWKS_URL", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}/realms/mcp-gateway/protocol/openid-connect/certs" : "" }, + { name = "VALIDATE_ISSUER", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}/realms/mcp-gateway" : "" }, + { name = "VALIDATE_AUDIENCE", value = var.go_validate_audience }, + { name = "DOCUMENTDB_HOST", value = var.documentdb_endpoint }, + { name = "DOCUMENTDB_PORT", value = "27017" }, + { name = "DOCUMENTDB_DATABASE", value = var.documentdb_database }, + { name = "DOCUMENTDB_NAMESPACE", value = var.documentdb_namespace }, + { name = "DOCUMENTDB_USE_TLS", value = tostring(var.documentdb_use_tls) }, + ] + + secrets = [ + { name = "SECRET_KEY", valueFrom = aws_secretsmanager_secret.secret_key.arn }, + { name = "AUTH_SERVER_NGINX_MARKER_SECRET", valueFrom = aws_secretsmanager_secret.nginx_marker_secret.arn }, + { name = "DOCUMENTDB_USERNAME", valueFrom = "${var.documentdb_credentials_secret_arn}:username::" }, + { name = "DOCUMENTDB_PASSWORD", valueFrom = "${var.documentdb_credentials_secret_arn}:password::" }, + ] + + enable_cloudwatch_logging = true + cloudwatch_log_group_name = "/ecs/${local.name_prefix}-auth-govalidate" + cloudwatch_log_group_retention_in_days = 30 + dependencies = [{ containerName = "auth-server" condition = "START" @@ -899,6 +954,12 @@ module "ecs_service_registry" { name = "AUTH_SERVER_URL" value = var.auth_server_url }, + { + # nginx /validate upstream. Empty -> auth-server (default); set to the + # go-validate sidecar when enabled. Only /validate is affected. + name = "VALIDATE_UPSTREAM_URL" + value = var.validate_upstream_url + }, { name = "AUTH_SERVER_EXTERNAL_URL" value = var.domain_name != "" ? "https://${var.domain_name}" : "http://${module.alb.dns_name}" @@ -2096,6 +2157,20 @@ resource "aws_vpc_security_group_ingress_rule" "registry_to_auth" { tags = local.common_tags } +# Allow registry to reach the go-validate sidecar (8899) in the auth task, only +# when the sidecar is enabled. Service Connect proxy-to-proxy uses containerPort. +resource "aws_vpc_security_group_ingress_rule" "registry_to_auth_govalidate" { + count = var.go_validate_enabled ? 1 : 0 + security_group_id = module.ecs_service_auth.security_group_id + referenced_security_group_id = module.ecs_service_registry.security_group_id + from_port = 8899 + to_port = 8899 + ip_protocol = "tcp" + description = "Allow registry to access the go-validate /validate sidecar" + + tags = local.common_tags +} + # Allow auth server to communicate with mcpgw on port 8003 # Required for the mcp-proxy hop (PR #1026): auth server intercepts MCP diff --git a/terraform/aws-ecs/modules/mcp-gateway/variables.tf b/terraform/aws-ecs/modules/mcp-gateway/variables.tf index c6d36dbfd..c43f99400 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/variables.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/variables.tf @@ -1918,3 +1918,31 @@ variable "egress_secrets_manager_path_prefix" { type = string default = "mcp/egress" } + +# --------------------------------------------------------------------------- +# Go /validate fast-path sidecar (issue #1652). Opt-in; default off keeps +# nginx /validate pointed at the auth-server (unchanged behavior). +# --------------------------------------------------------------------------- +variable "go_validate_enabled" { + description = "Deploy the go-validate fast-path sidecar in the auth-server task and route the registry's nginx /validate to it. Default false: /validate stays on the auth-server (non-breaking)." + type = bool + default = false +} + +variable "go_validate_image_uri" { + description = "Container image URI for the go-validate sidecar." + type = string + default = "public.ecr.aws/p3v1o3c6/go-validate:latest" +} + +variable "go_validate_audience" { + description = "Expected token audience (aud) for the go-validate fast path. Empty leaves the sidecar in safe fallback-only mode (transparent proxy to Python)." + type = string + default = "" +} + +variable "validate_upstream_url" { + description = "nginx /validate upstream for the registry. Empty (default) resolves to the auth-server. Set to http://go-validate:8899 when go_validate_enabled = true." + type = string + default = "" +} diff --git a/terraform/aws-ecs/terraform.tfvars.example b/terraform/aws-ecs/terraform.tfvars.example index 989315cd3..6de506928 100755 --- a/terraform/aws-ecs/terraform.tfvars.example +++ b/terraform/aws-ecs/terraform.tfvars.example @@ -1366,3 +1366,17 @@ aws_registry_federation_enabled = true # set this only to pin a specific value. Avoid quotes/backslashes/$ (the registry # substitutes it verbatim into the generated nginx config). # egress_nginx_marker_secret = "" + +# --------------------------------------------------------------------------- +# Go /validate fast-path sidecar (issue #1652) - OPT-IN, default off. +# When enabled, a tiny Go sidecar runs in the auth-server task and the +# registry's nginx routes /validate to it (Keycloak RS256 bearer fast path); +# everything else falls back to the Python auth-server. Leaving this unset +# keeps /validate on the auth-server (unchanged, non-breaking). +# To enable: set go_validate_enabled = true AND +# validate_upstream_url = "http://go-validate:8899". +# --------------------------------------------------------------------------- +# go_validate_enabled = true +# go_validate_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-go-validate:latest" +# go_validate_audience = "account" # the aud claim your Keycloak tokens carry +# validate_upstream_url = "http://go-validate:8899" diff --git a/terraform/aws-ecs/variables.tf b/terraform/aws-ecs/variables.tf index 1fe4dd3e0..6a7fb1efd 100755 --- a/terraform/aws-ecs/variables.tf +++ b/terraform/aws-ecs/variables.tf @@ -2151,3 +2151,28 @@ variable "egress_secrets_manager_path_prefix" { type = string default = "mcp/egress" } + +# Go /validate fast-path sidecar (issue #1652) - opt-in; see modules/mcp-gateway. +variable "go_validate_enabled" { + description = "Deploy the go-validate fast-path sidecar in the auth-server task and route nginx /validate to it. Default false (unchanged behavior)." + type = bool + default = false +} + +variable "go_validate_image_uri" { + description = "Container image URI for the go-validate sidecar." + type = string + default = "public.ecr.aws/p3v1o3c6/go-validate:latest" +} + +variable "go_validate_audience" { + description = "Expected token audience for the go-validate fast path. Empty -> fallback-only (safe)." + type = string + default = "" +} + +variable "validate_upstream_url" { + description = "nginx /validate upstream for the registry. Empty -> auth-server. Set to http://go-validate:8899 when go_validate_enabled = true." + type = string + default = "" +} From caf4cbd847d24419875eb3106d2f794ed8fc80cf Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 00:55:02 +0000 Subject: [PATCH 09/26] test(go-validate,pingmcp): unit tests for crypto, minting, handler, MCP protocol go-validate (18.8% -> 55.6% stmt coverage; remainder is network/DB wiring exercised by the live parity runs): - verifyRS256 all branches (valid, bad sig/expired -> 401, unknown kid/wrong iss/wrong aud -> fallback, non-JWT); audContains string+array. - mintRegistryUIToken / mintMCPProxyToken round-trip (HMAC verified, claim shapes/audiences/token_use asserted); mintInternal empty-subject fail-closed. - parseKey (JWK->rsa.PublicKey); buildMongoURI (disabled + escaped creds). - full fast-path handler (verify->map->mint->headers) + recognized-invalid 401 + health/metrics. pingmcp (62.5%): initialize (+session header, protocol echo), tools/list, tools/call echo, ping, notifications/initialized 202, unknown-method error, GET 405. Refs #1652 --- go-validate/jwt_test.go | 216 +++++++++++++++++++++++++++++++++++ go-validate/main_test.go | 104 +++++++++++++++++ servers/pingmcp/main_test.go | 91 +++++++++++++++ 3 files changed, 411 insertions(+) create mode 100644 go-validate/jwt_test.go create mode 100644 servers/pingmcp/main_test.go diff --git a/go-validate/jwt_test.go b/go-validate/jwt_test.go new file mode 100644 index 000000000..c222533d6 --- /dev/null +++ b/go-validate/jwt_test.go @@ -0,0 +1,216 @@ +package main + +import ( + "crypto" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "math/big" + "testing" + "time" +) + +// --- helpers ------------------------------------------------------------- + +// mintRS256 signs a test RS256 JWT with priv, header kid, and the given claims. +func mintRS256(t *testing.T, priv *rsa.PrivateKey, kid string, claims map[string]any) string { + t.Helper() + hdr := map[string]string{"alg": "RS256", "typ": "JWT", "kid": kid} + hb, _ := json.Marshal(hdr) + cb, _ := json.Marshal(claims) + signingInput := b64urlEncode(hb) + "." + b64urlEncode(cb) + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, digest[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + return signingInput + "." + b64urlEncode(sig) +} + +// testKeyset builds a keysetCache holding one public key under kid. +func testKeyset(pub *rsa.PublicKey, kid string) *keysetCache { + ks := &keysetCache{} + m := map[string]*rsa.PublicKey{kid: pub} + ks.keys.Store(&m) + return ks +} + +const ( + tIss = "https://kc/realms/mcp-gateway" + tAud = "account" +) + +func baseClaims() map[string]any { + return map[string]any{ + "iss": tIss, "aud": tAud, "exp": time.Now().Add(time.Hour).Unix(), + "sub": "svc-sub", "preferred_username": "svc", "azp": "svc-client", + "groups": []string{"admins"}, "scope": "profile email", + } +} + +// --- verifyRS256 -------------------------------------------------------- + +func TestVerifyRS256(t *testing.T) { + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + ks := testKeyset(&priv.PublicKey, "kid1") + + t.Run("valid", func(t *testing.T) { + tok := mintRS256(t, priv, "kid1", baseClaims()) + c, err := verifyRS256(tok, ks, tIss, tAud) + if err != nil || c == nil || c.Username != "svc" || c.Azp != "svc-client" { + t.Fatalf("valid token failed: err=%v claims=%+v", err, c) + } + }) + t.Run("bad signature -> 401 (errInvalidToken)", func(t *testing.T) { + tok := mintRS256(t, priv, "kid1", baseClaims()) + if _, err := verifyRS256(tok[:len(tok)-2]+"xx", ks, tIss, tAud); err != errInvalidToken { + t.Fatalf("want errInvalidToken, got %v", err) + } + }) + t.Run("expired -> 401", func(t *testing.T) { + c := baseClaims() + c["exp"] = time.Now().Add(-time.Hour).Unix() + tok := mintRS256(t, priv, "kid1", c) + if _, err := verifyRS256(tok, ks, tIss, tAud); err != errInvalidToken { + t.Fatalf("want errInvalidToken (expired), got %v", err) + } + }) + t.Run("unknown kid -> fallback (errUnknownKey)", func(t *testing.T) { + tok := mintRS256(t, priv, "otherkid", baseClaims()) + if _, err := verifyRS256(tok, ks, tIss, tAud); err != errUnknownKey { + t.Fatalf("want errUnknownKey, got %v", err) + } + }) + t.Run("wrong issuer -> fallback", func(t *testing.T) { + tok := mintRS256(t, priv, "kid1", baseClaims()) + if _, err := verifyRS256(tok, ks, "https://other", tAud); err != errUnknownKey { + t.Fatalf("want errUnknownKey (iss), got %v", err) + } + }) + t.Run("wrong audience -> fallback (never 401)", func(t *testing.T) { + tok := mintRS256(t, priv, "kid1", baseClaims()) + if _, err := verifyRS256(tok, ks, tIss, "different-aud"); err != errUnknownKey { + t.Fatalf("want errUnknownKey (aud), got %v", err) + } + }) + t.Run("not a JWT -> fallback", func(t *testing.T) { + if _, err := verifyRS256("not.a", ks, tIss, tAud); err != errNotJWT { + t.Fatalf("want errNotJWT, got %v", err) + } + }) +} + +// --- audContains -------------------------------------------------------- + +func TestAudContains(t *testing.T) { + str := &Claims{Aud: json.RawMessage(`"account"`)} + if !str.audContains("account") || str.audContains("nope") { + t.Error("string aud match failed") + } + arr := &Claims{Aud: json.RawMessage(`["mcp-gateway","account"]`)} + if !arr.audContains("account") || !arr.audContains("mcp-gateway") || arr.audContains("nope") { + t.Error("array aud match failed") + } +} + +// --- mint round-trips --------------------------------------------------- + +// decodeHS256 verifies the HMAC and returns the claims. +func decodeHS256(t *testing.T, tok, secret string) map[string]any { + t.Helper() + var h, p, sig string + parts := 0 + last := 0 + for i := 0; i <= len(tok); i++ { + if i == len(tok) || tok[i] == '.' { + seg := tok[last:i] + switch parts { + case 0: + h = seg + case 1: + p = seg + case 2: + sig = seg + } + parts++ + last = i + 1 + } + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(h + "." + p)) + want := b64urlEncode(mac.Sum(nil)) + if !hmac.Equal([]byte(want), []byte(sig)) { + t.Fatalf("HS256 signature mismatch") + } + pb, _ := base64.RawURLEncoding.DecodeString(p) + var claims map[string]any + if err := json.Unmarshal(pb, &claims); err != nil { + t.Fatalf("decode claims: %v", err) + } + return claims +} + +func TestMintRegistryUIToken(t *testing.T) { + secret := "unit-test-secret-32-bytes-xxxxxxxxxx" + tok, err := mintRegistryUIToken(secret, "svc-sub", "", []string{"admins"}, "oauth2", "svc-client", "svc-sub") + if err != nil { + t.Fatal(err) + } + c := decodeHS256(t, tok, secret) + if c["iss"] != "mcp-auth-server" || c["aud"] != "mcp-registry-ui" || c["sub"] != "svc-sub" || + c["token_use"] != "mcp-registry-ui" || c["auth_method"] != "oauth2" || c["client_id"] != "svc-client" { + t.Fatalf("registry-ui claims wrong: %+v", c) + } + if s, ok := c["scopes"].([]any); !ok || len(s) != 0 { + t.Fatalf("registry-ui token must carry empty scopes, got %v", c["scopes"]) + } +} + +func TestMintMCPProxyToken(t *testing.T) { + secret := "unit-test-secret-32-bytes-xxxxxxxxxx" + tok, err := mintMCPProxyToken(secret, "svc-sub", []string{"s1", "s2"}, "myserver/mcp", "http://up:9/mcp", "oauth2", "svc-sub") + if err != nil { + t.Fatal(err) + } + c := decodeHS256(t, tok, secret) + if c["aud"] != "mcp-proxy" || c["token_use"] != "mcp-proxy" || c["server"] != "myserver" || + c["upstream_url"] != "http://up:9/mcp" { + t.Fatalf("mcp-proxy claims wrong: %+v", c) + } + if s, ok := c["scopes"].([]any); !ok || len(s) != 2 { + t.Fatalf("mcp-proxy token must carry scopes, got %v", c["scopes"]) + } +} + +func TestMintInternal_EmptySubjectFailsClosed(t *testing.T) { + if _, err := mintInternal("secret", "aud", "", nil, nil); err == nil { + t.Fatal("empty subject must fail closed") + } +} + +// --- parseKey ----------------------------------------------------------- + +func TestParseKey(t *testing.T) { + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + n := base64.RawURLEncoding.EncodeToString(priv.PublicKey.N.Bytes()) + eb := make([]byte, 4) + binary.BigEndian.PutUint32(eb, uint32(priv.PublicKey.E)) + // trim leading zero bytes + i := 0 + for i < len(eb)-1 && eb[i] == 0 { + i++ + } + e := base64.RawURLEncoding.EncodeToString(eb[i:]) + pub, err := parseKey(jwk{Kty: "RSA", Kid: "k", N: n, E: e}) + if err != nil { + t.Fatal(err) + } + if pub.N.Cmp(priv.PublicKey.N) != 0 || pub.E != priv.PublicKey.E { + t.Fatalf("parsed key mismatch: E got %d want %d", pub.E, priv.PublicKey.E) + } + _ = big.NewInt(0) +} diff --git a/go-validate/main_test.go b/go-validate/main_test.go index 7da401113..c75f8f06b 100644 --- a/go-validate/main_test.go +++ b/go-validate/main_test.go @@ -1,6 +1,8 @@ package main import ( + "crypto/rand" + "crypto/rsa" "net/http" "net/http/httptest" "net/http/httputil" @@ -198,3 +200,105 @@ func TestScopeResolver_Resolve(t *testing.T) { t.Fatal("user-generated sentinel must not resolve as M2M") } } + +// --- full fast-path handler (verify -> map -> mint -> headers) ----------- + +func TestHandleValidate_FastPathSuccess(t *testing.T) { + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + ks := testKeyset(&priv.PublicKey, "kid1") + res := &scopeResolver{} + res.snap.Store(&scopeSnapshot{ + scopes: []scopeDoc{{ID: "mcp-servers-unrestricted/read", GroupMappings: []string{"admins"}}}, + m2mGroups: map[string][]string{}, + }) + s := &server{ + cfg: Config{ + FastPathReady: true, SecretKey: "unit-test-secret-32-bytes-xxxxxxxxxx", + Issuer: tIss, Audience: tAud, AuthMethod: "keycloak", + }, + ks: ks, + scopes: res, + fallback: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not fall back") }), + } + tok := mintRS256(t, priv, "kid1", baseClaims()) + req := httptest.NewRequest(http.MethodGet, "/validate", nil) + req.Header.Set("X-Authorization", "Bearer "+tok) + req.Header.Set("X-Original-URL", "http://localhost/api/servers") + req.Header.Set("X-Registry-Api-Auth", "1") // marker -> mint registry-ui token + rr := httptest.NewRecorder() + s.handleValidate(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rr.Code) + } + h := rr.Header() + if h.Get("X-User") != "svc" || h.Get("X-Scopes") != "mcp-servers-unrestricted/read" || + h.Get("X-Auth-Method") != "keycloak" { + t.Fatalf("identity headers wrong: user=%q scopes=%q method=%q", h.Get("X-User"), h.Get("X-Scopes"), h.Get("X-Auth-Method")) + } + if h.Get("X-Internal-Token-Registry") == "" { + t.Fatal("expected X-Internal-Token-Registry (marker present)") + } +} + +func TestHandleValidate_RecognizedInvalidIs401(t *testing.T) { + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + ks := testKeyset(&priv.PublicKey, "kid1") + s := &server{ + cfg: Config{FastPathReady: true, SecretKey: "unit-test-secret-32-bytes-xxxxxxxxxx", Issuer: tIss, Audience: tAud}, + ks: ks, + scopes: &scopeResolver{}, + fallback: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not fall back on bad sig") }), + } + tok := mintRS256(t, priv, "kid1", baseClaims()) + req := httptest.NewRequest(http.MethodGet, "/validate", nil) + req.Header.Set("X-Authorization", "Bearer "+tok[:len(tok)-2]+"xx") // corrupt sig + req.Header.Set("X-Original-URL", "http://localhost/api/x") + rr := httptest.NewRecorder() + s.handleValidate(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rr.Code) + } +} + +func TestHealthAndMetrics(t *testing.T) { + s := &server{cfg: Config{FastPathReady: false}} + rr := httptest.NewRecorder() + s.handleHealth(rr, httptest.NewRequest("GET", "/health", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("health want 200, got %d", rr.Code) + } + rr2 := httptest.NewRecorder() + s.handleMetrics(rr2, httptest.NewRequest("GET", "/metrics", nil)) + if rr2.Code != http.StatusOK || rr2.Body.Len() == 0 { + t.Fatalf("metrics want 200 + body, got %d len=%d", rr2.Code, rr2.Body.Len()) + } +} + +func TestBuildMongoURI(t *testing.T) { + t.Setenv("DOCUMENTDB_HOST", "") + if _, ok := buildMongoURI(); ok { + t.Fatal("no host -> disabled") + } + t.Setenv("DOCUMENTDB_HOST", "mongodb") + t.Setenv("DOCUMENTDB_USERNAME", "admin") + t.Setenv("DOCUMENTDB_PASSWORD", "p@ss/w:rd") + uri, ok := buildMongoURI() + if !ok || uri == "" { + t.Fatalf("expected uri, got %q ok=%v", uri, ok) + } + // password special chars must be URL-encoded + if !contains(uri, "authSource=admin") || !contains(uri, "p%40ss") { + t.Fatalf("uri not built/escaped correctly: %s", uri) + } +} + +func contains(s, sub string) bool { return len(s) >= len(sub) && (indexOf(s, sub) >= 0) } +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/servers/pingmcp/main_test.go b/servers/pingmcp/main_test.go new file mode 100644 index 000000000..643ecba12 --- /dev/null +++ b/servers/pingmcp/main_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func post(t *testing.T, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handleMCP(rr, req) + return rr +} + +func decode(t *testing.T, rr *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &m); err != nil { + t.Fatalf("bad JSON: %v (%s)", err, rr.Body.String()) + } + return m +} + +func TestInitialize(t *testing.T) { + rr := post(t, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`) + if rr.Code != 200 { + t.Fatalf("want 200, got %d", rr.Code) + } + if rr.Header().Get(sessionHeader) == "" { + t.Error("initialize must set an Mcp-Session-Id header") + } + res := decode(t, rr)["result"].(map[string]any) + si := res["serverInfo"].(map[string]any) + if si["name"] != serverName { + t.Errorf("serverInfo.name = %v", si["name"]) + } + if res["protocolVersion"] != "2025-06-18" { + t.Errorf("protocolVersion echo failed: %v", res["protocolVersion"]) + } +} + +func TestToolsList(t *testing.T) { + rr := post(t, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + tools := decode(t, rr)["result"].(map[string]any)["tools"].([]any) + if len(tools) != 1 || tools[0].(map[string]any)["name"] != "echo" { + t.Fatalf("expected one echo tool, got %v", tools) + } +} + +func TestToolsCallEcho(t *testing.T) { + rr := post(t, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi-there"}}}`) + content := decode(t, rr)["result"].(map[string]any)["content"].([]any) + if content[0].(map[string]any)["text"] != "hi-there" { + t.Fatalf("echo returned %v", content) + } +} + +func TestPing(t *testing.T) { + rr := post(t, `{"jsonrpc":"2.0","id":4,"method":"ping"}`) + if rr.Code != 200 || decode(t, rr)["result"] == nil { + t.Fatalf("ping failed: %d %s", rr.Code, rr.Body.String()) + } +} + +func TestNotificationsInitializedIs202(t *testing.T) { + rr := post(t, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) + if rr.Code != http.StatusAccepted { + t.Fatalf("want 202, got %d", rr.Code) + } +} + +func TestUnknownMethodIsJSONRPCError(t *testing.T) { + rr := post(t, `{"jsonrpc":"2.0","id":9,"method":"does/not/exist"}`) + if decode(t, rr)["error"] == nil { + t.Fatal("unknown method must return a JSON-RPC error") + } +} + +func TestGetIs405(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + rr := httptest.NewRecorder() + handleMCP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET want 405, got %d", rr.Code) + } +} From cdc68c41dbdfa7d44ca2ae0fd0420c0b7567d039 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 01:01:57 +0000 Subject: [PATCH 10/26] feat(go-validate): auto-derive JWKS/issuer; surface config on all three reference points Ergonomics + the config-surface checklist for the sidecar params: - Auto-derivation: go-validate now derives JWKS_URL and VALIDATE_ISSUER at runtime from KEYCLOAK_URL / KEYCLOAK_EXTERNAL_URL / KEYCLOAK_REALM (already present in every deployment) when unset. Operators now only set enabled + audience (aud can't be auto-derived; Keycloak varies it). Explicit values win; unit-tested (derive / explicit-wins / no-keycloak-fallback). - docker-compose: pass KEYCLOAK_* to the sidecar so derivation works there too. - Helm auth-server values: document that jwksUrl/issuer auto-derive (override-only). - System Config UI: add validate_upstream_url to registry Settings + CONFIG_GROUPS (Settings -> System Config -> Authentication). - docs/unified-parameter-reference.md: Group 3 rows for VALIDATE_UPSTREAM_URL and the go-validate sidecar params across Docker / Terraform / Helm. Refs #1652 --- charts/auth-server/values.yaml | 9 ++++-- docker-compose.yml | 4 +++ docs/unified-parameter-reference.md | 4 +++ go-validate/config.go | 24 ++++++++++++++ go-validate/config_test.go | 49 +++++++++++++++++++++++++++++ registry/api/config_routes.py | 1 + registry/core/config.py | 3 ++ 7 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 go-validate/config_test.go diff --git a/charts/auth-server/values.yaml b/charts/auth-server/values.yaml index a86a8ccc6..ff2530f02 100644 --- a/charts/auth-server/values.yaml +++ b/charts/auth-server/values.yaml @@ -22,9 +22,12 @@ goValidate: enabled: false image: repository: go-validate # image name within the registry (shares global registry/tag) - jwksUrl: "" # e.g. https://keycloak.example.com/realms/mcp-gateway/protocol/openid-connect/certs - issuer: "" # e.g. https://keycloak.example.com/realms/mcp-gateway - audience: "" # the aud claim your tokens carry, e.g. "account" + # jwksUrl and issuer are AUTO-DERIVED at runtime from the KEYCLOAK_URL / + # KEYCLOAK_EXTERNAL_URL / KEYCLOAK_REALM env the sidecar already receives, so + # you normally only set enabled + audience. Set these to override the derivation. + jwksUrl: "" # override, e.g. https:///realms/mcp-gateway/protocol/openid-connect/certs + issuer: "" # override, e.g. https:///realms/mcp-gateway + audience: "" # REQUIRED to engage the fast path: the aud claim your tokens carry, e.g. "account" resources: requests: cpu: 25m diff --git a/docker-compose.yml b/docker-compose.yml index 91e13d5b4..3f3eb515f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -618,6 +618,10 @@ services: # Fast path engages only when JWKS_URL + VALIDATE_ISSUER + VALIDATE_AUDIENCE are # all set and match the tokens; otherwise every /validate request is transparently # proxied to the auth-server (correct, just not accelerated). + # KEYCLOAK_* let go-validate auto-derive JWKS_URL + VALIDATE_ISSUER when unset. + - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} + - KEYCLOAK_EXTERNAL_URL=${KEYCLOAK_EXTERNAL_URL:-} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-mcp-gateway} - JWKS_URL=${VALIDATE_JWKS_URL:-http://keycloak:8080/realms/${KEYCLOAK_REALM:-mcp-gateway}/protocol/openid-connect/certs} - VALIDATE_ISSUER=${VALIDATE_ISSUER:-} - VALIDATE_AUDIENCE=${VALIDATE_AUDIENCE:-} diff --git a/docs/unified-parameter-reference.md b/docs/unified-parameter-reference.md index aa3d07404..d5aff96b5 100644 --- a/docs/unified-parameter-reference.md +++ b/docs/unified-parameter-reference.md @@ -121,6 +121,10 @@ Internal and external URLs for the auth server, plus internal JWT signing. |-----------|-----------------|-----------------------|----------------------|---------| | Auth server internal URL | `AUTH_SERVER_URL` | — (constructed by module) | `registry.app.authServerUrl` | Server-to-server URL inside the container network. | | Auth server external URL | `AUTH_SERVER_EXTERNAL_URL` | — (from domain config) | `auth-server.app.externalUrl` | Public URL for browser redirects. | +| /validate upstream | `VALIDATE_UPSTREAM_URL` | `validate_upstream_url` | `registry.app.validateUpstreamUrl` | nginx `/validate` upstream. Empty -> auth-server (unchanged). Set to `http://go-validate:8899` (or the auth-server Service on 8899) to route the auth check through the go-validate fast-path sidecar. Only `/validate` is affected. (issue #1652) | +| go-validate sidecar enabled | (runs by default via compose service) | `go_validate_enabled` | `auth-server.goValidate.enabled` | Deploy the Go `/validate` fast-path sidecar in the auth-server task/pod. Opt-in on ECS/Helm (default off); on by default (fallback-safe) in docker-compose. | +| go-validate image | (compose `go-validate` service build) | `go_validate_image_uri` | `auth-server.goValidate.image.repository` | Container image for the sidecar. | +| go-validate token audience | `VALIDATE_AUDIENCE` | `go_validate_audience` | `auth-server.goValidate.audience` | Expected `aud` claim for the fast path. The only fast-path value that cannot be auto-derived (Keycloak varies it); commonly `account`. `JWKS_URL` and `VALIDATE_ISSUER` are auto-derived at runtime from `KEYCLOAK_URL` / `KEYCLOAK_EXTERNAL_URL` / `KEYCLOAK_REALM` when unset. Empty audience -> safe fallback-only. | | Internal JWT issuer | (constant in code) | — | `auth-server.app.jwtIssuer` | `iss` claim on internal service JWTs. | | Internal JWT audience | (constant in code) | — | `auth-server.app.jwtAudience` | `aud` claim on internal service JWTs. | | App secret key **(secret)** | `SECRET_KEY` (required) | `secret_key` via `TF_VAR_*` / Secrets Manager (required) | `global.secretKey` (Helm chart auto-generates at install time if unset) | JWT signing + session-cookie signing + at-rest encryption of OAuth `id_token`. **Required** — auth_server and registry refuse to start without it (the previous per-replica random fallback caused `BadSignature` across replicas). Must be identical across all auth_server and registry replicas. Rotating invalidates stored creds and active sessions; rotation requires a process restart, not a SIGHUP reload. **Must be high-entropy (32+ bytes from a CSPRNG)** — read access to the `oauth_sessions_*` collection is equivalent to credential compromise unless this key is strong and never written to a logged location. Generate with `python3 -c 'import secrets; print(secrets.token_urlsafe(32))'`. | diff --git a/go-validate/config.go b/go-validate/config.go index f9742c4ea..7f2741b74 100644 --- a/go-validate/config.go +++ b/go-validate/config.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log" "os" "strings" @@ -79,6 +80,29 @@ func loadConfig() Config { MarkerSecret: os.Getenv("AUTH_SERVER_NGINX_MARKER_SECRET"), } + // Auto-derive JWKS_URL and VALIDATE_ISSUER from the KEYCLOAK_* env vars that + // every deployment already provides, so operators only opt in (+ set the + // audience) instead of hand-computing these. Explicit values always win. + // The audience claim is NOT derivable (Keycloak varies it per client), so it + // stays operator-supplied; an unset/mismatched audience fails safe (fallback). + realm := getenv("KEYCLOAK_REALM", "mcp-gateway") + if cfg.JWKSURL == "" { + if kc := strings.TrimRight(os.Getenv("KEYCLOAK_URL"), "/"); kc != "" { + cfg.JWKSURL = fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", kc, realm) + } + } + if cfg.Issuer == "" { + // Tokens carry the issuer of the URL the client used, usually the external + // Keycloak URL; fall back to the internal URL when no external is set. + iss := strings.TrimRight(os.Getenv("KEYCLOAK_EXTERNAL_URL"), "/") + if iss == "" { + iss = strings.TrimRight(os.Getenv("KEYCLOAK_URL"), "/") + } + if iss != "" { + cfg.Issuer = fmt.Sprintf("%s/realms/%s", iss, realm) + } + } + // B3: validate the signing secret. If a secret is provided at all it must be // strong; a weak secret is a hard failure (never fall open to a bad key). if cfg.SecretKey != "" { diff --git a/go-validate/config_test.go b/go-validate/config_test.go new file mode 100644 index 000000000..29e622e4f --- /dev/null +++ b/go-validate/config_test.go @@ -0,0 +1,49 @@ +package main + +import "testing" + +func TestLoadConfig_DerivesJWKSandIssuerFromKeycloak(t *testing.T) { + t.Setenv("SECRET_KEY", "unit-test-secret-32-bytes-xxxxxxxxxx") + t.Setenv("KEYCLOAK_URL", "http://keycloak:8080/") + t.Setenv("KEYCLOAK_EXTERNAL_URL", "https://kc.example.com") + t.Setenv("KEYCLOAK_REALM", "mcp-gateway") + t.Setenv("VALIDATE_AUDIENCE", "account") + // JWKS_URL / VALIDATE_ISSUER intentionally unset -> must be derived + t.Setenv("JWKS_URL", "") + t.Setenv("VALIDATE_ISSUER", "") + cfg := loadConfig() + if cfg.JWKSURL != "http://keycloak:8080/realms/mcp-gateway/protocol/openid-connect/certs" { + t.Fatalf("JWKS not derived: %q", cfg.JWKSURL) + } + if cfg.Issuer != "https://kc.example.com/realms/mcp-gateway" { + t.Fatalf("issuer not derived from external url: %q", cfg.Issuer) + } + if !cfg.FastPathReady { + t.Fatal("fast path should be ready after derivation + audience") + } +} + +func TestLoadConfig_ExplicitValuesWin(t *testing.T) { + t.Setenv("SECRET_KEY", "unit-test-secret-32-bytes-xxxxxxxxxx") + t.Setenv("KEYCLOAK_URL", "http://keycloak:8080") + t.Setenv("JWKS_URL", "https://explicit/certs") + t.Setenv("VALIDATE_ISSUER", "https://explicit/iss") + t.Setenv("VALIDATE_AUDIENCE", "account") + cfg := loadConfig() + if cfg.JWKSURL != "https://explicit/certs" || cfg.Issuer != "https://explicit/iss" { + t.Fatalf("explicit values must win: %q %q", cfg.JWKSURL, cfg.Issuer) + } +} + +func TestLoadConfig_NoKeycloak_FallbackOnly(t *testing.T) { + t.Setenv("SECRET_KEY", "") + t.Setenv("KEYCLOAK_URL", "") + t.Setenv("KEYCLOAK_EXTERNAL_URL", "") + t.Setenv("JWKS_URL", "") + t.Setenv("VALIDATE_ISSUER", "") + t.Setenv("VALIDATE_AUDIENCE", "") + cfg := loadConfig() + if cfg.FastPathReady { + t.Fatal("no config -> fallback-only (FastPathReady must be false)") + } +} diff --git a/registry/api/config_routes.py b/registry/api/config_routes.py index 342be1af5..2121a6eee 100644 --- a/registry/api/config_routes.py +++ b/registry/api/config_routes.py @@ -82,6 +82,7 @@ ("auth_provider", "Auth Provider", False), ("auth_server_url", "Auth Server URL", False), ("auth_server_external_url", "Auth Server External URL", False), + ("validate_upstream_url", "Validate Upstream URL (/validate fast path)", False), ("session_max_age_seconds", "Session Max Age", False), ("session_cookie_secure", "Secure Cookie", False), ("session_cookie_domain", "Cookie Domain", False), diff --git a/registry/core/config.py b/registry/core/config.py index 2aa87401c..003f09c27 100644 --- a/registry/core/config.py +++ b/registry/core/config.py @@ -208,6 +208,9 @@ class Settings(BaseSettings): ), ) auth_server_url: str = "http://localhost:8888" + # nginx /validate upstream. Empty -> auth-server (default). Set to the + # go-validate sidecar (e.g. http://go-validate:8899) to enable the fast path. + validate_upstream_url: str = "" auth_server_external_url: str = "http://localhost:8888" # External URL for OAuth redirects trusted_external_hosts: str = Field( default="", From a5e1189e2a47cc69c061edec49dc0c16559757ed Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 01:08:13 +0000 Subject: [PATCH 11/26] feat(fast-path): single feature switch (validate_fast_path_enabled / fastPath) + compose auto-derive - Rename the operator-facing enable switch from the implementation name to the FEATURE name: Terraform go_validate_enabled/image_uri/audience -> validate_fast_path_enabled / validate_fast_path_image_uri / validate_fast_path_audience; Helm goValidate.* -> fastPath.*. Artifact names (go-validate image/dir/container, VALIDATE_* env contract) are unchanged. - Single switch: on ECS, validate_fast_path_enabled=true now BOTH deploys the sidecar AND auto-routes nginx /validate to it (VALIDATE_UPSTREAM_URL derived unless overridden). On Helm, global.fastPath.enabled does the same across the auth-server + registry subcharts (honored via dig for standalone safety). - docker-compose: JWKS_URL now auto-derives from KEYCLOAK_* by default (was a hardcoded fallback); .env.example documents the compose switch (VALIDATE_UPSTREAM_URL: go-validate:8899 = on, auth-server:8888 = off). - Tests: added global-switch cases; full helm suite 180 pass, terraform validate ok. Refs #1652 --- .env.example | 7 ++++++- charts/auth-server/templates/deployment.yaml | 12 +++++------ charts/auth-server/templates/service.yaml | 2 +- .../auth-server/tests/go_validate_test.yaml | 20 ++++++++++++++++-- charts/auth-server/values.yaml | 2 +- charts/mcp-gateway-registry-stack/values.yaml | 21 ++++++++++--------- charts/registry/templates/secret.yaml | 8 +++++-- .../tests/validate_upstream_test.yaml | 14 +++++++++++++ docker-compose.yml | 4 +++- docs/unified-parameter-reference.md | 6 +++--- terraform/aws-ecs/main.tf | 6 +++--- .../modules/mcp-gateway/ecs-services.tf | 21 ++++++++++--------- .../aws-ecs/modules/mcp-gateway/variables.tf | 8 +++---- terraform/aws-ecs/terraform.tfvars.example | 8 +++---- terraform/aws-ecs/variables.tf | 8 +++---- 15 files changed, 95 insertions(+), 52 deletions(-) diff --git a/.env.example b/.env.example index 64bc53deb..2b61f516f 100644 --- a/.env.example +++ b/.env.example @@ -1928,10 +1928,15 @@ OPENBAO_TOKEN= # # Route /validate to the sidecar (docker-compose default). Set to # http://auth-server:8888 to bypass the sidecar entirely. +# ENABLE/DISABLE SWITCH (docker-compose): this routes nginx /validate. +# http://go-validate:8899 -> fast path ON (route through the sidecar) [default] +# http://auth-server:8888 -> fast path OFF (bypass the sidecar, pure Python) VALIDATE_UPSTREAM_URL=http://go-validate:8899 # JWKS endpoint the sidecar fetches signing keys from (must be reachable from the # sidecar container). Defaults to the internal Keycloak realm certs. -VALIDATE_JWKS_URL=http://keycloak:8080/realms/mcp-gateway/protocol/openid-connect/certs +# Optional overrides - auto-derived from KEYCLOAK_URL / KEYCLOAK_EXTERNAL_URL / +# KEYCLOAK_REALM when left empty. Set only to override the derivation. +VALIDATE_JWKS_URL= # Expected token issuer (the `iss` claim, usually the EXTERNAL Keycloak URL). Leave # empty to run fallback-only (safe: every /validate proxied to Python, not accelerated). VALIDATE_ISSUER= diff --git a/charts/auth-server/templates/deployment.yaml b/charts/auth-server/templates/deployment.yaml index 014e12a29..a92c594ec 100644 --- a/charts/auth-server/templates/deployment.yaml +++ b/charts/auth-server/templates/deployment.yaml @@ -248,12 +248,12 @@ spec: timeoutSeconds: 3 failureThreshold: 3 - {{- if .Values.goValidate.enabled }} + {{- if or .Values.fastPath.enabled (dig "fastPath" "enabled" false .Values.global) }} # Go /validate fast-path sidecar (issue #1652). Shares the pod network with # auth-server, so AUTH_FALLBACK_URL is localhost:8888. Reuses the same # secrets (SECRET_KEY, DOCUMENTDB_*, AUTH_SERVER_NGINX_MARKER_SECRET). - name: go-validate - image: "{{ $imgRegistry }}/{{ default "go-validate" .Values.goValidate.image.repository }}:{{ $imgTag }}" + image: "{{ $imgRegistry }}/{{ default "go-validate" .Values.fastPath.image.repository }}:{{ $imgTag }}" imagePullPolicy: {{ $imgPullPolicy }} securityContext: allowPrivilegeEscalation: false @@ -283,11 +283,11 @@ spec: - name: AUTH_FALLBACK_URL value: "http://localhost:8888" - name: JWKS_URL - value: {{ .Values.goValidate.jwksUrl | quote }} + value: {{ .Values.fastPath.jwksUrl | quote }} - name: VALIDATE_ISSUER - value: {{ .Values.goValidate.issuer | quote }} + value: {{ .Values.fastPath.issuer | quote }} - name: VALIDATE_AUDIENCE - value: {{ .Values.goValidate.audience | quote }} + value: {{ .Values.fastPath.audience | quote }} resources: - {{- toYaml .Values.goValidate.resources | nindent 12 }} + {{- toYaml .Values.fastPath.resources | nindent 12 }} {{- end }} diff --git a/charts/auth-server/templates/service.yaml b/charts/auth-server/templates/service.yaml index c0cce0349..7e75cab5e 100644 --- a/charts/auth-server/templates/service.yaml +++ b/charts/auth-server/templates/service.yaml @@ -14,7 +14,7 @@ spec: targetPort: http protocol: TCP name: http - {{- if .Values.goValidate.enabled }} + {{- if or .Values.fastPath.enabled (dig "fastPath" "enabled" false .Values.global) }} - port: 8899 targetPort: govalidate protocol: TCP diff --git a/charts/auth-server/tests/go_validate_test.yaml b/charts/auth-server/tests/go_validate_test.yaml index 70423f6e2..8a72b5a5c 100644 --- a/charts/auth-server/tests/go_validate_test.yaml +++ b/charts/auth-server/tests/go_validate_test.yaml @@ -18,7 +18,7 @@ tests: set: app: secretKey: test-key-32-bytes-aaaaaaaaaaaaaaaaaa - goValidate: + fastPath: enabled: true audience: account asserts: @@ -52,7 +52,7 @@ tests: - it: exposes port 8899 on the Service when enabled template: templates/service.yaml set: - goValidate: + fastPath: enabled: true asserts: - contains: @@ -62,3 +62,19 @@ tests: targetPort: govalidate protocol: TCP name: govalidate + + - it: global.fastPath.enabled also turns on the sidecar (stack single switch) + template: templates/deployment.yaml + set: + app: + secretKey: test-key-32-bytes-aaaaaaaaaaaaaaaaaa + global: + fastPath: + enabled: true + asserts: + - lengthEqual: + path: spec.template.spec.containers + count: 2 + - equal: + path: spec.template.spec.containers[1].name + value: go-validate diff --git a/charts/auth-server/values.yaml b/charts/auth-server/values.yaml index ff2530f02..e13772d5a 100644 --- a/charts/auth-server/values.yaml +++ b/charts/auth-server/values.yaml @@ -18,7 +18,7 @@ image: # it. Leave disabled to keep /validate on the Python auth-server (unchanged). # Fast path engages only when jwksUrl + issuer + audience are all set; otherwise # it transparently proxies to the auth-server (safe fallback). -goValidate: +fastPath: enabled: false image: repository: go-validate # image name within the registry (shares global registry/tag) diff --git a/charts/mcp-gateway-registry-stack/values.yaml b/charts/mcp-gateway-registry-stack/values.yaml index 7d9af7e91..34ada6319 100644 --- a/charts/mcp-gateway-registry-stack/values.yaml +++ b/charts/mcp-gateway-registry-stack/values.yaml @@ -27,6 +27,12 @@ global: # OAuth provider shared secret - contains auth provider type and IdP credentials # Both auth-server and registry reference this secret for Keycloak/Entra configuration oauthProviderSecretName: "oauth-provider-secret" + # Single switch for the /validate fast-path sidecar (issue #1652). true deploys + # the go-validate sidecar in the auth-server pod AND routes nginx /validate to it. + # You still set auth-server.fastPath.audience to engage the fast path (JWKS/issuer + # auto-derive from Keycloak); empty audience = safe fallback-only. + fastPath: + enabled: false # Existing secret references - set these to use pre-existing secrets instead of chart-managed ones. # When set, the chart skips creating the corresponding managed Secret resource. # The existing secret must contain the same keys the chart would have created. @@ -297,7 +303,7 @@ mongodb-configure: registry: app: # Point nginx /validate at the go-validate sidecar (must match the auth-server - # goValidate.enabled switch above). Empty -> auth-server (unchanged). (issue #1652) + # fastPath.enabled switch above). Empty -> auth-server (unchanged). (issue #1652) # validateUpstreamUrl: "http://auth-server..svc.cluster.local:8899" replicas: 2 # set to > 1 replica for high availability # Session cookie Domain attribute. Leave empty to default to @@ -548,15 +554,10 @@ mcpgw: extraEnvFrom: [] # Auth server configuration auth-server: - # Go /validate fast-path sidecar (issue #1652). OPT-IN. To enable end-to-end: - # 1. set goValidate.enabled: true (+ jwksUrl/issuer/audience for the fast path) - # 2. set registry.app.validateUpstreamUrl below so nginx routes /validate to it. - # Left disabled, /validate stays on the Python auth-server (unchanged). - goValidate: - enabled: false - # jwksUrl: "https:///realms/mcp-gateway/protocol/openid-connect/certs" - # issuer: "https:///realms/mcp-gateway" - # audience: "account" + # Fast path is toggled by global.fastPath.enabled above (single switch: + # deploys the sidecar AND routes /validate). Set the audience here to engage it. + fastPath: + # audience: "account" # required to accelerate; empty = safe fallback-only app: replicas: 2 # set to > 1 replica for high availability # Session cookie Domain attribute. See registry.app.sessionCookieDomain diff --git a/charts/registry/templates/secret.yaml b/charts/registry/templates/secret.yaml index f2872eafc..addd962e7 100644 --- a/charts/registry/templates/secret.yaml +++ b/charts/registry/templates/secret.yaml @@ -116,8 +116,12 @@ metadata: data: AUTH_SERVER_EXTERNAL_URL: {{ $authServerExternalUrl | b64enc | quote }} AUTH_SERVER_URL: {{ printf "http://auth-server.%s.svc.cluster.local:8888" .Release.Namespace | b64enc | quote }} - {{- if .Values.app.validateUpstreamUrl }} - VALIDATE_UPSTREAM_URL: {{ .Values.app.validateUpstreamUrl | b64enc | quote }} + {{- $validateUpstream := .Values.app.validateUpstreamUrl }} + {{- if and (not $validateUpstream) (dig "fastPath" "enabled" false .Values.global) }} + {{- $validateUpstream = printf "http://auth-server.%s.svc.cluster.local:8899" .Release.Namespace }} + {{- end }} + {{- if $validateUpstream }} + VALIDATE_UPSTREAM_URL: {{ $validateUpstream | b64enc | quote }} {{- end }} {{- if eq (.Values.global.authProvider.type | default "keycloak") "keycloak" }} KEYCLOAK_ADMIN: {{ (.Values.global.authProvider.keycloak.adminUsername | default "user") | b64enc | quote }} diff --git a/charts/registry/tests/validate_upstream_test.yaml b/charts/registry/tests/validate_upstream_test.yaml index 047000be2..3d969925f 100644 --- a/charts/registry/tests/validate_upstream_test.yaml +++ b/charts/registry/tests/validate_upstream_test.yaml @@ -24,3 +24,17 @@ tests: asserts: - isNotNullOrEmpty: path: data.VALIDATE_UPSTREAM_URL + + - it: global.fastPath.enabled derives VALIDATE_UPSTREAM_URL to the sidecar + template: templates/secret.yaml + set: + app: + secretKey: "test-key-not-for-prod" + egressAuth: + markerSecret: "test-marker-not-for-prod" + global: + fastPath: + enabled: true + asserts: + - isNotNullOrEmpty: + path: data.VALIDATE_UPSTREAM_URL diff --git a/docker-compose.yml b/docker-compose.yml index 3f3eb515f..d12a38352 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -622,7 +622,9 @@ services: - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} - KEYCLOAK_EXTERNAL_URL=${KEYCLOAK_EXTERNAL_URL:-} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-mcp-gateway} - - JWKS_URL=${VALIDATE_JWKS_URL:-http://keycloak:8080/realms/${KEYCLOAK_REALM:-mcp-gateway}/protocol/openid-connect/certs} + # JWKS_URL + VALIDATE_ISSUER auto-derive from the KEYCLOAK_* vars above when + # left empty; set VALIDATE_JWKS_URL / VALIDATE_ISSUER in .env only to override. + - JWKS_URL=${VALIDATE_JWKS_URL:-} - VALIDATE_ISSUER=${VALIDATE_ISSUER:-} - VALIDATE_AUDIENCE=${VALIDATE_AUDIENCE:-} - JWKS_REFRESH_SECONDS=${JWKS_REFRESH_SECONDS:-300} diff --git a/docs/unified-parameter-reference.md b/docs/unified-parameter-reference.md index d5aff96b5..aab945333 100644 --- a/docs/unified-parameter-reference.md +++ b/docs/unified-parameter-reference.md @@ -122,9 +122,9 @@ Internal and external URLs for the auth server, plus internal JWT signing. | Auth server internal URL | `AUTH_SERVER_URL` | — (constructed by module) | `registry.app.authServerUrl` | Server-to-server URL inside the container network. | | Auth server external URL | `AUTH_SERVER_EXTERNAL_URL` | — (from domain config) | `auth-server.app.externalUrl` | Public URL for browser redirects. | | /validate upstream | `VALIDATE_UPSTREAM_URL` | `validate_upstream_url` | `registry.app.validateUpstreamUrl` | nginx `/validate` upstream. Empty -> auth-server (unchanged). Set to `http://go-validate:8899` (or the auth-server Service on 8899) to route the auth check through the go-validate fast-path sidecar. Only `/validate` is affected. (issue #1652) | -| go-validate sidecar enabled | (runs by default via compose service) | `go_validate_enabled` | `auth-server.goValidate.enabled` | Deploy the Go `/validate` fast-path sidecar in the auth-server task/pod. Opt-in on ECS/Helm (default off); on by default (fallback-safe) in docker-compose. | -| go-validate image | (compose `go-validate` service build) | `go_validate_image_uri` | `auth-server.goValidate.image.repository` | Container image for the sidecar. | -| go-validate token audience | `VALIDATE_AUDIENCE` | `go_validate_audience` | `auth-server.goValidate.audience` | Expected `aud` claim for the fast path. The only fast-path value that cannot be auto-derived (Keycloak varies it); commonly `account`. `JWKS_URL` and `VALIDATE_ISSUER` are auto-derived at runtime from `KEYCLOAK_URL` / `KEYCLOAK_EXTERNAL_URL` / `KEYCLOAK_REALM` when unset. Empty audience -> safe fallback-only. | +| go-validate sidecar enabled | (runs by default via compose service) | `validate_fast_path_enabled` | `auth-server.fastPath.enabled` | Deploy the Go `/validate` fast-path sidecar in the auth-server task/pod. Opt-in on ECS/Helm (default off); on by default (fallback-safe) in docker-compose. | +| go-validate image | (compose `go-validate` service build) | `validate_fast_path_image_uri` | `auth-server.fastPath.image.repository` | Container image for the sidecar. | +| go-validate token audience | `VALIDATE_AUDIENCE` | `validate_fast_path_audience` | `auth-server.fastPath.audience` | Expected `aud` claim for the fast path. The only fast-path value that cannot be auto-derived (Keycloak varies it); commonly `account`. `JWKS_URL` and `VALIDATE_ISSUER` are auto-derived at runtime from `KEYCLOAK_URL` / `KEYCLOAK_EXTERNAL_URL` / `KEYCLOAK_REALM` when unset. Empty audience -> safe fallback-only. | | Internal JWT issuer | (constant in code) | — | `auth-server.app.jwtIssuer` | `iss` claim on internal service JWTs. | | Internal JWT audience | (constant in code) | — | `auth-server.app.jwtAudience` | `aud` claim on internal service JWTs. | | App secret key **(secret)** | `SECRET_KEY` (required) | `secret_key` via `TF_VAR_*` / Secrets Manager (required) | `global.secretKey` (Helm chart auto-generates at install time if unset) | JWT signing + session-cookie signing + at-rest encryption of OAuth `id_token`. **Required** — auth_server and registry refuse to start without it (the previous per-replica random fallback caused `BadSignature` across replicas). Must be identical across all auth_server and registry replicas. Rotating invalidates stored creds and active sessions; rotation requires a process restart, not a SIGHUP reload. **Must be high-entropy (32+ bytes from a CSPRNG)** — read access to the `oauth_sessions_*` collection is equivalent to credential compromise unless this key is strong and never written to a logged location. Generate with `python3 -c 'import secrets; print(secrets.token_urlsafe(32))'`. | diff --git a/terraform/aws-ecs/main.tf b/terraform/aws-ecs/main.tf index c3170e2e2..ae2fe839f 100755 --- a/terraform/aws-ecs/main.tf +++ b/terraform/aws-ecs/main.tf @@ -75,9 +75,9 @@ module "mcp_gateway" { # Container images (core services default to public ECR) registry_image_uri = var.registry_image_uri auth_server_image_uri = var.auth_server_image_uri - go_validate_image_uri = var.go_validate_image_uri - go_validate_enabled = var.go_validate_enabled - go_validate_audience = var.go_validate_audience + validate_fast_path_image_uri = var.validate_fast_path_image_uri + validate_fast_path_enabled = var.validate_fast_path_enabled + validate_fast_path_audience = var.validate_fast_path_audience validate_upstream_url = var.validate_upstream_url mcpgw_image_uri = var.mcpgw_image_uri diff --git a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf index 4c11a2942..72b2cfd76 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf @@ -73,7 +73,7 @@ module "ecs_service_auth" { } port_name = "auth-server" discovery_name = "auth-server" - }], var.go_validate_enabled ? [{ + }], var.validate_fast_path_enabled ? [{ client_alias = { port = 8899 dns_name = "go-validate" @@ -86,8 +86,8 @@ module "ecs_service_auth" { # Container definitions container_definitions = merge({ auth-server = { - cpu = tonumber(var.cpu) - (var.enable_observability ? local.adot_sidecar_cpu : 0) - (var.go_validate_enabled ? 128 : 0) - memory = tonumber(var.memory) - (var.enable_observability ? local.adot_sidecar_memory : 0) - (var.go_validate_enabled ? 128 : 0) + cpu = tonumber(var.cpu) - (var.enable_observability ? local.adot_sidecar_cpu : 0) - (var.validate_fast_path_enabled ? 128 : 0) + memory = tonumber(var.memory) - (var.enable_observability ? local.adot_sidecar_memory : 0) - (var.validate_fast_path_enabled ? 128 : 0) essential = true image = var.auth_server_image_uri versionConsistency = "disabled" @@ -728,12 +728,12 @@ module "ecs_service_auth" { }] } } : {}, - var.go_validate_enabled ? { + var.validate_fast_path_enabled ? { go-validate = { cpu = 128 memory = 128 essential = false - image = var.go_validate_image_uri + image = var.validate_fast_path_image_uri versionConsistency = "disabled" readonlyRootFilesystem = true @@ -751,7 +751,7 @@ module "ecs_service_auth" { { name = "AUTH_FALLBACK_URL", value = "http://localhost:18888" }, { name = "JWKS_URL", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}/realms/mcp-gateway/protocol/openid-connect/certs" : "" }, { name = "VALIDATE_ISSUER", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}/realms/mcp-gateway" : "" }, - { name = "VALIDATE_AUDIENCE", value = var.go_validate_audience }, + { name = "VALIDATE_AUDIENCE", value = var.validate_fast_path_audience }, { name = "DOCUMENTDB_HOST", value = var.documentdb_endpoint }, { name = "DOCUMENTDB_PORT", value = "27017" }, { name = "DOCUMENTDB_DATABASE", value = var.documentdb_database }, @@ -955,10 +955,11 @@ module "ecs_service_registry" { value = var.auth_server_url }, { - # nginx /validate upstream. Empty -> auth-server (default); set to the - # go-validate sidecar when enabled. Only /validate is affected. + # nginx /validate upstream. validate_fast_path_enabled is the single switch: + # when true (and no explicit override), route /validate to the sidecar; + # otherwise leave empty so nginx uses the auth-server (unchanged). name = "VALIDATE_UPSTREAM_URL" - value = var.validate_upstream_url + value = var.validate_upstream_url != "" ? var.validate_upstream_url : (var.validate_fast_path_enabled ? "http://go-validate:8899" : "") }, { name = "AUTH_SERVER_EXTERNAL_URL" @@ -2160,7 +2161,7 @@ resource "aws_vpc_security_group_ingress_rule" "registry_to_auth" { # Allow registry to reach the go-validate sidecar (8899) in the auth task, only # when the sidecar is enabled. Service Connect proxy-to-proxy uses containerPort. resource "aws_vpc_security_group_ingress_rule" "registry_to_auth_govalidate" { - count = var.go_validate_enabled ? 1 : 0 + count = var.validate_fast_path_enabled ? 1 : 0 security_group_id = module.ecs_service_auth.security_group_id referenced_security_group_id = module.ecs_service_registry.security_group_id from_port = 8899 diff --git a/terraform/aws-ecs/modules/mcp-gateway/variables.tf b/terraform/aws-ecs/modules/mcp-gateway/variables.tf index c43f99400..ab3c76cbe 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/variables.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/variables.tf @@ -1923,26 +1923,26 @@ variable "egress_secrets_manager_path_prefix" { # Go /validate fast-path sidecar (issue #1652). Opt-in; default off keeps # nginx /validate pointed at the auth-server (unchanged behavior). # --------------------------------------------------------------------------- -variable "go_validate_enabled" { +variable "validate_fast_path_enabled" { description = "Deploy the go-validate fast-path sidecar in the auth-server task and route the registry's nginx /validate to it. Default false: /validate stays on the auth-server (non-breaking)." type = bool default = false } -variable "go_validate_image_uri" { +variable "validate_fast_path_image_uri" { description = "Container image URI for the go-validate sidecar." type = string default = "public.ecr.aws/p3v1o3c6/go-validate:latest" } -variable "go_validate_audience" { +variable "validate_fast_path_audience" { description = "Expected token audience (aud) for the go-validate fast path. Empty leaves the sidecar in safe fallback-only mode (transparent proxy to Python)." type = string default = "" } variable "validate_upstream_url" { - description = "nginx /validate upstream for the registry. Empty (default) resolves to the auth-server. Set to http://go-validate:8899 when go_validate_enabled = true." + description = "nginx /validate upstream for the registry. Empty (default) resolves to the auth-server. Set to http://go-validate:8899 when validate_fast_path_enabled = true." type = string default = "" } diff --git a/terraform/aws-ecs/terraform.tfvars.example b/terraform/aws-ecs/terraform.tfvars.example index 6de506928..252b32361 100755 --- a/terraform/aws-ecs/terraform.tfvars.example +++ b/terraform/aws-ecs/terraform.tfvars.example @@ -1373,10 +1373,10 @@ aws_registry_federation_enabled = true # registry's nginx routes /validate to it (Keycloak RS256 bearer fast path); # everything else falls back to the Python auth-server. Leaving this unset # keeps /validate on the auth-server (unchanged, non-breaking). -# To enable: set go_validate_enabled = true AND +# To enable: set validate_fast_path_enabled = true AND # validate_upstream_url = "http://go-validate:8899". # --------------------------------------------------------------------------- -# go_validate_enabled = true -# go_validate_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-go-validate:latest" -# go_validate_audience = "account" # the aud claim your Keycloak tokens carry +# validate_fast_path_enabled = true +# validate_fast_path_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-go-validate:latest" +# validate_fast_path_audience = "account" # the aud claim your Keycloak tokens carry # validate_upstream_url = "http://go-validate:8899" diff --git a/terraform/aws-ecs/variables.tf b/terraform/aws-ecs/variables.tf index 6a7fb1efd..ccebeea45 100755 --- a/terraform/aws-ecs/variables.tf +++ b/terraform/aws-ecs/variables.tf @@ -2153,26 +2153,26 @@ variable "egress_secrets_manager_path_prefix" { } # Go /validate fast-path sidecar (issue #1652) - opt-in; see modules/mcp-gateway. -variable "go_validate_enabled" { +variable "validate_fast_path_enabled" { description = "Deploy the go-validate fast-path sidecar in the auth-server task and route nginx /validate to it. Default false (unchanged behavior)." type = bool default = false } -variable "go_validate_image_uri" { +variable "validate_fast_path_image_uri" { description = "Container image URI for the go-validate sidecar." type = string default = "public.ecr.aws/p3v1o3c6/go-validate:latest" } -variable "go_validate_audience" { +variable "validate_fast_path_audience" { description = "Expected token audience for the go-validate fast path. Empty -> fallback-only (safe)." type = string default = "" } variable "validate_upstream_url" { - description = "nginx /validate upstream for the registry. Empty -> auth-server. Set to http://go-validate:8899 when go_validate_enabled = true." + description = "nginx /validate upstream for the registry. Empty -> auth-server. Set to http://go-validate:8899 when validate_fast_path_enabled = true." type = string default = "" } From b725f1971ae1704e1b6848b3c73dcec8f352978a Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 01:17:03 +0000 Subject: [PATCH 12/26] docs(fast-path): explain how to set VALIDATE_AUDIENCE/ISSUER/JWKS The unified reference named the three fast-path values but did not tell an operator what to set them to. Add a subsection under Group 3 that: gives the one-liner to decode a real token's aud/iss, states the exact derived formats for issuer and JWKS URL (so they can be verified or overridden), and shows how to confirm the fast path engaged via /metrics (fastpath_ok vs fallback). Refs #1652 --- docs/unified-parameter-reference.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/unified-parameter-reference.md b/docs/unified-parameter-reference.md index aab945333..97689f949 100644 --- a/docs/unified-parameter-reference.md +++ b/docs/unified-parameter-reference.md @@ -127,6 +127,31 @@ Internal and external URLs for the auth server, plus internal JWT signing. | go-validate token audience | `VALIDATE_AUDIENCE` | `validate_fast_path_audience` | `auth-server.fastPath.audience` | Expected `aud` claim for the fast path. The only fast-path value that cannot be auto-derived (Keycloak varies it); commonly `account`. `JWKS_URL` and `VALIDATE_ISSUER` are auto-derived at runtime from `KEYCLOAK_URL` / `KEYCLOAK_EXTERNAL_URL` / `KEYCLOAK_REALM` when unset. Empty audience -> safe fallback-only. | | Internal JWT issuer | (constant in code) | — | `auth-server.app.jwtIssuer` | `iss` claim on internal service JWTs. | | Internal JWT audience | (constant in code) | — | `auth-server.app.jwtAudience` | `aud` claim on internal service JWTs. | + +### Setting the fast-path values (`VALIDATE_AUDIENCE` / `VALIDATE_ISSUER` / `JWKS_URL`) + +To turn the fast path on you normally set only the **enable switch** and the **audience**; the issuer and JWKS URL auto-derive from the `KEYCLOAK_*` config every deployment already has. +If the audience is wrong or unset, or the derived issuer does not match the token, the sidecar fails safe and every request is proxied to Python (correct results, no speedup) — so these values must match your tokens exactly for the feature to actually accelerate anything. + +**`VALIDATE_AUDIENCE` (you must set this — it cannot be derived).** +It is the `aud` claim your gateway's access tokens carry. To find the exact value, decode a real token your clients present (for a standard Keycloak realm this is usually `account`): + +```bash +# Paste a real bearer token; prints its aud and iss claims. +python3 -c "import sys,json,base64; p=sys.argv[1].split('.')[1]; p+='='*(-len(p)%4); c=json.loads(base64.urlsafe_b64decode(p)); print('aud =', c.get('aud')); print('iss =', c.get('iss'))" '' +``` + +If `aud` is a list, set `VALIDATE_AUDIENCE` to any one of its entries (the verifier accepts a match against any member). + +**`VALIDATE_ISSUER` (auto-derived — override only if it differs from your tokens' `iss`).** +Derived as `/realms/` (realm defaults to `mcp-gateway`). +It must equal the `iss` claim printed above; set it explicitly if your tokens are issued under a different host than the derived one (common in local testing, where tokens are minted against `localhost` but `KEYCLOAK_EXTERNAL_URL` points at the public host). + +**`JWKS_URL` (auto-derived — rarely overridden).** +Derived as `/realms//protocol/openid-connect/certs`. +Override only if the signing keys live at a non-standard path (e.g. a non-Keycloak IdP). + +After setting these, confirm the fast path actually engaged (not silently falling back): `curl -s http://:8899/metrics` should show `govalidate_fastpath_ok` climbing under load; if only `govalidate_fallback` climbs, the audience or issuer does not match your tokens. | App secret key **(secret)** | `SECRET_KEY` (required) | `secret_key` via `TF_VAR_*` / Secrets Manager (required) | `global.secretKey` (Helm chart auto-generates at install time if unset) | JWT signing + session-cookie signing + at-rest encryption of OAuth `id_token`. **Required** — auth_server and registry refuse to start without it (the previous per-replica random fallback caused `BadSignature` across replicas). Must be identical across all auth_server and registry replicas. Rotating invalidates stored creds and active sessions; rotation requires a process restart, not a SIGHUP reload. **Must be high-entropy (32+ bytes from a CSPRNG)** — read access to the `oauth_sessions_*` collection is equivalent to credential compromise unless this key is strong and never written to a logged location. Generate with `python3 -c 'import secrets; print(secrets.token_urlsafe(32))'`. | | Advertised OAuth scopes | `MCP_ADVERTISED_SCOPES` | — | `registry.app.mcpAdvertisedScopes` | Space-separated override for the `scopes_supported` array in the PRM (Protected Resource Metadata) document. When set, only these scopes are advertised to MCP discovery clients. Useful when the IdP performs RFC 7591 DCR and rejects scope names it does not recognize. Example: `openid email profile offline_access`. When unset, all scopes from the registry authorization config are advertised (default). | From 83534c9703eb1845903420be7349ba5d29412694 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 01:49:39 +0000 Subject: [PATCH 13/26] feat(fast-path): multi-issuer/audience parity, drop 'account', loud fail-safe Match the Python Keycloak provider exactly and make a broken fast path visible. Verifier parity (was a real gap): - Accept a LIST of issuers (external + internal + localhost realm URLs) and a LIST of audiences, matching on ANY member - Python already accepts all three issuers and [client_id, m2m_client_id, mcp-gateway]. The old single-issuer/single-audience check fell back on browser-login tokens and, worse, accepted 'account'. - Refuse 'account' as an audience even if set explicitly (strip + log): it rides on every realm token, so accepting it is a same-realm cross-client confused-deputy that Python rejects. A looser fast path accepting what Python rejects is a bypass. - Auto-derive both lists from KEYCLOAK_* (issuers from URL/EXTERNAL_URL/REALM, audiences from CLIENT_ID/M2M_CLIENT_ID + mcp-gateway) so operators set nothing. Explicit VALIDATE_ISSUER/VALIDATE_AUDIENCE take comma/space lists to override. Fail safe, loudly (logs + metrics), never crash: - Metrics gauges govalidate_fastpath_ready and govalidate_jwks_healthy plus govalidate_jwks_refresh_failures_total; ready=1 + jwks_healthy=0 = degraded. - ERROR log on the healthy->degraded JWKS transition, WARN on repeat, INFO on recovery; WARN FALLBACK-ONLY at startup naming exactly which vars are unset. - Fallback to Python stays correct throughout (safe), it is just now visible. Wiring: - compose + ECS sidecars now pass KEYCLOAK_CLIENT_ID/M2M so audiences derive; ECS switched from a single hardcoded issuer to full auto-derivation. Helm already passed KEYCLOAK_* via envFrom (no template change). - Examples enable the fast path by default: .env.example (compose) already routed to the sidecar; terraform.tfvars.example now sets validate_fast_path_enabled=true (the variable DEFAULT stays false, so upgrading stacks are unaffected). - Docs: unified reference + SECURITY_GUIDELINES (never accept an IdP universal default audience; a re-implemented verifier must reproduce the same allowlists). - Tests: multi-issuer/multi-audience match, account rejection, missingReason, degraded metrics, JWKS refresh-failure counter. go test 65.9%%, helm 180, tf ok. Refs #1652 --- .env.example | 18 ++- charts/auth-server/values.yaml | 17 ++- charts/mcp-gateway-registry-stack/values.yaml | 13 +- docker-compose.yml | 13 +- docs/SECURITY_GUIDELINES.md | 13 ++ docs/unified-parameter-reference.md | 39 ++++-- go-validate/config.go | 129 +++++++++++++++--- go-validate/config_test.go | 48 +++++-- go-validate/jwks.go | 27 +++- go-validate/jwks_test.go | 34 +++++ go-validate/jwt.go | 30 +++- go-validate/jwt_test.go | 38 +++++- go-validate/main.go | 66 ++++++++- go-validate/main_test.go | 49 ++++++- .../modules/mcp-gateway/ecs-services.tf | 17 ++- .../aws-ecs/modules/mcp-gateway/variables.tf | 2 +- terraform/aws-ecs/terraform.tfvars.example | 26 ++-- terraform/aws-ecs/variables.tf | 2 +- 18 files changed, 472 insertions(+), 109 deletions(-) create mode 100644 go-validate/jwks_test.go diff --git a/.env.example b/.env.example index 2b61f516f..2dfbad192 100644 --- a/.env.example +++ b/.env.example @@ -1933,15 +1933,19 @@ OPENBAO_TOKEN= # http://auth-server:8888 -> fast path OFF (bypass the sidecar, pure Python) VALIDATE_UPSTREAM_URL=http://go-validate:8899 # JWKS endpoint the sidecar fetches signing keys from (must be reachable from the -# sidecar container). Defaults to the internal Keycloak realm certs. -# Optional overrides - auto-derived from KEYCLOAK_URL / KEYCLOAK_EXTERNAL_URL / -# KEYCLOAK_REALM when left empty. Set only to override the derivation. +# sidecar container). Auto-derived from KEYCLOAK_URL + KEYCLOAK_REALM when empty. VALIDATE_JWKS_URL= -# Expected token issuer (the `iss` claim, usually the EXTERNAL Keycloak URL). Leave -# empty to run fallback-only (safe: every /validate proxied to Python, not accelerated). +# Accepted token issuers - the `iss` claim. Leave empty to auto-derive the SAME +# three URLs Python accepts (external + internal + localhost realm), so browser-login +# AND service/M2M tokens both fast-path. Accepts a comma/space-separated LIST to +# override; a token whose iss matches ANY member is accepted (others -> Python). VALIDATE_ISSUER= -# Expected token audience (the `aud` claim). Empty -> fallback-only. A mismatch is -# treated as "not our fast path" and deferred to Python, never a 401. +# Accepted token audiences - the `aud` claim. Leave empty to auto-derive the SAME set +# Python accepts (KEYCLOAK_CLIENT_ID + KEYCLOAK_M2M_CLIENT_ID + "mcp-gateway"). Accepts +# a comma/space-separated LIST; a token whose aud contains ANY member is accepted. +# Do NOT use "account": it rides on every realm token and is refused as an audience +# (accepting it is a cross-client confused-deputy; Python rejects it too). A non-matching +# audience is deferred to Python, never a 401. VALIDATE_AUDIENCE= # JWKS cache refresh interval (seconds). JWKS_REFRESH_SECONDS=300 diff --git a/charts/auth-server/values.yaml b/charts/auth-server/values.yaml index e13772d5a..cce534116 100644 --- a/charts/auth-server/values.yaml +++ b/charts/auth-server/values.yaml @@ -16,18 +16,21 @@ image: # Go /validate fast-path sidecar (issue #1652). OPT-IN: when enabled, a tiny Go # container runs alongside auth-server and the registry routes nginx /validate to # it. Leave disabled to keep /validate on the Python auth-server (unchanged). -# Fast path engages only when jwksUrl + issuer + audience are all set; otherwise -# it transparently proxies to the auth-server (safe fallback). +# When enabled the fast path engages automatically: jwksUrl, the accepted issuer +# list and the accepted audience list all AUTO-DERIVE from the KEYCLOAK_* env the +# sidecar already receives (matching the Python Keycloak provider). Tokens that do +# not match are transparently proxied to the auth-server (safe fallback). fastPath: enabled: false image: repository: go-validate # image name within the registry (shares global registry/tag) - # jwksUrl and issuer are AUTO-DERIVED at runtime from the KEYCLOAK_URL / - # KEYCLOAK_EXTERNAL_URL / KEYCLOAK_REALM env the sidecar already receives, so - # you normally only set enabled + audience. Set these to override the derivation. + # All three below AUTO-DERIVE at runtime; leave empty in the common case. Set only + # to OVERRIDE the derivation. issuer/audience accept a comma/space-separated LIST + # (a token matches on ANY member). "account" is refused as an audience (it rides on + # every realm token -> cross-client confused-deputy; Python rejects it too). jwksUrl: "" # override, e.g. https:///realms/mcp-gateway/protocol/openid-connect/certs - issuer: "" # override, e.g. https:///realms/mcp-gateway - audience: "" # REQUIRED to engage the fast path: the aud claim your tokens carry, e.g. "account" + issuer: "" # override list, e.g. "https://ext/realms/mcp-gateway http://keycloak:8080/realms/mcp-gateway" + audience: "" # override list, e.g. "mcp-gateway mcp-gateway-m2m"; empty derives from the KEYCLOAK client ids resources: requests: cpu: 25m diff --git a/charts/mcp-gateway-registry-stack/values.yaml b/charts/mcp-gateway-registry-stack/values.yaml index 34ada6319..e84f71860 100644 --- a/charts/mcp-gateway-registry-stack/values.yaml +++ b/charts/mcp-gateway-registry-stack/values.yaml @@ -29,8 +29,9 @@ global: oauthProviderSecretName: "oauth-provider-secret" # Single switch for the /validate fast-path sidecar (issue #1652). true deploys # the go-validate sidecar in the auth-server pod AND routes nginx /validate to it. - # You still set auth-server.fastPath.audience to engage the fast path (JWKS/issuer - # auto-derive from Keycloak); empty audience = safe fallback-only. + # The fast path then engages automatically: JWKS URL, accepted issuers and accepted + # audiences all auto-derive from the Keycloak config (matching Python). No audience + # needs to be set; non-matching tokens fall back safely to Python. fastPath: enabled: false # Existing secret references - set these to use pre-existing secrets instead of chart-managed ones. @@ -554,10 +555,12 @@ mcpgw: extraEnvFrom: [] # Auth server configuration auth-server: - # Fast path is toggled by global.fastPath.enabled above (single switch: - # deploys the sidecar AND routes /validate). Set the audience here to engage it. + # Fast path is toggled by global.fastPath.enabled above (single switch: deploys + # the sidecar AND routes /validate). It engages automatically once enabled - + # issuers/audiences/JWKS auto-derive from Keycloak. Override only to narrow the + # derived sets (comma/space-separated LISTs); never set audience to "account". fastPath: - # audience: "account" # required to accelerate; empty = safe fallback-only + # audience: "mcp-gateway mcp-gateway-m2m" # optional override; empty auto-derives app: replicas: 2 # set to > 1 replica for high availability # Session cookie Domain attribute. See registry.app.sessionCookieDomain diff --git a/docker-compose.yml b/docker-compose.yml index d12a38352..6ba849cd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -618,12 +618,19 @@ services: # Fast path engages only when JWKS_URL + VALIDATE_ISSUER + VALIDATE_AUDIENCE are # all set and match the tokens; otherwise every /validate request is transparently # proxied to the auth-server (correct, just not accelerated). - # KEYCLOAK_* let go-validate auto-derive JWKS_URL + VALIDATE_ISSUER when unset. + # KEYCLOAK_* let go-validate auto-derive JWKS_URL, the accepted issuer list + # (external + internal + localhost) and the accepted audience list (web + # client id + M2M client id + "mcp-gateway") when the VALIDATE_* overrides + # are left empty, matching the Python Keycloak provider exactly. - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} - KEYCLOAK_EXTERNAL_URL=${KEYCLOAK_EXTERNAL_URL:-} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-mcp-gateway} - # JWKS_URL + VALIDATE_ISSUER auto-derive from the KEYCLOAK_* vars above when - # left empty; set VALIDATE_JWKS_URL / VALIDATE_ISSUER in .env only to override. + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-mcp-gateway-web} + - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID:-mcp-gateway-m2m} + # JWKS_URL, VALIDATE_ISSUER and VALIDATE_AUDIENCE auto-derive from the + # KEYCLOAK_* vars above when left empty. VALIDATE_ISSUER / VALIDATE_AUDIENCE + # accept comma/space-separated LISTS to override; "account" is refused as an + # audience (it rides on every realm token -> cross-client confused-deputy). - JWKS_URL=${VALIDATE_JWKS_URL:-} - VALIDATE_ISSUER=${VALIDATE_ISSUER:-} - VALIDATE_AUDIENCE=${VALIDATE_AUDIENCE:-} diff --git a/docs/SECURITY_GUIDELINES.md b/docs/SECURITY_GUIDELINES.md index e61e3a750..cfe9160ec 100644 --- a/docs/SECURITY_GUIDELINES.md +++ b/docs/SECURITY_GUIDELINES.md @@ -364,6 +364,19 @@ sanitizer that isn't called) is equivalent to no check. resource in the tenant, is then accepted (confused deputy). Enforce audience against a config-driven allowlist with `verify_aud=True`; fail closed (reject) when the allowlist is unconfigured rather than accepting any audience. +- **Never accept the IdP's universal default audience.** Keycloak stamps + `account` on EVERY realm token regardless of which client requested it (other + IdPs have equivalents), so accepting it lets a token minted for a different + same-realm client be replayed against this gateway (cross-client confused + deputy). The accepted-audience allowlist must name THIS gateway (its client + ids + a gateway-specific custom audience) and must exclude the universal + default even if an operator supplies it (strip + log, don't honor). A + re-implementation of a verifier (e.g. a Go fast-path mirroring a Python one) + must reproduce the SAME issuer list and audience allowlist, not a looser + single-value check — a looser fast path that accepts what the authoritative + path rejects is a bypass. Match on ANY member of each list (a token's `iss` + legitimately varies by the host the client reached the IdP through: + external/internal/localhost). - **Never auto-grant groups/roles/admin from a code-shipped mapping.** A hardcoded `client_id → [groups]` (or default-admin) table in an M2M/SSO sync path silently confers privilege. Drive the mapping from config, fail closed to diff --git a/docs/unified-parameter-reference.md b/docs/unified-parameter-reference.md index 97689f949..9feafa539 100644 --- a/docs/unified-parameter-reference.md +++ b/docs/unified-parameter-reference.md @@ -124,34 +124,45 @@ Internal and external URLs for the auth server, plus internal JWT signing. | /validate upstream | `VALIDATE_UPSTREAM_URL` | `validate_upstream_url` | `registry.app.validateUpstreamUrl` | nginx `/validate` upstream. Empty -> auth-server (unchanged). Set to `http://go-validate:8899` (or the auth-server Service on 8899) to route the auth check through the go-validate fast-path sidecar. Only `/validate` is affected. (issue #1652) | | go-validate sidecar enabled | (runs by default via compose service) | `validate_fast_path_enabled` | `auth-server.fastPath.enabled` | Deploy the Go `/validate` fast-path sidecar in the auth-server task/pod. Opt-in on ECS/Helm (default off); on by default (fallback-safe) in docker-compose. | | go-validate image | (compose `go-validate` service build) | `validate_fast_path_image_uri` | `auth-server.fastPath.image.repository` | Container image for the sidecar. | -| go-validate token audience | `VALIDATE_AUDIENCE` | `validate_fast_path_audience` | `auth-server.fastPath.audience` | Expected `aud` claim for the fast path. The only fast-path value that cannot be auto-derived (Keycloak varies it); commonly `account`. `JWKS_URL` and `VALIDATE_ISSUER` are auto-derived at runtime from `KEYCLOAK_URL` / `KEYCLOAK_EXTERNAL_URL` / `KEYCLOAK_REALM` when unset. Empty audience -> safe fallback-only. | +| go-validate token audiences | `VALIDATE_AUDIENCE` | `validate_fast_path_audience` | `auth-server.fastPath.audience` | Accepted `aud` claims for the fast path (comma/space-separated LIST; a token matches on ANY member). Auto-derived when empty from `KEYCLOAK_CLIENT_ID` + `KEYCLOAK_M2M_CLIENT_ID` + `mcp-gateway`, matching the Python Keycloak provider. `account` is refused (rides on every realm token -> cross-client confused-deputy). `JWKS_URL` and the accepted issuer LIST (external + internal + localhost realm) are also auto-derived from `KEYCLOAK_URL` / `KEYCLOAK_EXTERNAL_URL` / `KEYCLOAK_REALM`. A non-matching token is deferred to Python (never a 401). | | Internal JWT issuer | (constant in code) | — | `auth-server.app.jwtIssuer` | `iss` claim on internal service JWTs. | | Internal JWT audience | (constant in code) | — | `auth-server.app.jwtAudience` | `aud` claim on internal service JWTs. | ### Setting the fast-path values (`VALIDATE_AUDIENCE` / `VALIDATE_ISSUER` / `JWKS_URL`) -To turn the fast path on you normally set only the **enable switch** and the **audience**; the issuer and JWKS URL auto-derive from the `KEYCLOAK_*` config every deployment already has. -If the audience is wrong or unset, or the derived issuer does not match the token, the sidecar fails safe and every request is proxied to Python (correct results, no speedup) — so these values must match your tokens exactly for the feature to actually accelerate anything. +**In the common case you set NONE of these** — you only flip the enable switch. All three auto-derive from the `KEYCLOAK_*` config every deployment already has, and the derivation deliberately mirrors the Python Keycloak provider so the fast path accepts exactly the tokens Python does (no more, no less). Set them only to narrow or override the derived sets. All three are matched as **lists**: a token is accepted when its `iss` equals ANY accepted issuer and its `aud` contains ANY accepted audience. A token that matches neither is deferred to Python (correct results, no speedup), never a 401. -**`VALIDATE_AUDIENCE` (you must set this — it cannot be derived).** -It is the `aud` claim your gateway's access tokens carry. To find the exact value, decode a real token your clients present (for a standard Keycloak realm this is usually `account`): +**Accepted issuers (`VALIDATE_ISSUER`) — auto-derived list.** +Derived as the three realm URLs Python accepts, so both browser-login and service/M2M tokens fast-path at once: + +- `/realms/` (browser logins, e.g. `https://mcpgateway.ddns.net/realms/mcp-gateway`) +- `/realms/` (internal service-to-service, e.g. `http://keycloak:8080/realms/mcp-gateway`) +- `http://localhost:8080/realms/` (host-minted / local dev) + +Override only to restrict the set; the value is a comma/space-separated list. + +**Accepted audiences (`VALIDATE_AUDIENCE`) — auto-derived list.** +Derived as `KEYCLOAK_CLIENT_ID` + `KEYCLOAK_M2M_CLIENT_ID` + `mcp-gateway`, matching Python's `accepted_audiences`. +**Do not set `account`.** It is present on *every* realm token regardless of which client requested it, so accepting it lets a token minted for a different client be replayed against the gateway (a same-realm cross-client confused-deputy). It is refused even if supplied explicitly (logged, then dropped), exactly as Python rejects it. + +**`JWKS_URL` — auto-derived, rarely overridden.** +Derived as `/realms//protocol/openid-connect/certs`. Override only for a non-standard key path. + +To inspect a real token when debugging (decode `aud`/`iss` — but note `account` in the list is expected and correctly NOT accepted): ```bash # Paste a real bearer token; prints its aud and iss claims. python3 -c "import sys,json,base64; p=sys.argv[1].split('.')[1]; p+='='*(-len(p)%4); c=json.loads(base64.urlsafe_b64decode(p)); print('aud =', c.get('aud')); print('iss =', c.get('iss'))" '' ``` -If `aud` is a list, set `VALIDATE_AUDIENCE` to any one of its entries (the verifier accepts a match against any member). - -**`VALIDATE_ISSUER` (auto-derived — override only if it differs from your tokens' `iss`).** -Derived as `/realms/` (realm defaults to `mcp-gateway`). -It must equal the `iss` claim printed above; set it explicitly if your tokens are issued under a different host than the derived one (common in local testing, where tokens are minted against `localhost` but `KEYCLOAK_EXTERNAL_URL` points at the public host). +After enabling, confirm the fast path actually engaged (it fails **safe but loud** — a broken fast path still proxies correctly to Python, and says so). `curl -s http://:8899/metrics` exposes: -**`JWKS_URL` (auto-derived — rarely overridden).** -Derived as `/realms//protocol/openid-connect/certs`. -Override only if the signing keys live at a non-standard path (e.g. a non-Keycloak IdP). +- `govalidate_fastpath_ready` — `1` if configured to accelerate, `0` if it will only proxy (misconfig). +- `govalidate_jwks_healthy` — `1` if the signing keyset is loaded. **`ready 1` + `jwks_healthy 0` = degraded** (keys unreachable, everything falling back) — alert on this. +- `govalidate_jwks_refresh_failures_total` — climbs when JWKS fetches fail. +- `govalidate_fastpath_ok` vs `govalidate_fallback` — under load `fastpath_ok` should climb; if only `fallback` climbs (with `ready 1` + `jwks_healthy 1`), the token's `iss`/`aud` are not in the accepted sets. -After setting these, confirm the fast path actually engaged (not silently falling back): `curl -s http://:8899/metrics` should show `govalidate_fastpath_ok` climbing under load; if only `govalidate_fallback` climbs, the audience or issuer does not match your tokens. +The sidecar also logs loudly: a `WARN ... FALLBACK-ONLY mode: missing ...` line at startup names exactly what is unset, `ERROR ... fast path DEGRADED` on a JWKS outage, and prints `accepted issuers=[...] | accepted audiences=[...]` when healthy. `/health` returns `503` while ready-but-degraded. It never crashes or drops requests — fallback to Python stays correct throughout. | App secret key **(secret)** | `SECRET_KEY` (required) | `secret_key` via `TF_VAR_*` / Secrets Manager (required) | `global.secretKey` (Helm chart auto-generates at install time if unset) | JWT signing + session-cookie signing + at-rest encryption of OAuth `id_token`. **Required** — auth_server and registry refuse to start without it (the previous per-replica random fallback caused `BadSignature` across replicas). Must be identical across all auth_server and registry replicas. Rotating invalidates stored creds and active sessions; rotation requires a process restart, not a SIGHUP reload. **Must be high-entropy (32+ bytes from a CSPRNG)** — read access to the `oauth_sessions_*` collection is equivalent to credential compromise unless this key is strong and never written to a logged location. Generate with `python3 -c 'import secrets; print(secrets.token_urlsafe(32))'`. | | Advertised OAuth scopes | `MCP_ADVERTISED_SCOPES` | — | `registry.app.mcpAdvertisedScopes` | Space-separated override for the `scopes_supported` array in the PRM (Protected Resource Metadata) document. When set, only these scopes are advertised to MCP discovery clients. Useful when the IdP performs RFC 7591 DCR and rejects scope names it does not recognize. Example: `openid email profile offline_access`. When unset, all scopes from the registry authorization config are advertised (default). | diff --git a/go-validate/config.go b/go-validate/config.go index 7f2741b74..fcc17dce0 100644 --- a/go-validate/config.go +++ b/go-validate/config.go @@ -13,11 +13,16 @@ import ( // reverse-proxied to the Python auth-server). This fails closed: when we cannot // safely verify a token ourselves, Python remains authoritative. type Config struct { - Listen string - SecretKey string - JWKSURL string - Issuer string - Audience string + Listen string + SecretKey string + JWKSURL string + // Issuers/Audiences are lists: a Keycloak token's iss is whatever host the + // client reached Keycloak through (external URL for browser logins, internal + // or localhost for service/M2M callers), and its aud names the specific + // client. Matching ANY member mirrors the Python Keycloak provider, which + // accepts three issuer URLs and a set of gateway-identifying audiences. + Issuers []string + Audiences []string FallbackURL string JWKSRefreshSec int ScopeTTLSec int @@ -64,6 +69,89 @@ func getenv(key, def string) string { return def } +// parseList splits a comma/whitespace-separated env value into a trimmed, +// de-duplicated, non-empty slice (order preserved). Lets VALIDATE_ISSUER / +// VALIDATE_AUDIENCE carry more than one value. +func parseList(s string) []string { + fields := strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + seen := map[string]bool{} + out := []string{} + for _, f := range fields { + if f == "" || seen[f] { + continue + } + seen[f] = true + out = append(out, f) + } + return out +} + +// deriveIssuers builds the same three issuer URLs the Python Keycloak provider +// accepts (external, internal, localhost), so a token minted against any of +// those hosts fast-paths instead of falling back. Empty bases are skipped. +func deriveIssuers(realm string) []string { + out := []string{} + add := func(base string) { + base = strings.TrimRight(base, "/") + if base == "" { + return + } + iss := fmt.Sprintf("%s/realms/%s", base, realm) + for _, e := range out { + if e == iss { + return + } + } + out = append(out, iss) + } + add(os.Getenv("KEYCLOAK_EXTERNAL_URL")) // browser logins + add(os.Getenv("KEYCLOAK_URL")) // internal service-to-service + add("http://localhost:8080") // local dev / host-minted tokens + return out +} + +// deriveAudiences builds the set of gateway-identifying audiences Python accepts +// (web client id, M2M client id, "mcp-gateway"). "account" is deliberately +// excluded: it rides on EVERY realm token, so accepting it would let a token +// minted for a different client in the same realm be replayed against the +// gateway (a same-realm cross-client confused-deputy). +func deriveAudiences() []string { + out := []string{} + add := func(a string) { + a = strings.TrimSpace(a) + if a == "" || a == "account" { + return + } + for _, e := range out { + if e == a { + return + } + } + out = append(out, a) + } + add(os.Getenv("KEYCLOAK_CLIENT_ID")) + add(os.Getenv("KEYCLOAK_M2M_CLIENT_ID")) + add("mcp-gateway") + return out +} + +// dropAccount removes "account" from an operator-supplied audience list and logs +// why, so copying the old VALIDATE_AUDIENCE=account value cannot silently reopen +// the cross-client confused-deputy that Python fails closed on. +func dropAccount(in []string) []string { + out := []string{} + for _, a := range in { + if a == "account" { + log.Printf("ignoring VALIDATE_AUDIENCE entry \"account\": it rides on every realm token; accepting it is a cross-client confused-deputy (matches Python, which rejects it)") + continue + } + out = append(out, a) + } + return out +} + // loadConfig reads configuration from the environment and validates the signing key. // It exits the process (fail closed) when SECRET_KEY is present but weak/invalid. func loadConfig() Config { @@ -71,8 +159,6 @@ func loadConfig() Config { Listen: getenv("GOVALIDATE_LISTEN", ":8899"), SecretKey: os.Getenv("SECRET_KEY"), JWKSURL: os.Getenv("JWKS_URL"), - Issuer: os.Getenv("VALIDATE_ISSUER"), - Audience: os.Getenv("VALIDATE_AUDIENCE"), FallbackURL: getenv("AUTH_FALLBACK_URL", "http://auth-server:8888"), JWKSRefreshSec: atoiDefault(os.Getenv("JWKS_REFRESH_SECONDS"), 300), ScopeTTLSec: atoiDefault(os.Getenv("SCOPE_SNAPSHOT_TTL_SECONDS"), 60), @@ -91,16 +177,21 @@ func loadConfig() Config { cfg.JWKSURL = fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", kc, realm) } } - if cfg.Issuer == "" { - // Tokens carry the issuer of the URL the client used, usually the external - // Keycloak URL; fall back to the internal URL when no external is set. - iss := strings.TrimRight(os.Getenv("KEYCLOAK_EXTERNAL_URL"), "/") - if iss == "" { - iss = strings.TrimRight(os.Getenv("KEYCLOAK_URL"), "/") - } - if iss != "" { - cfg.Issuer = fmt.Sprintf("%s/realms/%s", iss, realm) - } + + // Issuers: an explicit VALIDATE_ISSUER list wins; otherwise derive the same + // external/internal/localhost issuer URLs Python accepts. A token matches when + // its iss equals ANY member, so both browser-login and service tokens fast-path. + cfg.Issuers = parseList(os.Getenv("VALIDATE_ISSUER")) + if len(cfg.Issuers) == 0 { + cfg.Issuers = deriveIssuers(realm) + } + + // Audiences: an explicit VALIDATE_AUDIENCE list wins (with "account" stripped, + // fail closed); otherwise derive the gateway-identifying audiences Python + // accepts. A token matches when its aud contains ANY member. + cfg.Audiences = dropAccount(parseList(os.Getenv("VALIDATE_AUDIENCE"))) + if len(cfg.Audiences) == 0 { + cfg.Audiences = deriveAudiences() } // B3: validate the signing secret. If a secret is provided at all it must be @@ -114,8 +205,8 @@ func loadConfig() Config { // Fast path requires everything needed to verify AND mint safely. cfg.FastPathReady = cfg.SecretKey != "" && cfg.JWKSURL != "" && - cfg.Issuer != "" && - cfg.Audience != "" + len(cfg.Issuers) > 0 && + len(cfg.Audiences) > 0 return cfg } diff --git a/go-validate/config_test.go b/go-validate/config_test.go index 29e622e4f..889811deb 100644 --- a/go-validate/config_test.go +++ b/go-validate/config_test.go @@ -2,36 +2,62 @@ package main import "testing" -func TestLoadConfig_DerivesJWKSandIssuerFromKeycloak(t *testing.T) { +func TestLoadConfig_DerivesJWKSandIssuersFromKeycloak(t *testing.T) { t.Setenv("SECRET_KEY", "unit-test-secret-32-bytes-xxxxxxxxxx") t.Setenv("KEYCLOAK_URL", "http://keycloak:8080/") t.Setenv("KEYCLOAK_EXTERNAL_URL", "https://kc.example.com") t.Setenv("KEYCLOAK_REALM", "mcp-gateway") - t.Setenv("VALIDATE_AUDIENCE", "account") - // JWKS_URL / VALIDATE_ISSUER intentionally unset -> must be derived + t.Setenv("KEYCLOAK_CLIENT_ID", "mcp-gateway-web") + t.Setenv("KEYCLOAK_M2M_CLIENT_ID", "mcp-gateway-m2m") + // JWKS_URL / VALIDATE_ISSUER / VALIDATE_AUDIENCE unset -> must all be derived t.Setenv("JWKS_URL", "") t.Setenv("VALIDATE_ISSUER", "") + t.Setenv("VALIDATE_AUDIENCE", "") cfg := loadConfig() if cfg.JWKSURL != "http://keycloak:8080/realms/mcp-gateway/protocol/openid-connect/certs" { t.Fatalf("JWKS not derived: %q", cfg.JWKSURL) } - if cfg.Issuer != "https://kc.example.com/realms/mcp-gateway" { - t.Fatalf("issuer not derived from external url: %q", cfg.Issuer) + // All three Python-parity issuers must be present (external, internal, localhost). + for _, want := range []string{ + "https://kc.example.com/realms/mcp-gateway", + "http://keycloak:8080/realms/mcp-gateway", + "http://localhost:8080/realms/mcp-gateway", + } { + if !containsStr(cfg.Issuers, want) { + t.Fatalf("derived issuers %v missing %q", cfg.Issuers, want) + } + } + // Audiences derived from the client ids + "mcp-gateway"; never "account". + for _, want := range []string{"mcp-gateway-web", "mcp-gateway-m2m", "mcp-gateway"} { + if !containsStr(cfg.Audiences, want) { + t.Fatalf("derived audiences %v missing %q", cfg.Audiences, want) + } + } + if containsStr(cfg.Audiences, "account") { + t.Fatal("derived audiences must never include \"account\" (cross-client confused-deputy)") } if !cfg.FastPathReady { - t.Fatal("fast path should be ready after derivation + audience") + t.Fatal("fast path should be ready after derivation") } } -func TestLoadConfig_ExplicitValuesWin(t *testing.T) { +func TestLoadConfig_ExplicitListsWin(t *testing.T) { t.Setenv("SECRET_KEY", "unit-test-secret-32-bytes-xxxxxxxxxx") t.Setenv("KEYCLOAK_URL", "http://keycloak:8080") t.Setenv("JWKS_URL", "https://explicit/certs") - t.Setenv("VALIDATE_ISSUER", "https://explicit/iss") - t.Setenv("VALIDATE_AUDIENCE", "account") + // Comma/space-separated lists with an interspersed "account" that must be dropped. + t.Setenv("VALIDATE_ISSUER", "https://a/iss, https://b/iss") + t.Setenv("VALIDATE_AUDIENCE", "account, my-aud") cfg := loadConfig() - if cfg.JWKSURL != "https://explicit/certs" || cfg.Issuer != "https://explicit/iss" { - t.Fatalf("explicit values must win: %q %q", cfg.JWKSURL, cfg.Issuer) + if cfg.JWKSURL != "https://explicit/certs" { + t.Fatalf("explicit JWKS must win: %q", cfg.JWKSURL) + } + if len(cfg.Issuers) != 2 || cfg.Issuers[0] != "https://a/iss" || cfg.Issuers[1] != "https://b/iss" { + t.Fatalf("explicit issuer list must win: %v", cfg.Issuers) + } + // "account" stripped even when set explicitly; only "my-aud" survives. + if len(cfg.Audiences) != 1 || cfg.Audiences[0] != "my-aud" { + t.Fatalf("account must be dropped from explicit audiences: %v", cfg.Audiences) } } diff --git a/go-validate/jwks.go b/go-validate/jwks.go index 8306d9230..6c9808a1e 100644 --- a/go-validate/jwks.go +++ b/go-validate/jwks.go @@ -26,10 +26,11 @@ type jwksDoc struct { // keysetCache holds kid -> *rsa.PublicKey behind an atomic.Pointer so request // handlers read lock-free. On refresh failure it retains the last-good keyset (B4). type keysetCache struct { - url string - keys atomic.Pointer[map[string]*rsa.PublicKey] - client *http.Client - healthy atomic.Bool + url string + keys atomic.Pointer[map[string]*rsa.PublicKey] + client *http.Client + healthy atomic.Bool + refreshFails atomic.Int64 // total failed refreshes, exposed at /metrics } // key returns the public key for a kid, or nil if absent. @@ -105,14 +106,28 @@ func newKeysetCache(url string, refreshSec int) *keysetCache { client: &http.Client{Timeout: 5 * time.Second}, } if err := k.refresh(); err != nil { - log.Printf("WARN initial JWKS load failed (serving via fallback until it loads): %v", err) + k.refreshFails.Add(1) + // Loud: the fast path cannot verify ANY token until the keyset loads, so + // every request falls back to Python (correct, not accelerated). + log.Printf("ERROR go-validate: initial JWKS load FAILED from %s - fast path is DEGRADED (all /validate proxied to Python) until it loads: %v", url, err) } go func() { ticker := time.NewTicker(time.Duration(refreshSec) * time.Second) defer ticker.Stop() for range ticker.C { + wasHealthy := k.healthy.Load() if err := k.refresh(); err != nil { - log.Printf("WARN JWKS refresh failed, keeping last-good keyset: %v", err) + k.refreshFails.Add(1) + // Log the healthy->unhealthy transition at ERROR (a working fast + // path just broke); keep repeating failures at WARN so the signal + // stays visible without flooding. + if wasHealthy { + log.Printf("ERROR go-validate: JWKS refresh FAILED and the keyset is now STALE - fast path DEGRADED, all /validate falling back to Python until recovery: %v", err) + } else { + log.Printf("WARN go-validate: JWKS still failing (fast path degraded), keeping last-good keyset: %v", err) + } + } else if !wasHealthy { + log.Printf("INFO go-validate: JWKS recovered - fast path healthy again") } } }() diff --git a/go-validate/jwks_test.go b/go-validate/jwks_test.go new file mode 100644 index 000000000..a1d6b3afc --- /dev/null +++ b/go-validate/jwks_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestKeyset_RefreshFailIncrementsCounterAndStaysUnhealthy verifies the loud +// fail-safe: a failed JWKS fetch marks the cache unhealthy and bumps the counter +// exposed at /metrics, so a broken fast path is visible rather than silent. +func TestKeyset_RefreshFailIncrementsCounterAndStaysUnhealthy(t *testing.T) { + // Server that always 500s -> refresh must fail. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + k := &keysetCache{url: srv.URL, client: &http.Client{Timeout: 2 * time.Second}} + if err := k.refresh(); err == nil { + t.Fatal("refresh against a 500 server should fail") + } + k.refreshFails.Add(1) // callers increment on failure (mirrors newKeysetCache) + if k.healthy.Load() { + t.Fatal("keyset must be unhealthy after a failed refresh") + } + if k.refreshFails.Load() != 1 { + t.Fatalf("refreshFails should be 1, got %d", k.refreshFails.Load()) + } + if k.key("anykid") != nil { + t.Fatal("no keys should be present after a failed initial load") + } +} diff --git a/go-validate/jwt.go b/go-validate/jwt.go index ddcb0b649..1b1bf8b80 100644 --- a/go-validate/jwt.go +++ b/go-validate/jwt.go @@ -85,10 +85,30 @@ func (c *Claims) audContains(want string) bool { return false } +// audMatchesAny reports whether the token audience contains ANY accepted value. +func (c *Claims) audMatchesAny(accepted []string) bool { + for _, a := range accepted { + if c.audContains(a) { + return true + } + } + return false +} + +// containsStr reports whether want is in list. +func containsStr(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + // verifyRS256 verifies an RS256 JWT against the cached keyset and enforces // iss/aud/exp from config (never from the token). It returns the parsed claims on // success, or a sentinel error telling the caller whether to fall back or 401. -func verifyRS256(token string, ks *keysetCache, issuer, audience string) (*Claims, error) { +func verifyRS256(token string, ks *keysetCache, issuers, audiences []string) (*Claims, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, errNotJWT @@ -131,11 +151,13 @@ func verifyRS256(token string, ks *keysetCache, issuer, audience string) (*Claim } _ = json.Unmarshal(payloadBytes, &c.raw) - // Enforce iss/aud/exp (config-driven, fail closed). - if c.Iss != issuer { + // Enforce iss/aud/exp (config-driven, fail closed). iss must match ANY + // accepted issuer; aud must contain ANY accepted audience (mirrors the + // Python Keycloak provider's valid_issuers / accepted_audiences lists). + if !containsStr(issuers, c.Iss) { return nil, errUnknownKey // different issuer -> let Python handle it } - if !c.audContains(audience) { + if !c.audMatchesAny(audiences) { // Audience is a policy decision, not proof of forgery. Defer to Python // (authoritative) rather than 401 so we never reject a token the full // handler would have accepted. Only a bad signature or expiry -> 401. diff --git a/go-validate/jwt_test.go b/go-validate/jwt_test.go index c222533d6..42f0a4aeb 100644 --- a/go-validate/jwt_test.go +++ b/go-validate/jwt_test.go @@ -60,14 +60,14 @@ func TestVerifyRS256(t *testing.T) { t.Run("valid", func(t *testing.T) { tok := mintRS256(t, priv, "kid1", baseClaims()) - c, err := verifyRS256(tok, ks, tIss, tAud) + c, err := verifyRS256(tok, ks, []string{tIss}, []string{tAud}) if err != nil || c == nil || c.Username != "svc" || c.Azp != "svc-client" { t.Fatalf("valid token failed: err=%v claims=%+v", err, c) } }) t.Run("bad signature -> 401 (errInvalidToken)", func(t *testing.T) { tok := mintRS256(t, priv, "kid1", baseClaims()) - if _, err := verifyRS256(tok[:len(tok)-2]+"xx", ks, tIss, tAud); err != errInvalidToken { + if _, err := verifyRS256(tok[:len(tok)-2]+"xx", ks, []string{tIss}, []string{tAud}); err != errInvalidToken { t.Fatalf("want errInvalidToken, got %v", err) } }) @@ -75,33 +75,57 @@ func TestVerifyRS256(t *testing.T) { c := baseClaims() c["exp"] = time.Now().Add(-time.Hour).Unix() tok := mintRS256(t, priv, "kid1", c) - if _, err := verifyRS256(tok, ks, tIss, tAud); err != errInvalidToken { + if _, err := verifyRS256(tok, ks, []string{tIss}, []string{tAud}); err != errInvalidToken { t.Fatalf("want errInvalidToken (expired), got %v", err) } }) t.Run("unknown kid -> fallback (errUnknownKey)", func(t *testing.T) { tok := mintRS256(t, priv, "otherkid", baseClaims()) - if _, err := verifyRS256(tok, ks, tIss, tAud); err != errUnknownKey { + if _, err := verifyRS256(tok, ks, []string{tIss}, []string{tAud}); err != errUnknownKey { t.Fatalf("want errUnknownKey, got %v", err) } }) t.Run("wrong issuer -> fallback", func(t *testing.T) { tok := mintRS256(t, priv, "kid1", baseClaims()) - if _, err := verifyRS256(tok, ks, "https://other", tAud); err != errUnknownKey { + if _, err := verifyRS256(tok, ks, []string{"https://other"}, []string{tAud}); err != errUnknownKey { t.Fatalf("want errUnknownKey (iss), got %v", err) } }) t.Run("wrong audience -> fallback (never 401)", func(t *testing.T) { tok := mintRS256(t, priv, "kid1", baseClaims()) - if _, err := verifyRS256(tok, ks, tIss, "different-aud"); err != errUnknownKey { + if _, err := verifyRS256(tok, ks, []string{tIss}, []string{"different-aud"}); err != errUnknownKey { t.Fatalf("want errUnknownKey (aud), got %v", err) } }) t.Run("not a JWT -> fallback", func(t *testing.T) { - if _, err := verifyRS256("not.a", ks, tIss, tAud); err != errNotJWT { + if _, err := verifyRS256("not.a", ks, []string{tIss}, []string{tAud}); err != errNotJWT { t.Fatalf("want errNotJWT, got %v", err) } }) + t.Run("iss matches ANY issuer in the list", func(t *testing.T) { + // token minted with tIss; tIss is the SECOND accepted issuer. + tok := mintRS256(t, priv, "kid1", baseClaims()) + issuers := []string{"https://external/realms/mcp-gateway", tIss} + if _, err := verifyRS256(tok, ks, issuers, []string{tAud}); err != nil { + t.Fatalf("token iss should match a non-first list member: %v", err) + } + }) + t.Run("aud matches ANY audience in the list", func(t *testing.T) { + c := baseClaims() + c["aud"] = []string{"mcp-gateway", "account"} // realm token shape + tok := mintRS256(t, priv, "kid1", c) + // Accept mcp-gateway (parity), NOT account -> must still verify on mcp-gateway. + if _, err := verifyRS256(tok, ks, []string{tIss}, []string{"mcp-gateway"}); err != nil { + t.Fatalf("aud list member should match: %v", err) + } + // A token carrying ONLY account must NOT verify when account is not accepted. + c2 := baseClaims() + c2["aud"] = "account" + tok2 := mintRS256(t, priv, "kid1", c2) + if _, err := verifyRS256(tok2, ks, []string{tIss}, []string{"mcp-gateway"}); err != errUnknownKey { + t.Fatalf("account-only token must fall back when account not accepted, got %v", err) + } + }) } // --- audContains -------------------------------------------------------- diff --git a/go-validate/main.go b/go-validate/main.go index 003391272..5483185c8 100644 --- a/go-validate/main.go +++ b/go-validate/main.go @@ -46,6 +46,28 @@ var perUserIdPMethods = map[string]bool{ "jwt": true, "boto3": true, } +// missingReason lists which fast-path prerequisites are unset, so a FALLBACK-ONLY +// startup log tells the operator exactly what to fix instead of just "not ready". +func missingReason(cfg Config) string { + missing := []string{} + if cfg.SecretKey == "" { + missing = append(missing, "SECRET_KEY") + } + if cfg.JWKSURL == "" { + missing = append(missing, "JWKS_URL (or KEYCLOAK_URL to derive it)") + } + if len(cfg.Issuers) == 0 { + missing = append(missing, "VALIDATE_ISSUER (or KEYCLOAK_URL/KEYCLOAK_EXTERNAL_URL to derive it)") + } + if len(cfg.Audiences) == 0 { + missing = append(missing, "VALIDATE_AUDIENCE (or KEYCLOAK_CLIENT_ID/KEYCLOAK_M2M_CLIENT_ID to derive it)") + } + if len(missing) == 0 { + return "no missing config" + } + return "missing " + strings.Join(missing, ", ") +} + // canonicalAuthMethod returns the egress-principal bucket stamped into the internal // tokens. Per-user IdP methods canonicalize to "oauth2"; others pass through. func canonicalAuthMethod(method string) string { @@ -153,7 +175,7 @@ func (s *server) handleValidate(w http.ResponseWriter, r *http.Request) { return } - claims, err := verifyRS256(tok, s.ks, s.cfg.Issuer, s.cfg.Audience) + claims, err := verifyRS256(tok, s.ks, s.cfg.Issuers, s.cfg.Audiences) switch err { case nil: // verified below @@ -247,11 +269,39 @@ func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok\n")) } +// handleMetrics exposes counters plus two health gauges so a "fast path silently +// not working" state is visible on a dashboard, not just in logs: govalidate_ +// fastpath_ready (is it configured to accelerate at all) and govalidate_jwks_ +// healthy (can it currently verify tokens). When ready=1 but jwks_healthy=0, the +// fast path is degraded and everything is falling back to Python — alert on that. func (s *server) handleMetrics(w http.ResponseWriter, _ *http.Request) { + ready := int64(0) + if s.cfg.FastPathReady { + ready = 1 + } + jwksHealthy := int64(0) + refreshFails := int64(0) + if s.ks != nil { + if s.ks.healthy.Load() { + jwksHealthy = 1 + } + refreshFails = s.ks.refreshFails.Load() + } w.Header().Set("Content-Type", "text/plain; version=0.0.4") _, _ = w.Write([]byte( - "govalidate_fastpath_ok " + itoa(s.stats.fastOK.Load()) + "\n" + + "# HELP govalidate_fastpath_ready 1 if the fast path is configured (else all requests proxy to Python)\n" + + "# TYPE govalidate_fastpath_ready gauge\n" + + "govalidate_fastpath_ready " + itoa(ready) + "\n" + + "# HELP govalidate_jwks_healthy 1 if the JWKS keyset is currently loaded (else fast path degraded)\n" + + "# TYPE govalidate_jwks_healthy gauge\n" + + "govalidate_jwks_healthy " + itoa(jwksHealthy) + "\n" + + "# TYPE govalidate_jwks_refresh_failures_total counter\n" + + "govalidate_jwks_refresh_failures_total " + itoa(refreshFails) + "\n" + + "# TYPE govalidate_fastpath_ok counter\n" + + "govalidate_fastpath_ok " + itoa(s.stats.fastOK.Load()) + "\n" + + "# TYPE govalidate_unauthorized counter\n" + "govalidate_unauthorized " + itoa(s.stats.unauth.Load()) + "\n" + + "# TYPE govalidate_fallback counter\n" + "govalidate_fallback " + itoa(s.stats.fallback.Load()) + "\n")) } @@ -304,10 +354,14 @@ func main() { mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/metrics", s.handleMetrics) - mode := "fast-path" - if !cfg.FastPathReady { - mode = "FALLBACK-ONLY (SECRET_KEY/JWKS_URL/VALIDATE_ISSUER/VALIDATE_AUDIENCE not all set)" + if cfg.FastPathReady { + log.Printf("go-validate listening on %s | mode=fast-path | fallback=%s", cfg.Listen, cfg.FallbackURL) + log.Printf("accepted issuers=%v | accepted audiences=%v", cfg.Issuers, cfg.Audiences) + } else { + // Loud: an operator who deployed the sidecar expecting acceleration must + // see WHY it is only proxying, not discover it from a flat latency graph. + log.Printf("WARN go-validate listening on %s in FALLBACK-ONLY mode: %s. Every /validate request is proxied to Python (correct, NOT accelerated). fallback=%s", + cfg.Listen, missingReason(cfg), cfg.FallbackURL) } - log.Printf("go-validate listening on %s | mode=%s | fallback=%s", cfg.Listen, mode, cfg.FallbackURL) log.Fatal(http.ListenAndServe(cfg.Listen, mux)) } diff --git a/go-validate/main_test.go b/go-validate/main_test.go index c75f8f06b..a8a0a2ef9 100644 --- a/go-validate/main_test.go +++ b/go-validate/main_test.go @@ -214,7 +214,7 @@ func TestHandleValidate_FastPathSuccess(t *testing.T) { s := &server{ cfg: Config{ FastPathReady: true, SecretKey: "unit-test-secret-32-bytes-xxxxxxxxxx", - Issuer: tIss, Audience: tAud, AuthMethod: "keycloak", + Issuers: []string{tIss}, Audiences: []string{tAud}, AuthMethod: "keycloak", }, ks: ks, scopes: res, @@ -245,7 +245,7 @@ func TestHandleValidate_RecognizedInvalidIs401(t *testing.T) { priv, _ := rsa.GenerateKey(rand.Reader, 2048) ks := testKeyset(&priv.PublicKey, "kid1") s := &server{ - cfg: Config{FastPathReady: true, SecretKey: "unit-test-secret-32-bytes-xxxxxxxxxx", Issuer: tIss, Audience: tAud}, + cfg: Config{FastPathReady: true, SecretKey: "unit-test-secret-32-bytes-xxxxxxxxxx", Issuers: []string{tIss}, Audiences: []string{tAud}}, ks: ks, scopes: &scopeResolver{}, fallback: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not fall back on bad sig") }), @@ -273,6 +273,51 @@ func TestHealthAndMetrics(t *testing.T) { if rr2.Code != http.StatusOK || rr2.Body.Len() == 0 { t.Fatalf("metrics want 200 + body, got %d len=%d", rr2.Code, rr2.Body.Len()) } + // Fallback-only sidecar reports ready=0. + body := rr2.Body.String() + for _, want := range []string{ + "govalidate_fastpath_ready 0", + "govalidate_jwks_healthy 0", + "govalidate_jwks_refresh_failures_total 0", + "govalidate_fallback 0", + } { + if !contains(body, want) { + t.Fatalf("metrics missing %q\n%s", want, body) + } + } +} + +func TestMetrics_DegradedWhenReadyButJWKSUnhealthy(t *testing.T) { + ks := &keysetCache{} // healthy defaults to false, keys nil + s := &server{cfg: Config{FastPathReady: true}, ks: ks} + // /health must fail LOUDLY (503) when ready but the keyset is unhealthy. + rr := httptest.NewRecorder() + s.handleHealth(rr, httptest.NewRequest("GET", "/health", nil)) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("degraded health want 503, got %d", rr.Code) + } + rr2 := httptest.NewRecorder() + s.handleMetrics(rr2, httptest.NewRequest("GET", "/metrics", nil)) + body := rr2.Body.String() + // ready=1 + jwks_healthy=0 is the "silently degraded" signal to alert on. + if !contains(body, "govalidate_fastpath_ready 1") || !contains(body, "govalidate_jwks_healthy 0") { + t.Fatalf("expected ready=1 + jwks_healthy=0 (degraded), got:\n%s", body) + } +} + +func TestMissingReason(t *testing.T) { + // Nothing set -> every prerequisite named. + got := missingReason(Config{}) + for _, want := range []string{"SECRET_KEY", "JWKS_URL", "VALIDATE_ISSUER", "VALIDATE_AUDIENCE"} { + if !contains(got, want) { + t.Fatalf("missingReason should name %q, got %q", want, got) + } + } + // Fully configured -> no missing config. + ready := Config{SecretKey: "x", JWKSURL: "u", Issuers: []string{"i"}, Audiences: []string{"a"}} + if missingReason(ready) != "no missing config" { + t.Fatalf("fully configured should report none, got %q", missingReason(ready)) + } } func TestBuildMongoURI(t *testing.T) { diff --git a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf index 72b2cfd76..4f26ad210 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf @@ -743,14 +743,21 @@ module "ecs_service_auth" { protocol = "tcp" }] - # Fast path engages only when JWKS_URL + VALIDATE_ISSUER + VALIDATE_AUDIENCE - # are all set (and match the tokens); otherwise it reverse-proxies to the - # auth-server (same task, loopback), which is correct but not accelerated. + # The sidecar auto-derives JWKS_URL, the accepted issuer list and the + # accepted audience list from the KEYCLOAK_* vars below (same values the + # auth-server uses), matching the Python Keycloak provider. It engages when + # SECRET_KEY + a reachable Keycloak are present; otherwise it reverse-proxies + # to the auth-server (same task, loopback), which is correct but not + # accelerated. validate_fast_path_audience overrides the derived audience + # list only when set (never "account" -> refused as a confused-deputy). environment = [ { name = "GOVALIDATE_LISTEN", value = ":8899" }, { name = "AUTH_FALLBACK_URL", value = "http://localhost:18888" }, - { name = "JWKS_URL", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}/realms/mcp-gateway/protocol/openid-connect/certs" : "" }, - { name = "VALIDATE_ISSUER", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}/realms/mcp-gateway" : "" }, + { name = "KEYCLOAK_URL", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}" : "" }, + { name = "KEYCLOAK_EXTERNAL_URL", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}" : "" }, + { name = "KEYCLOAK_REALM", value = "mcp-gateway" }, + { name = "KEYCLOAK_CLIENT_ID", value = "mcp-gateway-web" }, + { name = "KEYCLOAK_M2M_CLIENT_ID", value = "mcp-gateway-m2m" }, { name = "VALIDATE_AUDIENCE", value = var.validate_fast_path_audience }, { name = "DOCUMENTDB_HOST", value = var.documentdb_endpoint }, { name = "DOCUMENTDB_PORT", value = "27017" }, diff --git a/terraform/aws-ecs/modules/mcp-gateway/variables.tf b/terraform/aws-ecs/modules/mcp-gateway/variables.tf index ab3c76cbe..cb92b619a 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/variables.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/variables.tf @@ -1936,7 +1936,7 @@ variable "validate_fast_path_image_uri" { } variable "validate_fast_path_audience" { - description = "Expected token audience (aud) for the go-validate fast path. Empty leaves the sidecar in safe fallback-only mode (transparent proxy to Python)." + description = "Optional override for the go-validate fast-path accepted audiences (comma/space-separated). Empty auto-derives from the Keycloak client ids + \"mcp-gateway\", matching Python. \"account\" is refused (cross-client confused-deputy)." type = string default = "" } diff --git a/terraform/aws-ecs/terraform.tfvars.example b/terraform/aws-ecs/terraform.tfvars.example index 252b32361..9f5aaeac5 100755 --- a/terraform/aws-ecs/terraform.tfvars.example +++ b/terraform/aws-ecs/terraform.tfvars.example @@ -1368,15 +1368,19 @@ aws_registry_federation_enabled = true # egress_nginx_marker_secret = "" # --------------------------------------------------------------------------- -# Go /validate fast-path sidecar (issue #1652) - OPT-IN, default off. -# When enabled, a tiny Go sidecar runs in the auth-server task and the -# registry's nginx routes /validate to it (Keycloak RS256 bearer fast path); -# everything else falls back to the Python auth-server. Leaving this unset -# keeps /validate on the auth-server (unchanged, non-breaking). -# To enable: set validate_fast_path_enabled = true AND -# validate_upstream_url = "http://go-validate:8899". +# Go /validate fast-path sidecar (issue #1652) - ENABLED in this example. +# A tiny Go sidecar runs in the auth-server task and the registry's nginx routes +# /validate to it (Keycloak RS256 bearer fast path); everything else falls back to +# the Python auth-server. Once enabled the fast path engages automatically: JWKS, +# accepted issuers and accepted audiences all auto-derive from the Keycloak config +# (matching Python). No audience needs to be set; image_uri defaults to the public +# ECR go-validate image, and validate_upstream_url auto-routes to the sidecar. +# +# NOTE: the variable DEFAULT is false, so existing stacks that upgrade WITHOUT +# copying this line are unaffected (non-breaking). This example turns it on so new +# deployments get the fast path out of the box. Set to false to keep pure Python. # --------------------------------------------------------------------------- -# validate_fast_path_enabled = true -# validate_fast_path_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-go-validate:latest" -# validate_fast_path_audience = "account" # the aud claim your Keycloak tokens carry -# validate_upstream_url = "http://go-validate:8899" +validate_fast_path_enabled = true +# validate_fast_path_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-go-validate:latest" # optional; defaults to the public ECR image +# validate_fast_path_audience = "mcp-gateway mcp-gateway-m2m" # optional override; empty auto-derives (never "account") +# validate_upstream_url = "http://go-validate:8899" # optional; auto-set when enabled diff --git a/terraform/aws-ecs/variables.tf b/terraform/aws-ecs/variables.tf index ccebeea45..597400f4f 100755 --- a/terraform/aws-ecs/variables.tf +++ b/terraform/aws-ecs/variables.tf @@ -2166,7 +2166,7 @@ variable "validate_fast_path_image_uri" { } variable "validate_fast_path_audience" { - description = "Expected token audience for the go-validate fast path. Empty -> fallback-only (safe)." + description = "Optional override for the go-validate fast-path accepted audiences (comma/space-separated). Empty auto-derives from the Keycloak client ids + \"mcp-gateway\", matching Python. \"account\" is refused (cross-client confused-deputy)." type = string default = "" } From e637e02c9cbeaf50fc0f2104f1b36d74a9344821 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 17:24:14 +0000 Subject: [PATCH 14/26] feat(fast-path): add Cognito verifier (multi-provider fast path) ECS uses Cognito, so the fast path now supports it alongside Keycloak, mirroring auth_server/providers/cognito.py exactly. - Provider select: AUTH_PROVIDER (the auth-server already sets it) picks the verifier; auto-detected from COGNITO_USER_POOL_ID / KEYCLOAK_URL when unset. Only keycloak + cognito are fast-pathed; other IdPs stay fallback-only (safe). - Cognito verify (cognito.go): issuer https://cognito-idp..amazonaws.com/ , JWKS at /.well-known/jwks.json. Access tokens only (token_use=access; id/login tokens defer to Python). Access tokens are client_id-bound (no aud): client_id must be in the allowlist (web + IDE + M2M ids), with a '*' M2M wildcard honored for machine tokens (no username) only - never widening user tokens. All auto-derived from COGNITO_* / AWS_REGION. - Scopes match server.py: cognito:groups -> group->scope mapping (same DocumentDB path); no-group / machine token -> the token's own scope claim. - Refactor: extract parseVerifyDecode() (shared RS256 crypto half); handleValidate now branches via resolveFastPath() -> resolveKeycloak / resolveCognito, sharing the header/mint tail. Keycloak path behavior unchanged. - Wiring: ECS sidecar + compose now pass AUTH_PROVIDER + AWS_REGION + COGNITO_*; Helm sidecar already gets them via envFrom the auth secret (no template change). Enablement is the SAME switch (validate_fast_path_enabled / fastPath) - the provider is auto-detected, no new enable param needed. - Loud fail-safe + metrics are provider-aware (missingReason, startup log). - Tests: verifyCognito (access/id/wrong-iss/bad-sig/client-id allowlist/M2M wildcard), resolveCognito scope sources, cognito config derivation + wildcard. go test 69.2%, helm 180, terraform validate ok. Refs #1652 --- docker-compose.yml | 10 ++ go-validate/cognito.go | 69 ++++++++ go-validate/cognito_test.go | 139 ++++++++++++++++ go-validate/config.go | 153 +++++++++++++----- go-validate/config_test.go | 52 ++++++ go-validate/jwt.go | 37 +++-- go-validate/main.go | 148 ++++++++++++----- .../modules/mcp-gateway/ecs-services.tf | 25 ++- 8 files changed, 540 insertions(+), 93 deletions(-) create mode 100644 go-validate/cognito.go create mode 100644 go-validate/cognito_test.go diff --git a/docker-compose.yml b/docker-compose.yml index 6ba849cd6..1d8740c17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -618,6 +618,16 @@ services: # Fast path engages only when JWKS_URL + VALIDATE_ISSUER + VALIDATE_AUDIENCE are # all set and match the tokens; otherwise every /validate request is transparently # proxied to the auth-server (correct, just not accelerated). + # AUTH_PROVIDER selects the verifier (keycloak default, or cognito). The + # sidecar auto-derives everything from the matching IdP vars below. + - AUTH_PROVIDER=${AUTH_PROVIDER:-keycloak} + # Cognito (used when AUTH_PROVIDER=cognito): issuer + JWKS + accepted client-id + # allowlist derive from AWS_REGION + COGNITO_* (matches the Python provider). + - AWS_REGION=${AWS_REGION:-us-east-1} + - COGNITO_USER_POOL_ID=${COGNITO_USER_POOL_ID:-} + - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID:-} + - COGNITO_M2M_CLIENT_IDS=${COGNITO_M2M_CLIENT_IDS:-} + - IDE_OAUTH_CLIENT_ID=${IDE_OAUTH_CLIENT_ID:-} # KEYCLOAK_* let go-validate auto-derive JWKS_URL, the accepted issuer list # (external + internal + localhost) and the accepted audience list (web # client id + M2M client id + "mcp-gateway") when the VALIDATE_* overrides diff --git a/go-validate/cognito.go b/go-validate/cognito.go new file mode 100644 index 000000000..090d8770e --- /dev/null +++ b/go-validate/cognito.go @@ -0,0 +1,69 @@ +package main + +// Cognito fast path. Mirrors auth_server/providers/cognito.py exactly: +// +// - Issuer is https://cognito-idp..amazonaws.com/; JWKS +// lives at /.well-known/jwks.json. +// - Cognito issues ACCESS tokens (token_use="access", NO aud claim, client in +// the "client_id" claim) and ID tokens (token_use="id", aud=client_id). MCP +// clients send the ACCESS token to the resource server, so the fast path +// verifies access tokens and defers id/login tokens to Python. +// - Access tokens are not audience-bound, so the client binding is the +// "client_id" claim checked against an allowlist (web client + IDE client + +// M2M client ids). "*" is an M2M-only wildcard: accept any client_id, but +// ONLY for machine tokens (no "username" claim), so it can never widen which +// clients may mint a USER token. +// - Scopes: a USER access token carries cognito:groups -> group->scope mapping +// (same DocumentDB path Keycloak uses). A machine / no-group token carries no +// groups; its authorization is the token's own "scope" claim (Cognito +// resource-server scopes = registry scope names). This matches server.py's +// validate scope finalization. + +// verifyCognito verifies a Cognito RS256 access token and applies the client_id +// allowlist policy. Sentinel errors preserve the fail-closed boundary: a +// recognized-invalid token (bad sig / expiry) -> 401; anything else (id token, +// unknown kid, wrong issuer, client_id not allowed) -> fall back to Python. +func verifyCognito( + token string, + ks *keysetCache, + issuer string, + acceptedClientIDs []string, + m2mAcceptAny bool, +) (*Claims, error) { + c, err := parseVerifyDecode(token, ks) + if err != nil { + return nil, err + } + if c.Iss != issuer { + return nil, errUnknownKey // different issuer -> let Python handle it + } + // Only access tokens flow through the resource server; id/login tokens are + // audience-bound and handled by Python. Treat non-access as "not ours". + if c.TokenUse != "access" { + return nil, errNotJWT + } + // A machine (client_credentials) token has no end-user "username" claim. The + // "*" wildcard accepts any client_id but ONLY for such machine tokens. + isMachine := c.CognitoUsername == "" + if !containsStr(acceptedClientIDs, c.ClientID) && !(m2mAcceptAny && isMachine) { + // client_id not in the allowlist is a policy decision, not proof of + // forgery -> defer to Python (authoritative), never a 401. + return nil, errUnknownKey + } + return c, nil +} + +// mapCognitoClaims turns verified Cognito claims into a caller identity. Cognito +// carries the end-user handle in "username" (not preferred_username); fall back +// to sub for machine tokens. +func mapCognitoClaims(c *Claims) identity { + username := c.CognitoUsername + if username == "" { + username = c.Sub + } + return identity{ + Sub: c.Sub, + Username: username, + ClientID: c.ClientID, + } +} diff --git a/go-validate/cognito_test.go b/go-validate/cognito_test.go new file mode 100644 index 000000000..517b020e7 --- /dev/null +++ b/go-validate/cognito_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + "time" +) + +const cIss = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pool" + +// cognitoAccessClaims builds a minimal Cognito ACCESS-token claim set. +func cognitoAccessClaims() map[string]any { + return map[string]any{ + "iss": cIss, + "token_use": "access", + "client_id": "web-client", + "username": "alice", + "sub": "sub-123", + "scope": "mcp-servers-unrestricted/read", + "exp": time.Now().Add(time.Hour).Unix(), + } +} + +func TestVerifyCognito(t *testing.T) { + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + ks := testKeyset(&priv.PublicKey, "kid1") + accepted := []string{"web-client", "ide-client"} + + t.Run("valid access token, client_id allowed", func(t *testing.T) { + tok := mintRS256(t, priv, "kid1", cognitoAccessClaims()) + c, err := verifyCognito(tok, ks, cIss, accepted, false) + if err != nil || c == nil || c.CognitoUsername != "alice" || c.ClientID != "web-client" { + t.Fatalf("valid token failed: err=%v claims=%+v", err, c) + } + }) + t.Run("id token -> fallback (not access)", func(t *testing.T) { + cl := cognitoAccessClaims() + cl["token_use"] = "id" + tok := mintRS256(t, priv, "kid1", cl) + if _, err := verifyCognito(tok, ks, cIss, accepted, false); err != errNotJWT { + t.Fatalf("id token must fall back (errNotJWT), got %v", err) + } + }) + t.Run("wrong issuer -> fallback", func(t *testing.T) { + tok := mintRS256(t, priv, "kid1", cognitoAccessClaims()) + if _, err := verifyCognito(tok, ks, "https://other/pool", accepted, false); err != errUnknownKey { + t.Fatalf("wrong issuer must fall back (errUnknownKey), got %v", err) + } + }) + t.Run("bad signature -> 401", func(t *testing.T) { + tok := mintRS256(t, priv, "kid1", cognitoAccessClaims()) + if _, err := verifyCognito(tok[:len(tok)-2]+"xx", ks, cIss, accepted, false); err != errInvalidToken { + t.Fatalf("bad sig must be errInvalidToken, got %v", err) + } + }) + t.Run("client_id not in allowlist -> fallback", func(t *testing.T) { + cl := cognitoAccessClaims() + cl["client_id"] = "rogue-client" + tok := mintRS256(t, priv, "kid1", cl) + if _, err := verifyCognito(tok, ks, cIss, accepted, false); err != errUnknownKey { + t.Fatalf("unlisted client_id must fall back, got %v", err) + } + }) + t.Run("m2m wildcard accepts a MACHINE token with any client_id", func(t *testing.T) { + cl := cognitoAccessClaims() + cl["client_id"] = "some-agent-client" + delete(cl, "username") // machine token: no username + tok := mintRS256(t, priv, "kid1", cl) + if _, err := verifyCognito(tok, ks, cIss, accepted, true); err != nil { + t.Fatalf("m2m wildcard should accept a machine token: %v", err) + } + }) + t.Run("m2m wildcard does NOT widen USER tokens", func(t *testing.T) { + cl := cognitoAccessClaims() + cl["client_id"] = "some-agent-client" + // username present -> a user token -> wildcard must not apply. + tok := mintRS256(t, priv, "kid1", cl) + if _, err := verifyCognito(tok, ks, cIss, accepted, true); err != errUnknownKey { + t.Fatalf("wildcard must not accept a USER token with an unlisted client_id, got %v", err) + } + }) +} + +// TestResolveCognito_ScopeSources verifies the two scope paths: cognito:groups -> +// group->scope mapping, and no-group token -> the token's own scope claim. +func TestResolveCognito_ScopeSources(t *testing.T) { + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + ks := testKeyset(&priv.PublicKey, "kid1") + res := &scopeResolver{} + res.snap.Store(&scopeSnapshot{ + scopes: []scopeDoc{{ID: "mcp-servers-unrestricted/read", GroupMappings: []string{"admins"}}}, + m2mGroups: map[string][]string{}, + }) + s := &server{ + cfg: Config{ + Provider: "cognito", FastPathReady: true, + Issuers: []string{cIss}, AcceptedClientIDs: []string{"web-client"}, + }, + ks: ks, + scopes: res, + } + + t.Run("user token with cognito:groups -> group mapping", func(t *testing.T) { + cl := cognitoAccessClaims() + cl["cognito:groups"] = []string{"admins"} + cl["scope"] = "ignored-when-groups-present" + tok := mintRS256(t, priv, "kid1", cl) + ident, groups, scopes, method, verdict := s.resolveCognito(tok) + if verdict != vOK || method != "cognito" || ident.Username != "alice" { + t.Fatalf("verdict=%d method=%q ident=%+v", verdict, method, ident) + } + if len(groups) != 1 || groups[0] != "admins" { + t.Fatalf("groups wrong: %v", groups) + } + if len(scopes) != 1 || scopes[0] != "mcp-servers-unrestricted/read" { + t.Fatalf("group->scope mapping wrong: %v", scopes) + } + }) + t.Run("machine token (no groups) -> token scope claim", func(t *testing.T) { + cl := cognitoAccessClaims() + delete(cl, "username") + cl["scope"] = "mcp-servers-unrestricted/read mcp-servers-unrestricted/execute" + tok := mintRS256(t, priv, "kid1", cl) + ident, groups, scopes, _, verdict := s.resolveCognito(tok) + if verdict != vOK { + t.Fatalf("machine token should resolve OK, verdict=%d", verdict) + } + if len(groups) != 0 { + t.Fatalf("machine token should carry no groups, got %v", groups) + } + if len(scopes) != 2 { + t.Fatalf("scopes should come from the token claim: %v", scopes) + } + if ident.Username != "sub-123" { // no username -> falls back to sub + t.Fatalf("machine identity should be sub, got %q", ident.Username) + } + }) +} diff --git a/go-validate/config.go b/go-validate/config.go index fcc17dce0..c0955159b 100644 --- a/go-validate/config.go +++ b/go-validate/config.go @@ -15,20 +15,29 @@ import ( type Config struct { Listen string SecretKey string - JWKSURL string + // Provider selects the verifier: "keycloak" (default) or "cognito". Other IdPs + // are not fast-pathed yet and stay in fallback-only mode (deferred to Python). + Provider string + JWKSURL string // Issuers/Audiences are lists: a Keycloak token's iss is whatever host the // client reached Keycloak through (external URL for browser logins, internal // or localhost for service/M2M callers), and its aud names the specific // client. Matching ANY member mirrors the Python Keycloak provider, which - // accepts three issuer URLs and a set of gateway-identifying audiences. - Issuers []string - Audiences []string - FallbackURL string - JWKSRefreshSec int - ScopeTTLSec int - AuthMethod string - MarkerSecret string - FastPathReady bool + // accepts three issuer URLs and a set of gateway-identifying audiences. For + // Cognito, Issuers holds the single user-pool issuer. + Issuers []string + Audiences []string + // Cognito access tokens are not audience-bound: the client binding is the + // "client_id" claim checked against AcceptedClientIDs (web + IDE + M2M ids). + // M2MAcceptAny is the "*" wildcard, honored for machine tokens only. + AcceptedClientIDs []string + M2MAcceptAny bool + FallbackURL string + JWKSRefreshSec int + ScopeTTLSec int + AuthMethod string + MarkerSecret string + FastPathReady bool } // knownWeakSecrets are literals that must never be accepted as a signing key. @@ -152,6 +161,67 @@ func dropAccount(in []string) []string { return out } +// detectProvider resolves which verifier to run. An explicit AUTH_PROVIDER wins +// (the auth-server already sets it); otherwise infer from which IdP env is present. +// Only "cognito" and "keycloak" are fast-pathed; anything else stays fallback-only. +func detectProvider() string { + p := strings.ToLower(strings.TrimSpace(os.Getenv("AUTH_PROVIDER"))) + if p == "cognito" || p == "keycloak" { + return p + } + if os.Getenv("COGNITO_USER_POOL_ID") != "" { + return "cognito" + } + if os.Getenv("KEYCLOAK_URL") != "" { + return "keycloak" + } + return p // e.g. "entra"/"okta"/"default"/"" -> keycloak-style derivation yields nothing -> fallback-only +} + +// deriveCognito fills the Cognito issuer, JWKS URL and accepted client-id +// allowlist from the COGNITO_* / AWS_REGION env the auth-server already has, +// mirroring auth_server/providers/cognito.py. Explicit VALIDATE_ISSUER / JWKS_URL +// still win. Returns nothing derivable when COGNITO_USER_POOL_ID is unset. +func deriveCognito(cfg *Config) { + region := getenv("AWS_REGION", "us-east-1") + pool := strings.TrimSpace(os.Getenv("COGNITO_USER_POOL_ID")) + if pool != "" { + issuer := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", region, pool) + if len(cfg.Issuers) == 0 { + cfg.Issuers = []string{issuer} + } + if cfg.JWKSURL == "" { + cfg.JWKSURL = issuer + "/.well-known/jwks.json" + } + } + + // accepted_client_ids = web client + IDE client + explicit M2M ids; "*" = M2M + // wildcard (machine tokens only). + ids := []string{} + add := func(v string) { + v = strings.TrimSpace(v) + if v == "" { + return + } + for _, e := range ids { + if e == v { + return + } + } + ids = append(ids, v) + } + add(os.Getenv("COGNITO_CLIENT_ID")) + add(os.Getenv("IDE_OAUTH_CLIENT_ID")) + for _, m := range parseList(os.Getenv("COGNITO_M2M_CLIENT_IDS")) { + if m == "*" { + cfg.M2MAcceptAny = true + continue + } + add(m) + } + cfg.AcceptedClientIDs = ids +} + // loadConfig reads configuration from the environment and validates the signing key. // It exits the process (fail closed) when SECRET_KEY is present but weak/invalid. func loadConfig() Config { @@ -166,32 +236,35 @@ func loadConfig() Config { MarkerSecret: os.Getenv("AUTH_SERVER_NGINX_MARKER_SECRET"), } - // Auto-derive JWKS_URL and VALIDATE_ISSUER from the KEYCLOAK_* env vars that - // every deployment already provides, so operators only opt in (+ set the - // audience) instead of hand-computing these. Explicit values always win. - // The audience claim is NOT derivable (Keycloak varies it per client), so it - // stays operator-supplied; an unset/mismatched audience fails safe (fallback). - realm := getenv("KEYCLOAK_REALM", "mcp-gateway") - if cfg.JWKSURL == "" { - if kc := strings.TrimRight(os.Getenv("KEYCLOAK_URL"), "/"); kc != "" { - cfg.JWKSURL = fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", kc, realm) - } - } + cfg.Provider = detectProvider() - // Issuers: an explicit VALIDATE_ISSUER list wins; otherwise derive the same - // external/internal/localhost issuer URLs Python accepts. A token matches when - // its iss equals ANY member, so both browser-login and service tokens fast-path. + // An explicit VALIDATE_ISSUER / JWKS_URL always wins for either provider. cfg.Issuers = parseList(os.Getenv("VALIDATE_ISSUER")) - if len(cfg.Issuers) == 0 { - cfg.Issuers = deriveIssuers(realm) - } - // Audiences: an explicit VALIDATE_AUDIENCE list wins (with "account" stripped, - // fail closed); otherwise derive the gateway-identifying audiences Python - // accepts. A token matches when its aud contains ANY member. - cfg.Audiences = dropAccount(parseList(os.Getenv("VALIDATE_AUDIENCE"))) - if len(cfg.Audiences) == 0 { - cfg.Audiences = deriveAudiences() + if cfg.Provider == "cognito" { + // Cognito: derive issuer + JWKS + accepted client-id allowlist from the + // COGNITO_* env. Access tokens are client_id-bound (no aud), so there is + // no audience list; scopes come from cognito:groups or the scope claim. + deriveCognito(&cfg) + } else { + // Keycloak (default): auto-derive JWKS_URL and the accepted issuer list + // (external/internal/localhost realm URLs) + audience list from KEYCLOAK_*, + // so operators only opt in. Explicit values win. A token matches when its + // iss equals ANY issuer and its aud contains ANY audience. + realm := getenv("KEYCLOAK_REALM", "mcp-gateway") + if cfg.JWKSURL == "" { + if kc := strings.TrimRight(os.Getenv("KEYCLOAK_URL"), "/"); kc != "" { + cfg.JWKSURL = fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", kc, realm) + } + } + if len(cfg.Issuers) == 0 { + cfg.Issuers = deriveIssuers(realm) + } + // "account" is stripped (fail closed) even if supplied explicitly. + cfg.Audiences = dropAccount(parseList(os.Getenv("VALIDATE_AUDIENCE"))) + if len(cfg.Audiences) == 0 { + cfg.Audiences = deriveAudiences() + } } // B3: validate the signing secret. If a secret is provided at all it must be @@ -202,11 +275,15 @@ func loadConfig() Config { } } - // Fast path requires everything needed to verify AND mint safely. - cfg.FastPathReady = cfg.SecretKey != "" && - cfg.JWKSURL != "" && - len(cfg.Issuers) > 0 && - len(cfg.Audiences) > 0 + // Fast path requires everything needed to verify AND mint safely. The + // per-provider audience check differs: Keycloak needs an audience allowlist; + // Cognito needs the client-id allowlist (or the M2M wildcard). + base := cfg.SecretKey != "" && cfg.JWKSURL != "" && len(cfg.Issuers) > 0 + if cfg.Provider == "cognito" { + cfg.FastPathReady = base && (len(cfg.AcceptedClientIDs) > 0 || cfg.M2MAcceptAny) + } else { + cfg.FastPathReady = base && len(cfg.Audiences) > 0 + } return cfg } diff --git a/go-validate/config_test.go b/go-validate/config_test.go index 889811deb..e2aa4f382 100644 --- a/go-validate/config_test.go +++ b/go-validate/config_test.go @@ -61,6 +61,58 @@ func TestLoadConfig_ExplicitListsWin(t *testing.T) { } } +func TestLoadConfig_CognitoDerivation(t *testing.T) { + t.Setenv("SECRET_KEY", "unit-test-secret-32-bytes-xxxxxxxxxx") + t.Setenv("AUTH_PROVIDER", "cognito") + t.Setenv("AWS_REGION", "us-west-2") + t.Setenv("COGNITO_USER_POOL_ID", "us-west-2_ABC123") + t.Setenv("COGNITO_CLIENT_ID", "web-client") + t.Setenv("IDE_OAUTH_CLIENT_ID", "ide-client") + t.Setenv("COGNITO_M2M_CLIENT_IDS", "agent-a agent-b") + // Keycloak vars unset; Cognito path must be taken. + t.Setenv("JWKS_URL", "") + t.Setenv("VALIDATE_ISSUER", "") + cfg := loadConfig() + + if cfg.Provider != "cognito" { + t.Fatalf("provider should be cognito, got %q", cfg.Provider) + } + wantIss := "https://cognito-idp.us-west-2.amazonaws.com/us-west-2_ABC123" + if len(cfg.Issuers) != 1 || cfg.Issuers[0] != wantIss { + t.Fatalf("issuer not derived: %v", cfg.Issuers) + } + if cfg.JWKSURL != wantIss+"/.well-known/jwks.json" { + t.Fatalf("jwks not derived: %q", cfg.JWKSURL) + } + for _, want := range []string{"web-client", "ide-client", "agent-a", "agent-b"} { + if !containsStr(cfg.AcceptedClientIDs, want) { + t.Fatalf("accepted client ids %v missing %q", cfg.AcceptedClientIDs, want) + } + } + if cfg.M2MAcceptAny { + t.Fatal("no '*' supplied -> M2MAcceptAny must be false") + } + if !cfg.FastPathReady { + t.Fatal("cognito fast path should be ready") + } +} + +func TestLoadConfig_CognitoWildcard(t *testing.T) { + t.Setenv("SECRET_KEY", "unit-test-secret-32-bytes-xxxxxxxxxx") + t.Setenv("AUTH_PROVIDER", "cognito") + t.Setenv("AWS_REGION", "us-east-1") + t.Setenv("COGNITO_USER_POOL_ID", "us-east-1_pool") + t.Setenv("COGNITO_CLIENT_ID", "web-client") + t.Setenv("COGNITO_M2M_CLIENT_IDS", "*") + cfg := loadConfig() + if !cfg.M2MAcceptAny { + t.Fatal("'*' should set M2MAcceptAny") + } + if containsStr(cfg.AcceptedClientIDs, "*") { + t.Fatal("'*' must not be added as a literal client id") + } +} + func TestLoadConfig_NoKeycloak_FallbackOnly(t *testing.T) { t.Setenv("SECRET_KEY", "") t.Setenv("KEYCLOAK_URL", "") diff --git a/go-validate/jwt.go b/go-validate/jwt.go index 1b1bf8b80..614da4fd9 100644 --- a/go-validate/jwt.go +++ b/go-validate/jwt.go @@ -64,7 +64,11 @@ type Claims struct { ClientID string `json:"client_id"` Scope string `json:"scope"` Groups []string `json:"groups"` - raw map[string]any + // Cognito-specific claim shapes (unused by other providers, decoded permissively). + CognitoGroups []string `json:"cognito:groups"` + TokenUse string `json:"token_use"` + CognitoUsername string `json:"username"` + raw map[string]any } // audContains reports whether the token audience matches want (aud may be a string @@ -108,7 +112,12 @@ func containsStr(list []string, want string) bool { // verifyRS256 verifies an RS256 JWT against the cached keyset and enforces // iss/aud/exp from config (never from the token). It returns the parsed claims on // success, or a sentinel error telling the caller whether to fall back or 401. -func verifyRS256(token string, ks *keysetCache, issuers, audiences []string) (*Claims, error) { +// parseVerifyDecode does the IdP-agnostic half of RS256 verification: structural +// parse, alg=RS256, key lookup by kid, signature check, claim decode, and expiry. +// Provider-specific iss/aud/client_id policy is layered on by the callers +// (verifyRS256 for Keycloak, verifyCognito for Cognito). The sentinel errors keep +// the fail-closed boundary: fallback vs 401. +func parseVerifyDecode(token string, ks *keysetCache) (*Claims, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, errNotJWT @@ -151,9 +160,21 @@ func verifyRS256(token string, ks *keysetCache, issuers, audiences []string) (*C } _ = json.Unmarshal(payloadBytes, &c.raw) - // Enforce iss/aud/exp (config-driven, fail closed). iss must match ANY - // accepted issuer; aud must contain ANY accepted audience (mirrors the - // Python Keycloak provider's valid_issuers / accepted_audiences lists). + now := time.Now().Unix() + if c.Exp != 0 && now > c.Exp+clockLeewaySeconds { + return nil, errInvalidToken + } + return &c, nil +} + +func verifyRS256(token string, ks *keysetCache, issuers, audiences []string) (*Claims, error) { + c, err := parseVerifyDecode(token, ks) + if err != nil { + return nil, err + } + // Enforce iss/aud (config-driven, fail closed). iss must match ANY accepted + // issuer; aud must contain ANY accepted audience (mirrors the Python Keycloak + // provider's valid_issuers / accepted_audiences lists). if !containsStr(issuers, c.Iss) { return nil, errUnknownKey // different issuer -> let Python handle it } @@ -163,11 +184,7 @@ func verifyRS256(token string, ks *keysetCache, issuers, audiences []string) (*C // handler would have accepted. Only a bad signature or expiry -> 401. return nil, errUnknownKey } - now := time.Now().Unix() - if c.Exp != 0 && now > c.Exp+clockLeewaySeconds { - return nil, errInvalidToken - } - return &c, nil + return c, nil } // Internal-token issuer + audiences (mirror auth_server/internal_request_token.py). diff --git a/go-validate/main.go b/go-validate/main.go index 5483185c8..c91d0f57f 100644 --- a/go-validate/main.go +++ b/go-validate/main.go @@ -56,11 +56,20 @@ func missingReason(cfg Config) string { if cfg.JWKSURL == "" { missing = append(missing, "JWKS_URL (or KEYCLOAK_URL to derive it)") } - if len(cfg.Issuers) == 0 { - missing = append(missing, "VALIDATE_ISSUER (or KEYCLOAK_URL/KEYCLOAK_EXTERNAL_URL to derive it)") - } - if len(cfg.Audiences) == 0 { - missing = append(missing, "VALIDATE_AUDIENCE (or KEYCLOAK_CLIENT_ID/KEYCLOAK_M2M_CLIENT_ID to derive it)") + if cfg.Provider == "cognito" { + if len(cfg.Issuers) == 0 { + missing = append(missing, "COGNITO_USER_POOL_ID/AWS_REGION (to derive the issuer)") + } + if len(cfg.AcceptedClientIDs) == 0 && !cfg.M2MAcceptAny { + missing = append(missing, "COGNITO_CLIENT_ID/COGNITO_M2M_CLIENT_IDS (accepted client-id allowlist)") + } + } else { + if len(cfg.Issuers) == 0 { + missing = append(missing, "VALIDATE_ISSUER (or KEYCLOAK_URL/KEYCLOAK_EXTERNAL_URL to derive it)") + } + if len(cfg.Audiences) == 0 { + missing = append(missing, "VALIDATE_AUDIENCE (or KEYCLOAK_CLIENT_ID/KEYCLOAK_M2M_CLIENT_ID to derive it)") + } } if len(missing) == 0 { return "no missing config" @@ -138,9 +147,84 @@ func mapClaims(c *Claims) identity { } } -// handleValidate is the hot path: verify the RS256 bearer and either answer 200 -// with identity headers, 401 for a recognized-invalid token, or fall back to -// Python for anything unrecognized (cookies, other IdPs, opaque tokens). +// Fast-path verdicts: what handleValidate should do after provider resolution. +const ( + vOK = iota // write identity headers + mint (200) + vFallback // not ours (other IdP / cookie / unknown) -> Python + vUnauthorized // recognized but invalid (bad sig / expiry) -> 401 +) + +// resolveFastPath verifies the bearer with the configured provider and resolves +// identity, groups, scopes and the auth-method label. It centralizes the +// per-provider differences; the header/mint tail in handleValidate is shared. +func (s *server) resolveFastPath( + r *http.Request, + tok string, +) (identity, []string, []string, string, int) { + if s.cfg.Provider == "cognito" { + return s.resolveCognito(tok) + } + return s.resolveKeycloak(tok) +} + +// resolveKeycloak: RS256 verify against the issuer/audience lists, then group->scope +// resolution (with M2M enrichment) exactly as Python does. +func (s *server) resolveKeycloak( + tok string, +) (identity, []string, []string, string, int) { + claims, err := verifyRS256(tok, s.ks, s.cfg.Issuers, s.cfg.Audiences) + switch err { + case nil: + case errInvalidToken: + return identity{}, nil, nil, "", vUnauthorized + default: + return identity{}, nil, nil, "", vFallback + } + ident := mapClaims(claims) + if s.scopes == nil { + return identity{}, nil, nil, "", vFallback + } + scopes, ok := s.scopes.resolve(claims.Groups, ident.ClientID) + if !ok { + return identity{}, nil, nil, "", vFallback + } + return ident, claims.Groups, scopes, s.cfg.AuthMethod, vOK +} + +// resolveCognito: verify a Cognito access token, then compute scopes the way +// server.py does -- cognito:groups -> group->scope mapping, else the token's own +// "scope" claim (M2M / no-group user tokens). Mirrors auth_server/providers/cognito.py. +func (s *server) resolveCognito( + tok string, +) (identity, []string, []string, string, int) { + claims, err := verifyCognito(tok, s.ks, s.cfg.Issuers[0], s.cfg.AcceptedClientIDs, s.cfg.M2MAcceptAny) + switch err { + case nil: + case errInvalidToken: + return identity{}, nil, nil, "", vUnauthorized + default: + return identity{}, nil, nil, "", vFallback + } + ident := mapCognitoClaims(claims) + if len(claims.CognitoGroups) > 0 { + // User token with groups: map groups -> scopes (same DocumentDB path). + if s.scopes == nil { + return identity{}, nil, nil, "", vFallback + } + scopes, ok := s.scopes.resolve(claims.CognitoGroups, ident.ClientID) + if !ok { + return identity{}, nil, nil, "", vFallback + } + return ident, claims.CognitoGroups, scopes, "cognito", vOK + } + // Machine / no-group token: authorization is the token's own scope claim + // (Cognito resource-server scopes == registry scope names). + return ident, nil, strings.Fields(claims.Scope), "cognito", vOK +} + +// handleValidate is the hot path: verify the bearer and either answer 200 with +// identity headers, 401 for a recognized-invalid token, or fall back to Python +// for anything unrecognized (cookies, other IdPs, opaque tokens). func (s *server) handleValidate(w http.ResponseWriter, r *http.Request) { // nginx's auth_request subrequest never carries a usable body, but for a // POST/PUT/PATCH origin request nginx forwards the original Content-Length @@ -175,33 +259,17 @@ func (s *server) handleValidate(w http.ResponseWriter, r *http.Request) { return } - claims, err := verifyRS256(tok, s.ks, s.cfg.Issuers, s.cfg.Audiences) - switch err { - case nil: - // verified below - case errInvalidToken: + // Resolve identity + groups + scopes via the configured provider's verifier. + // verdict decides the outcome: 401 (recognized-invalid), fallback (not ours), + // or OK (write identity headers below). + ident, groups, scopes, authMethod, verdict := s.resolveFastPath(r, tok) + switch verdict { + case vUnauthorized: s.stats.unauth.Add(1) w.Header().Set("WWW-Authenticate", "Bearer") w.WriteHeader(http.StatusUnauthorized) // recognized but invalid -> 401 (fail closed) return - default: - // errNotJWT / errUnknownKey / errWrongAlg -> other IdP / opaque / unknown kid - s.stats.fallback.Add(1) - s.fallback.ServeHTTP(w, r) - return - } - - // Fast path. Resolve scopes exactly as Python does (group->scope mapping, - // M2M enrichment). If we cannot resolve them safely (no DB snapshot, or a - // user token that would need idp_user_groups enrichment), fall back. - ident := mapClaims(claims) - if s.scopes == nil { - s.stats.fallback.Add(1) - s.fallback.ServeHTTP(w, r) - return - } - scopes, ok := s.scopes.resolve(claims.Groups, ident.ClientID) - if !ok { + case vFallback: s.stats.fallback.Add(1) s.fallback.ServeHTTP(w, r) return @@ -210,23 +278,23 @@ func (s *server) handleValidate(w http.ResponseWriter, r *http.Request) { // Identity in the RESPONSE is fully controlled below (Set overwrites), so a // client cannot inject identity even though we never mutate the request. serverName := serverNameFromOriginalURL(r.Header.Get("X-Original-URL")) - egressUser := ident.Sub // canonical egress vault id = OIDC sub (bearer callers) - canonMethod := canonicalAuthMethod(s.cfg.AuthMethod) // internal-token auth_method claim + egressUser := ident.Sub // canonical egress vault id = OIDC sub (bearer callers) + canonMethod := canonicalAuthMethod(authMethod) // internal-token auth_method claim h := w.Header() h.Set("X-User", ident.Username) h.Set("X-Username", ident.Username) h.Set("X-Client-Id", ident.ClientID) h.Set("X-Scopes", scopesToHeader(scopes)) - h.Set("X-Auth-Method", s.cfg.AuthMethod) + h.Set("X-Auth-Method", authMethod) h.Set("X-Server-Name", serverName) h.Set("X-Tool-Name", "") - h.Set("X-Groups", strings.Join(claims.Groups, " ")) + h.Set("X-Groups", strings.Join(groups, " ")) // Registry /api/ hop: thin identity token, minted only when nginx set the marker. if r.Header.Get("X-Registry-Api-Auth") != "" { if tok, err := mintRegistryUIToken( - s.cfg.SecretKey, ident.Username, "", claims.Groups, + s.cfg.SecretKey, ident.Username, "", groups, canonMethod, ident.ClientID, egressUser, ); err == nil { h.Set("X-Internal-Token-Registry", tok) @@ -355,8 +423,12 @@ func main() { mux.HandleFunc("/metrics", s.handleMetrics) if cfg.FastPathReady { - log.Printf("go-validate listening on %s | mode=fast-path | fallback=%s", cfg.Listen, cfg.FallbackURL) - log.Printf("accepted issuers=%v | accepted audiences=%v", cfg.Issuers, cfg.Audiences) + log.Printf("go-validate listening on %s | mode=fast-path | provider=%s | fallback=%s", cfg.Listen, cfg.Provider, cfg.FallbackURL) + if cfg.Provider == "cognito" { + log.Printf("accepted issuers=%v | accepted client_ids=%v | m2m_accept_any=%v", cfg.Issuers, cfg.AcceptedClientIDs, cfg.M2MAcceptAny) + } else { + log.Printf("accepted issuers=%v | accepted audiences=%v", cfg.Issuers, cfg.Audiences) + } } else { // Loud: an operator who deployed the sidecar expecting acceleration must // see WHY it is only proxying, not discover it from a flat latency graph. diff --git a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf index 4f26ad210..c4eac077a 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf @@ -743,16 +743,27 @@ module "ecs_service_auth" { protocol = "tcp" }] - # The sidecar auto-derives JWKS_URL, the accepted issuer list and the - # accepted audience list from the KEYCLOAK_* vars below (same values the - # auth-server uses), matching the Python Keycloak provider. It engages when - # SECRET_KEY + a reachable Keycloak are present; otherwise it reverse-proxies - # to the auth-server (same task, loopback), which is correct but not - # accelerated. validate_fast_path_audience overrides the derived audience - # list only when set (never "account" -> refused as a confused-deputy). + # The sidecar picks its verifier from AUTH_PROVIDER and auto-derives + # everything from the same IdP vars the auth-server uses: + # - keycloak: JWKS + issuer list + audience list from KEYCLOAK_* + # (matches the Python Keycloak provider). + # - cognito: issuer + JWKS + accepted client-id allowlist from + # COGNITO_* / AWS_REGION (matches the Python Cognito provider). + # It engages when SECRET_KEY + a reachable IdP are present; otherwise it + # reverse-proxies to the auth-server (same task, loopback) - correct but not + # accelerated. validate_fast_path_audience overrides the derived Keycloak + # audience list only when set (never "account" -> refused as a confused-deputy). environment = [ { name = "GOVALIDATE_LISTEN", value = ":8899" }, { name = "AUTH_FALLBACK_URL", value = "http://localhost:18888" }, + { name = "AUTH_PROVIDER", value = var.pingfederate_enabled ? "pingfederate" : (var.auth0_enabled ? "auth0" : (var.okta_enabled ? "okta" : (var.entra_enabled ? "entra" : (var.cognito_enabled ? "cognito" : (var.keycloak_domain != "" ? "keycloak" : "default"))))) }, + { name = "AWS_REGION", value = data.aws_region.current.id }, + # Cognito (used when AUTH_PROVIDER=cognito): + { name = "COGNITO_USER_POOL_ID", value = var.cognito_user_pool_id }, + { name = "COGNITO_CLIENT_ID", value = var.cognito_client_id }, + { name = "COGNITO_M2M_CLIENT_IDS", value = var.cognito_m2m_client_ids }, + { name = "IDE_OAUTH_CLIENT_ID", value = var.ide_oauth_client_id }, + # Keycloak (used when AUTH_PROVIDER=keycloak): { name = "KEYCLOAK_URL", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}" : "" }, { name = "KEYCLOAK_EXTERNAL_URL", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}" : "" }, { name = "KEYCLOAK_REALM", value = "mcp-gateway" }, From 54bccf5d20bbf43c74fca015d6cea7c3b5271a04 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 17:24:39 +0000 Subject: [PATCH 15/26] docs(fast-path): document Cognito support in the fast-path reference The sidecar now supports Cognito (auto-detected from AUTH_PROVIDER=cognito): derives issuer/JWKS/client-id allowlist from COGNITO_*/AWS_REGION, fast-paths access tokens, scopes from cognito:groups or the token scope claim. Same enable switch; VALIDATE_AUDIENCE does not apply to Cognito. Refs #1652 --- docs/unified-parameter-reference.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/unified-parameter-reference.md b/docs/unified-parameter-reference.md index 9feafa539..5448afa2c 100644 --- a/docs/unified-parameter-reference.md +++ b/docs/unified-parameter-reference.md @@ -148,6 +148,9 @@ Derived as `KEYCLOAK_CLIENT_ID` + `KEYCLOAK_M2M_CLIENT_ID` + `mcp-gateway`, matc **`JWKS_URL` — auto-derived, rarely overridden.** Derived as `/realms//protocol/openid-connect/certs`. Override only for a non-standard key path. +**Amazon Cognito (auto-detected from `AUTH_PROVIDER=cognito`).** +The sidecar also supports Cognito, mirroring the Python Cognito provider — no extra config beyond what the auth-server already has. When `AUTH_PROVIDER=cognito` (the auth-server sets it), the sidecar derives the issuer `https://cognito-idp..amazonaws.com/`, its JWKS, and the accepted client-id allowlist (`COGNITO_CLIENT_ID` + `IDE_OAUTH_CLIENT_ID` + `COGNITO_M2M_CLIENT_IDS`, with `*` = M2M-only wildcard). It fast-paths **access tokens** (id/login tokens defer to Python); scopes come from `cognito:groups` (group→scope) or, for machine/no-group tokens, the token's own `scope` claim. Nothing to set beyond the same enable switch — provider selection is automatic. `VALIDATE_AUDIENCE` does not apply to Cognito (access tokens are client_id-bound, not audience-bound). + To inspect a real token when debugging (decode `aud`/`iss` — but note `account` in the list is expected and correctly NOT accepted): ```bash From 7813cf32c0df3c099b690ca2699d5edcee9064f0 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Wed, 19 Aug 2026 21:55:33 +0000 Subject: [PATCH 16/26] fix(ecs): grant registry exec role read on embeddings-idp-secret The registry task definition mounts aws_secretsmanager_secret.embeddings_idp_client_secret (EMBEDDINGS_AUTH_MODE=idp), but that secret's ARN was missing from the ecs_secrets_access execution-role policy. Result: ResourceInitializationError (AccessDenied on secretsmanager:GetSecretValue) at task init, so registry tasks could never start (crash loop, 0/2 running) whenever the embeddings-idp secret is created. Add the ARN to the policy (unconditional, alongside embeddings_api_key). Unrelated to the go-validate fast path; surfaced during the same terraform apply. --- terraform/aws-ecs/modules/mcp-gateway/iam.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/terraform/aws-ecs/modules/mcp-gateway/iam.tf b/terraform/aws-ecs/modules/mcp-gateway/iam.tf index 8a3ca17a9..a7b5ea0e1 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/iam.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/iam.tf @@ -19,6 +19,7 @@ resource "aws_iam_policy" "ecs_secrets_access" { aws_secretsmanager_secret.keycloak_client_secret.arn, aws_secretsmanager_secret.keycloak_m2m_client_secret.arn, aws_secretsmanager_secret.embeddings_api_key.arn, + aws_secretsmanager_secret.embeddings_idp_client_secret.arn, aws_secretsmanager_secret.keycloak_admin_password.arn ], var.documentdb_credentials_secret_arn != "" ? [var.documentdb_credentials_secret_arn] : [], From f9b1fa43e77f9202db662dd00eee3a82e5cc3306 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Thu, 20 Aug 2026 02:58:21 +0000 Subject: [PATCH 17/26] fix(fast-path): trust the Amazon DocumentDB CA for the scope resolver's TLS On ECS (DOCUMENTDB_USE_TLS=true) the sidecar reached DocumentDB but the TLS handshake failed: 'x509: certificate signed by unknown authority'. DocumentDB serves a cert signed by the Amazon RDS CA, which is not a public root, so the Mongo driver never trusted it -> the scope snapshot never loaded -> every group-bearing (Cognito user) token fell back to Python instead of fast-pathing. - scopes.go: when tls=true, append &tlsCAFile= (default /app/certs/global-bundle.pem), mirroring the Python containers exactly. - Dockerfile: bake the Amazon DocumentDB CA bundle into the image at that path (vendored global-bundle.pem, same file the Terraform stack ships). - test: buildMongoURI now asserts tls=true carries tlsCAFile, and honors a custom DOCUMENTDB_TLS_CA_FILE (URL-encoded). Verified: go test 69.5%, docker build resolves the CA COPY. Fixes the 'scope resolver: refresh failed ... unknown authority' loop seen in the go-validate ECS logs. Refs #1652 --- go-validate/Dockerfile | 4 ++++ go-validate/main_test.go | 14 ++++++++++++++ go-validate/scopes.go | 7 +++++++ 3 files changed, 25 insertions(+) diff --git a/go-validate/Dockerfile b/go-validate/Dockerfile index a268ae082..1ed00d877 100644 --- a/go-validate/Dockerfile +++ b/go-validate/Dockerfile @@ -8,6 +8,10 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/go-validate . FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/go-validate /go-validate +# Amazon DocumentDB CA bundle: DocumentDB serves a cert signed by the Amazon RDS +# CA (not a public root), so the scope-resolver's TLS connection needs it. Baked +# in at the same path the Python containers use (DOCUMENTDB_TLS_CA_FILE default). +COPY global-bundle.pem /app/certs/global-bundle.pem EXPOSE 8899 USER nonroot:nonroot ENTRYPOINT ["/go-validate"] diff --git a/go-validate/main_test.go b/go-validate/main_test.go index a8a0a2ef9..6d3a31ae6 100644 --- a/go-validate/main_test.go +++ b/go-validate/main_test.go @@ -336,6 +336,20 @@ func TestBuildMongoURI(t *testing.T) { if !contains(uri, "authSource=admin") || !contains(uri, "p%40ss") { t.Fatalf("uri not built/escaped correctly: %s", uri) } + + // TLS on -> must carry tls=true AND the CA bundle path, else the DocumentDB + // handshake fails with "unknown authority" and scope parity never loads. + t.Setenv("DOCUMENTDB_USE_TLS", "true") + tlsURI, ok := buildMongoURI() + if !ok || !contains(tlsURI, "tls=true") || !contains(tlsURI, "tlsCAFile=") { + t.Fatalf("TLS uri missing tls/tlsCAFile: %s", tlsURI) + } + // custom CA path is honored and URL-encoded + t.Setenv("DOCUMENTDB_TLS_CA_FILE", "/custom/ca.pem") + customURI, _ := buildMongoURI() + if !contains(customURI, "tlsCAFile=%2Fcustom%2Fca.pem") { + t.Fatalf("custom tlsCAFile not honored/encoded: %s", customURI) + } } func contains(s, sub string) bool { return len(s) >= len(sub) && (indexOf(s, sub) >= 0) } diff --git a/go-validate/scopes.go b/go-validate/scopes.go index 51cb0df45..a21c2c9ef 100644 --- a/go-validate/scopes.go +++ b/go-validate/scopes.go @@ -83,6 +83,13 @@ func buildMongoURI() (string, bool) { } if getenv("DOCUMENTDB_USE_TLS", "false") == "true" { params += "&tls=true" + // DocumentDB serves a cert signed by the Amazon RDS CA, which is NOT a + // public root, so the Mongo driver must trust the bundle explicitly (same + // path + env the Python side uses). Without this the handshake fails with + // "x509: certificate signed by unknown authority" and scope parity never + // loads. The bundle is baked into the image at the default path. + caFile := getenv("DOCUMENTDB_TLS_CA_FILE", "/app/certs/global-bundle.pem") + params += "&tlsCAFile=" + url.QueryEscape(caFile) } if user != "" && pass != "" { return fmt.Sprintf("mongodb://%s:%s@%s:%s/%s?%s", From 6fb3fe8ee8e4240215702ec0d02382b0cd860400 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Thu, 20 Aug 2026 02:59:09 +0000 Subject: [PATCH 18/26] fix(fast-path): fetch DocumentDB CA bundle at build time (not vendored) *.pem is gitignored (secret-guard), so the vendored bundle from the prior commit would be missing on a fresh checkout and break the image build. Download the Amazon RDS/DocumentDB global-bundle.pem in the build stage (same public source the Python entrypoints use) and COPY it into the final image at /app/certs/global-bundle.pem. Verified with a local docker build. Refs #1652 --- go-validate/Dockerfile | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/go-validate/Dockerfile b/go-validate/Dockerfile index 1ed00d877..2d1562a8a 100644 --- a/go-validate/Dockerfile +++ b/go-validate/Dockerfile @@ -6,12 +6,17 @@ RUN go mod download COPY *.go ./ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/go-validate . +# Fetch the Amazon DocumentDB CA bundle at build time (same source the Python +# entrypoints use). DocumentDB serves a cert signed by the Amazon RDS CA, which is +# not a public root, so the scope-resolver's TLS connection must trust it. It is +# NOT vendored in git (*.pem is gitignored as a secret-guard). +RUN apk add --no-cache ca-certificates wget \ + && wget -O /out/global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem + FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/go-validate /go-validate -# Amazon DocumentDB CA bundle: DocumentDB serves a cert signed by the Amazon RDS -# CA (not a public root), so the scope-resolver's TLS connection needs it. Baked -# in at the same path the Python containers use (DOCUMENTDB_TLS_CA_FILE default). -COPY global-bundle.pem /app/certs/global-bundle.pem +# CA bundle at the same path the Python containers use (DOCUMENTDB_TLS_CA_FILE default). +COPY --from=build /out/global-bundle.pem /app/certs/global-bundle.pem EXPOSE 8899 USER nonroot:nonroot ENTRYPOINT ["/go-validate"] From 1411d542b15db5766fa9b2b0ec60109baf88ff9e Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Thu, 20 Aug 2026 03:00:54 +0000 Subject: [PATCH 19/26] chore(fast-path): use ECR Public mirror for the golang build base Switch the build stage from Docker Hub golang:1.24-alpine to public.ecr.aws/docker/library/golang:1.24-alpine, matching the repo convention (docker/Dockerfile.metrics-db) and avoiding Docker Hub rate limits. Build stage only; the runtime image remains distroless/static. Verified with docker build. Refs #1652 --- go-validate/Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/go-validate/Dockerfile b/go-validate/Dockerfile index 2d1562a8a..08306cf42 100644 --- a/go-validate/Dockerfile +++ b/go-validate/Dockerfile @@ -1,5 +1,9 @@ # Build a static, dependency-free binary and ship it on a minimal base. -FROM golang:1.24-alpine AS build +# Build base uses the AWS ECR Public mirror of the Docker official golang image +# (same convention as docker/Dockerfile.metrics-db) to avoid Docker Hub rate +# limits. This is the BUILD stage only; nothing from it ships (multi-stage) -- the +# runtime image below is distroless/static (no shell, minimal CVE surface). +FROM public.ecr.aws/docker/library/golang:1.24-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download From 48dc8b986d4e0a16662cad49c29d82e04020926d Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Thu, 20 Aug 2026 15:45:26 +0000 Subject: [PATCH 20/26] fix(fast-path): use SCRAM-SHA-1 for DocumentDB auth (SHA-256 unsupported) After the TLS/CA fix the handshake succeeded but auth failed: 'unable to authenticate using mechanism SCRAM-SHA-256: Unsupported mechanism'. Amazon DocumentDB v5.0 only supports SCRAM-SHA-1; other MongoDB-compatible backends support SCRAM-SHA-256. Mirror registry/utils/mongodb_connection.py: select the mechanism from STORAGE_BACKEND (documentdb -> SCRAM-SHA-1, else SCRAM-SHA-256), and pass STORAGE_BACKEND to the sidecar in ECS + compose (it was missing). Test covers both mechanisms. go test green, terraform validate ok. Refs #1652 --- docker-compose.yml | 2 ++ go-validate/main_test.go | 13 ++++++++++++- go-validate/scopes.go | 9 ++++++++- .../aws-ecs/modules/mcp-gateway/ecs-services.tf | 2 ++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1d8740c17..728d9b1bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -647,6 +647,8 @@ services: - JWKS_REFRESH_SECONDS=${JWKS_REFRESH_SECONDS:-300} # Scope parity: read-only snapshots of mcp_scopes + idp_m2m_clients so # X-Scopes matches Python's group->scope resolution. Same DB the registry uses. + # STORAGE_BACKEND selects the auth mechanism (documentdb -> SCRAM-SHA-1). + - STORAGE_BACKEND=${STORAGE_BACKEND:-mongodb-ce} - DOCUMENTDB_HOST=${DOCUMENTDB_HOST:-mongodb} - DOCUMENTDB_PORT=${DOCUMENTDB_PORT:-27017} - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} diff --git a/go-validate/main_test.go b/go-validate/main_test.go index 6d3a31ae6..a9c2a4461 100644 --- a/go-validate/main_test.go +++ b/go-validate/main_test.go @@ -332,10 +332,21 @@ func TestBuildMongoURI(t *testing.T) { if !ok || uri == "" { t.Fatalf("expected uri, got %q ok=%v", uri, ok) } - // password special chars must be URL-encoded + // password special chars must be URL-encoded; default backend -> SCRAM-SHA-256 if !contains(uri, "authSource=admin") || !contains(uri, "p%40ss") { t.Fatalf("uri not built/escaped correctly: %s", uri) } + if !contains(uri, "authMechanism=SCRAM-SHA-256") { + t.Fatalf("non-documentdb backend must use SCRAM-SHA-256: %s", uri) + } + + // DocumentDB only supports SCRAM-SHA-1 (mirrors mongodb_connection.py). + t.Setenv("STORAGE_BACKEND", "documentdb") + docdbURI, _ := buildMongoURI() + if !contains(docdbURI, "authMechanism=SCRAM-SHA-1") || contains(docdbURI, "SHA-256") { + t.Fatalf("documentdb backend must use SCRAM-SHA-1: %s", docdbURI) + } + t.Setenv("STORAGE_BACKEND", "mongodb-ce") // TLS on -> must carry tls=true AND the CA bundle path, else the DocumentDB // handshake fails with "unknown authority" and scope parity never loads. diff --git a/go-validate/scopes.go b/go-validate/scopes.go index a21c2c9ef..2e9529643 100644 --- a/go-validate/scopes.go +++ b/go-validate/scopes.go @@ -77,7 +77,14 @@ func buildMongoURI() (string, bool) { dbName := getenv("DOCUMENTDB_DATABASE", "mcp_registry") user := os.Getenv("DOCUMENTDB_USERNAME") pass := os.Getenv("DOCUMENTDB_PASSWORD") - params := "authSource=admin&authMechanism=SCRAM-SHA-256" + // Amazon DocumentDB v5.0 only supports SCRAM-SHA-1; every other MongoDB- + // compatible backend (mongodb-ce / mongodb / atlas) supports SCRAM-SHA-256. + // Mirror registry/utils/mongodb_connection.py exactly, keyed on STORAGE_BACKEND. + authMech := "SCRAM-SHA-256" + if getenv("STORAGE_BACKEND", "mongodb-ce") == "documentdb" { + authMech = "SCRAM-SHA-1" + } + params := "authSource=admin&authMechanism=" + authMech if getenv("DOCUMENTDB_DIRECT_CONNECTION", "true") == "true" { params += "&directConnection=true" } diff --git a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf index c4eac077a..7821da613 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf @@ -770,6 +770,8 @@ module "ecs_service_auth" { { name = "KEYCLOAK_CLIENT_ID", value = "mcp-gateway-web" }, { name = "KEYCLOAK_M2M_CLIENT_ID", value = "mcp-gateway-m2m" }, { name = "VALIDATE_AUDIENCE", value = var.validate_fast_path_audience }, + # Selects the Mongo auth mechanism (documentdb -> SCRAM-SHA-1, else SHA-256). + { name = "STORAGE_BACKEND", value = var.storage_backend }, { name = "DOCUMENTDB_HOST", value = var.documentdb_endpoint }, { name = "DOCUMENTDB_PORT", value = "27017" }, { name = "DOCUMENTDB_DATABASE", value = var.documentdb_database }, From d9dcc71767e69b71c3f942e758fc38c8a4633f5b Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Thu, 20 Aug 2026 15:48:49 +0000 Subject: [PATCH 21/26] feat(deploy): build+deploy the go-validate sidecar in scripts/deploy.sh go-validate is the /validate fast-path sidecar that runs inside the auth task, so deploying it means build+push its image (make build-push IMAGE=go_validate, already wired via build-config.yaml) then force a new auth deployment to pull it. - --service auth and --service both now also build the go-validate image before the auth force-new-deployment (the sidecar ships in the auth task). - Adds --service go-validate (aliases: go_validate/govalidate) to rebuild only the sidecar and redeploy auth. - Dynamic step counting; usage/help updated. bash -n clean. Refs #1652 --- scripts/deploy.sh | 91 ++++++++++++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index b1e40421f..6a3cdb83c 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -32,6 +32,11 @@ REGISTRY_ECS_SERVICE="mcp-gateway-v2-registry" AUTH_IMAGE="auth_server" AUTH_ECS_SERVICE="mcp-gateway-v2-auth" +# go-validate is the /validate fast-path SIDECAR that runs inside the auth task +# (not its own ECS service), so deploying it = build+push its image + force a new +# auth deployment so the task pulls the new sidecar image. +GOVALIDATE_IMAGE="go_validate" + # Defaults SERVICE="both" NO_CACHE="" @@ -39,18 +44,22 @@ SKIP_MONITOR="false" _print_usage() { - echo "Usage: $0 [--service registry|auth|both] [--no-cache] [--skip-monitor]" + echo "Usage: $0 [--service registry|auth|go-validate|both] [--no-cache] [--skip-monitor]" echo "" echo "Options:" - echo " --service Service to deploy: registry, auth, or both (default: both)" + echo " --service Service to deploy: registry, auth, go-validate, or both (default: both)" + echo " 'auth' and 'both' also build+deploy the go-validate sidecar (it" + echo " rides in the auth task). 'go-validate' rebuilds only the sidecar" + echo " and force-redeploys the auth service to pull it." echo " --no-cache Build Docker images without cache" echo " --skip-monitor Skip the deployment monitoring step" echo "" echo "Examples:" - echo " $0 # Deploy both services" + echo " $0 # Deploy registry + auth (+ go-validate sidecar)" echo " $0 --service registry # Deploy registry only" - echo " $0 --service auth # Deploy auth server only" - echo " $0 --no-cache --service auth # Deploy auth without cache" + echo " $0 --service auth # Deploy auth server + go-validate sidecar" + echo " $0 --service go-validate # Rebuild the sidecar + redeploy auth" + echo " $0 --no-cache --service auth # Deploy auth (+ sidecar) without cache" } @@ -59,12 +68,15 @@ _parse_args() { case "$1" in --service) SERVICE="$2" - # Accept auth_server as alias for auth + # Accept aliases if [[ "$SERVICE" == "auth_server" ]]; then SERVICE="auth" fi - if [[ "$SERVICE" != "registry" && "$SERVICE" != "auth" && "$SERVICE" != "both" ]]; then - echo "Error: --service must be 'registry', 'auth', 'auth_server', or 'both'" + if [[ "$SERVICE" == "go_validate" || "$SERVICE" == "govalidate" ]]; then + SERVICE="go-validate" + fi + if [[ "$SERVICE" != "registry" && "$SERVICE" != "auth" && "$SERVICE" != "go-validate" && "$SERVICE" != "both" ]]; then + echo "Error: --service must be 'registry', 'auth', 'go-validate', or 'both'" _print_usage exit 1 fi @@ -151,55 +163,62 @@ _monitor_deployment() { _deploy_services() { local step=1 - local total_steps=0 local monitor_services="" - # Calculate total steps + # Resolve what to build/deploy. go-validate is a sidecar in the auth task, so + # it is built for auth/both, and any of auth/go-validate/both redeploys auth. + local do_registry=false do_auth=false do_govalidate=false case "$SERVICE" in - registry) - total_steps=2 - if [[ "$SKIP_MONITOR" == "false" ]]; then - total_steps=3 - fi - ;; - auth) - total_steps=2 - if [[ "$SKIP_MONITOR" == "false" ]]; then - total_steps=3 - fi - ;; - both) - total_steps=4 - if [[ "$SKIP_MONITOR" == "false" ]]; then - total_steps=5 - fi - ;; + registry) do_registry=true ;; + auth) do_auth=true; do_govalidate=true ;; + go-validate) do_govalidate=true ;; + both) do_registry=true; do_auth=true; do_govalidate=true ;; esac + local deploy_auth=false + if [[ "$do_auth" == "true" || "$do_govalidate" == "true" ]]; then + deploy_auth=true + fi + + # Calculate total steps (builds + deploys + optional monitor) + local total_steps=0 + [[ "$do_registry" == "true" ]] && total_steps=$((total_steps + 1)) # build registry + [[ "$do_auth" == "true" ]] && total_steps=$((total_steps + 1)) # build auth + [[ "$do_govalidate" == "true" ]] && total_steps=$((total_steps + 1)) # build go-validate + [[ "$do_registry" == "true" ]] && total_steps=$((total_steps + 1)) # deploy registry + [[ "$deploy_auth" == "true" ]] && total_steps=$((total_steps + 1)) # deploy auth + [[ "$SKIP_MONITOR" == "false" ]] && total_steps=$((total_steps + 1)) # monitor # Build and push - if [[ "$SERVICE" == "registry" || "$SERVICE" == "both" ]]; then + if [[ "$do_registry" == "true" ]]; then echo "Step ${step}/${total_steps}: Building Registry" _build_and_push "$REGISTRY_IMAGE" "Registry" step=$((step + 1)) fi - - if [[ "$SERVICE" == "auth" || "$SERVICE" == "both" ]]; then + if [[ "$do_auth" == "true" ]]; then echo "Step ${step}/${total_steps}: Building Auth Server" _build_and_push "$AUTH_IMAGE" "Auth Server" step=$((step + 1)) fi + if [[ "$do_govalidate" == "true" ]]; then + echo "Step ${step}/${total_steps}: Building go-validate (auth /validate sidecar)" + _build_and_push "$GOVALIDATE_IMAGE" "go-validate" + step=$((step + 1)) + fi # Force new deployments - if [[ "$SERVICE" == "registry" || "$SERVICE" == "both" ]]; then + if [[ "$do_registry" == "true" ]]; then echo "Step ${step}/${total_steps}: Deploying Registry" _force_new_deployment "$REGISTRY_ECS_SERVICE" "Registry" monitor_services="$REGISTRY_ECS_SERVICE" step=$((step + 1)) fi - - if [[ "$SERVICE" == "auth" || "$SERVICE" == "both" ]]; then - echo "Step ${step}/${total_steps}: Deploying Auth Server" - _force_new_deployment "$AUTH_ECS_SERVICE" "Auth Server" + if [[ "$deploy_auth" == "true" ]]; then + local auth_label="Auth Server" + if [[ "$do_govalidate" == "true" ]]; then + auth_label="Auth Server (incl. go-validate sidecar)" + fi + echo "Step ${step}/${total_steps}: Deploying ${auth_label}" + _force_new_deployment "$AUTH_ECS_SERVICE" "$auth_label" if [[ -n "$monitor_services" ]]; then monitor_services="$monitor_services $AUTH_ECS_SERVICE" else From 8ea115e908ffa0fb5d4d486e5eab316c7f29876b Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Thu, 20 Aug 2026 16:43:19 +0000 Subject: [PATCH 22/26] fix(fast-path): pick SCRAM-SHA-1 from TLS signal when STORAGE_BACKEND unset The prior SCRAM fix keyed only on STORAGE_BACKEND, which requires a terraform apply to reach the sidecar. On the live ECS task (rev 157) STORAGE_BACKEND was never wired, so the code defaulted to mongodb-ce -> SCRAM-SHA-256 -> DocumentDB kept rejecting it ('Unsupported mechanism'), even though the TLS/CA fix worked. Make it robust: an explicit STORAGE_BACKEND still wins, but when it is unset, fall back to the TLS signal (DocumentDB always runs with TLS; local mongo-ce does not) -> tls=true => SCRAM-SHA-1. The sidecar already receives DOCUMENTDB_USE_TLS, so a plain image redeploy (deploy.sh --service go-validate) now fixes DocumentDB auth without needing another terraform apply. Test covers heuristic + explicit-wins. Refs #1652 --- go-validate/main_test.go | 12 ++++++++++++ go-validate/scopes.go | 9 +++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/go-validate/main_test.go b/go-validate/main_test.go index a9c2a4461..768bff724 100644 --- a/go-validate/main_test.go +++ b/go-validate/main_test.go @@ -361,6 +361,18 @@ func TestBuildMongoURI(t *testing.T) { if !contains(customURI, "tlsCAFile=%2Fcustom%2Fca.pem") { t.Fatalf("custom tlsCAFile not honored/encoded: %s", customURI) } + // Explicit non-documentdb backend keeps SCRAM-SHA-256 even with TLS on + // (explicit STORAGE_BACKEND wins over the TLS heuristic). + if !contains(customURI, "authMechanism=SCRAM-SHA-256") { + t.Fatalf("explicit mongodb-ce must stay SCRAM-SHA-256 even with TLS: %s", customURI) + } + // Heuristic: STORAGE_BACKEND unset + TLS on -> DocumentDB -> SCRAM-SHA-1 + // (covers the case where the sidecar's STORAGE_BACKEND env was never wired). + t.Setenv("STORAGE_BACKEND", "") + heuristicURI, _ := buildMongoURI() + if !contains(heuristicURI, "authMechanism=SCRAM-SHA-1") { + t.Fatalf("unset backend + TLS should fall back to SCRAM-SHA-1: %s", heuristicURI) + } } func contains(s, sub string) bool { return len(s) >= len(sub) && (indexOf(s, sub) >= 0) } diff --git a/go-validate/scopes.go b/go-validate/scopes.go index 2e9529643..a2d687856 100644 --- a/go-validate/scopes.go +++ b/go-validate/scopes.go @@ -79,9 +79,14 @@ func buildMongoURI() (string, bool) { pass := os.Getenv("DOCUMENTDB_PASSWORD") // Amazon DocumentDB v5.0 only supports SCRAM-SHA-1; every other MongoDB- // compatible backend (mongodb-ce / mongodb / atlas) supports SCRAM-SHA-256. - // Mirror registry/utils/mongodb_connection.py exactly, keyed on STORAGE_BACKEND. + // An explicit STORAGE_BACKEND wins (mirrors mongodb_connection.py). When it is + // NOT set, fall back to the TLS signal: DocumentDB always runs with TLS while + // local mongo-ce does not, so tls=true => DocumentDB => SCRAM-SHA-1. This keeps + // the sidecar correct even if STORAGE_BACKEND was never wired into its env. + useTLS := getenv("DOCUMENTDB_USE_TLS", "false") == "true" + backend := os.Getenv("STORAGE_BACKEND") authMech := "SCRAM-SHA-256" - if getenv("STORAGE_BACKEND", "mongodb-ce") == "documentdb" { + if backend == "documentdb" || (backend == "" && useTLS) { authMech = "SCRAM-SHA-1" } params := "authSource=admin&authMechanism=" + authMech From 8261a9126a2063a68ad00457c107f2f1e7891db9 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Thu, 20 Aug 2026 19:43:30 +0000 Subject: [PATCH 23/26] feat(fast-path): add Entra and Okta verifiers (multi-provider) Extend the fast path to Microsoft Entra ID and Okta, same pattern as Cognito, mirroring auth_server/providers/entra.py and okta.py. Shared machinery reused: RS256 verify (issuer-list + audience-list), group->scope + idp_m2m_clients enrichment, token minting, loud metrics, DocumentDB TLS/SCRAM. Entra (entra.go): - Dual issuers (v2 //v2.0 + v1 sts.windows.net//), JWKS at //discovery/v2.0/keys, all derived from ENTRA_* env. - Accepted audiences: client id + api:// + ENTRA_APPLICATION_ID_URI. - id_token replay guard: reject on id_token-only claims (nonce/at_hash/c_hash), deferring to Python (never accept a token Python rejects). - Groups from 'groups', or 'roles' for M2M tokens. Okta (okta.go): - Org vs custom-auth-server issuer/JWKS from OKTA_DOMAIN (+ OKTA_AUTH_SERVER_ID). - Accepted audiences: OKTA_CLIENT_ID + OKTA_M2M_CLIENT_ID + OKTA_M2M_ALLOWED_AUDIENCES. - username from 'sub', client id from 'cid', scopes from 'scp'/'scope'. Shared resolveScopes() finalizes scopes exactly like server.py: groups -> group->scope map; else M2M enrichment; else the token's own scope claim. Wiring: sidecar now receives ENTRA_*/OKTA_* in ECS + compose (already gets AUTH_PROVIDER). Provider auto-detected; no new enable param. Tests: verify (valid/v1-issuer/api-aud/id_token-reject/aud-mismatch), roles fallback, claim mapping, scp parsing, config derivation (custom + org auth server), resolveScopes. go test 71.4%, terraform validate ok. Auth0 + PingFederate still deferred (fallback-only) per plan. Refs #1652 --- docker-compose.yml | 11 ++ docs/unified-parameter-reference.md | 10 +- go-validate/config.go | 110 +++++++++++++++++- go-validate/config_test.go | 82 +++++++++++++ go-validate/entra.go | 68 +++++++++++ go-validate/entra_test.go | 97 +++++++++++++++ go-validate/jwt.go | 30 ++++- go-validate/main.go | 83 ++++++++++++- go-validate/main_test.go | 30 +++++ go-validate/okta.go | 36 ++++++ go-validate/okta_test.go | 74 ++++++++++++ .../modules/mcp-gateway/ecs-services.tf | 11 ++ 12 files changed, 635 insertions(+), 7 deletions(-) create mode 100644 go-validate/entra.go create mode 100644 go-validate/entra_test.go create mode 100644 go-validate/okta.go create mode 100644 go-validate/okta_test.go diff --git a/docker-compose.yml b/docker-compose.yml index 728d9b1bc..ed2bf7108 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -628,6 +628,17 @@ services: - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID:-} - COGNITO_M2M_CLIENT_IDS=${COGNITO_M2M_CLIENT_IDS:-} - IDE_OAUTH_CLIENT_ID=${IDE_OAUTH_CLIENT_ID:-} + # Entra (used when AUTH_PROVIDER=entra): + - ENTRA_TENANT_ID=${ENTRA_TENANT_ID:-} + - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID:-} + - ENTRA_LOGIN_BASE_URL=${ENTRA_LOGIN_BASE_URL:-} + - ENTRA_APPLICATION_ID_URI=${ENTRA_APPLICATION_ID_URI:-} + # Okta (used when AUTH_PROVIDER=okta): + - OKTA_DOMAIN=${OKTA_DOMAIN:-} + - OKTA_CLIENT_ID=${OKTA_CLIENT_ID:-} + - OKTA_M2M_CLIENT_ID=${OKTA_M2M_CLIENT_ID:-} + - OKTA_AUTH_SERVER_ID=${OKTA_AUTH_SERVER_ID:-} + - OKTA_M2M_ALLOWED_AUDIENCES=${OKTA_M2M_ALLOWED_AUDIENCES:-} # KEYCLOAK_* let go-validate auto-derive JWKS_URL, the accepted issuer list # (external + internal + localhost) and the accepted audience list (web # client id + M2M client id + "mcp-gateway") when the VALIDATE_* overrides diff --git a/docs/unified-parameter-reference.md b/docs/unified-parameter-reference.md index 5448afa2c..8e1d56e4a 100644 --- a/docs/unified-parameter-reference.md +++ b/docs/unified-parameter-reference.md @@ -148,8 +148,14 @@ Derived as `KEYCLOAK_CLIENT_ID` + `KEYCLOAK_M2M_CLIENT_ID` + `mcp-gateway`, matc **`JWKS_URL` — auto-derived, rarely overridden.** Derived as `/realms//protocol/openid-connect/certs`. Override only for a non-standard key path. -**Amazon Cognito (auto-detected from `AUTH_PROVIDER=cognito`).** -The sidecar also supports Cognito, mirroring the Python Cognito provider — no extra config beyond what the auth-server already has. When `AUTH_PROVIDER=cognito` (the auth-server sets it), the sidecar derives the issuer `https://cognito-idp..amazonaws.com/`, its JWKS, and the accepted client-id allowlist (`COGNITO_CLIENT_ID` + `IDE_OAUTH_CLIENT_ID` + `COGNITO_M2M_CLIENT_IDS`, with `*` = M2M-only wildcard). It fast-paths **access tokens** (id/login tokens defer to Python); scopes come from `cognito:groups` (group→scope) or, for machine/no-group tokens, the token's own `scope` claim. Nothing to set beyond the same enable switch — provider selection is automatic. `VALIDATE_AUDIENCE` does not apply to Cognito (access tokens are client_id-bound, not audience-bound). +**Multi-provider (auto-detected from `AUTH_PROVIDER`).** +The sidecar picks its verifier from `AUTH_PROVIDER` (the auth-server already sets it) and derives everything from that IdP's existing env — no extra config beyond the enable switch. Supported today: **Keycloak, Amazon Cognito, Microsoft Entra ID, Okta**. Any other provider (Auth0, PingFederate) stays fallback-only (deferred to Python) until added. Per provider: + +- **Cognito** — issuer `https://cognito-idp..amazonaws.com/`; client-id allowlist (`COGNITO_CLIENT_ID` + `IDE_OAUTH_CLIENT_ID` + `COGNITO_M2M_CLIENT_IDS`, `*` = M2M-only wildcard). Access tokens only; scopes from `cognito:groups` or the token `scope` claim. `VALIDATE_AUDIENCE` does not apply (client_id-bound). +- **Entra** — dual issuers (v2 `.../v2.0` + v1 `sts.windows.net//`) from `ENTRA_TENANT_ID` / `ENTRA_LOGIN_BASE_URL`; accepted audiences = `ENTRA_CLIENT_ID` + `api://` + `ENTRA_APPLICATION_ID_URI`. Groups from `groups` (or `roles` for M2M). id_token replay is deferred to Python. +- **Okta** — issuer/JWKS from `OKTA_DOMAIN` (+ `OKTA_AUTH_SERVER_ID` for a custom auth server, else the org server); accepted audiences = `OKTA_CLIENT_ID` + `OKTA_M2M_CLIENT_ID` + `OKTA_M2M_ALLOWED_AUDIENCES`. Groups from `groups`; client id from `cid`; scopes from `scp`/`scope`. + +All group-based providers resolve scopes identically to Python: `groups` → DocumentDB group→scope mapping (+ `idp_m2m_clients` M2M enrichment), otherwise the token's own scope claim. Anything the fast path can't reproduce exactly falls back safely to Python. To inspect a real token when debugging (decode `aud`/`iss` — but note `account` in the list is expected and correctly NOT accepted): diff --git a/go-validate/config.go b/go-validate/config.go index c0955159b..4a251b3c5 100644 --- a/go-validate/config.go +++ b/go-validate/config.go @@ -166,16 +166,23 @@ func dropAccount(in []string) []string { // Only "cognito" and "keycloak" are fast-pathed; anything else stays fallback-only. func detectProvider() string { p := strings.ToLower(strings.TrimSpace(os.Getenv("AUTH_PROVIDER"))) - if p == "cognito" || p == "keycloak" { + switch p { + case "cognito", "keycloak", "entra", "okta": return p } if os.Getenv("COGNITO_USER_POOL_ID") != "" { return "cognito" } + if os.Getenv("ENTRA_TENANT_ID") != "" { + return "entra" + } + if os.Getenv("OKTA_DOMAIN") != "" { + return "okta" + } if os.Getenv("KEYCLOAK_URL") != "" { return "keycloak" } - return p // e.g. "entra"/"okta"/"default"/"" -> keycloak-style derivation yields nothing -> fallback-only + return p // e.g. "auth0"/"pingfederate"/"default"/"" -> not fast-pathed yet -> fallback-only } // deriveCognito fills the Cognito issuer, JWKS URL and accepted client-id @@ -222,6 +229,97 @@ func deriveCognito(cfg *Config) { cfg.AcceptedClientIDs = ids } +// deriveEntra fills the Entra issuer list (v2 + v1), JWKS URL and accepted +// audiences from ENTRA_* env, mirroring auth_server/providers/entra.py. Explicit +// VALIDATE_ISSUER / JWKS_URL still win. +func deriveEntra(cfg *Config) { + tenant := strings.TrimSpace(os.Getenv("ENTRA_TENANT_ID")) + loginBase := strings.TrimRight(getenv("ENTRA_LOGIN_BASE_URL", "https://login.microsoftonline.com"), "/") + if tenant != "" { + base := fmt.Sprintf("%s/%s", loginBase, tenant) + if len(cfg.Issuers) == 0 { + cfg.Issuers = []string{ + base + "/v2.0", // v2 issuer + fmt.Sprintf("https://sts.windows.net/%s/", tenant), // v1 / M2M issuer + } + } + if cfg.JWKSURL == "" { + cfg.JWKSURL = base + "/discovery/v2.0/keys" + } + } + // accepted audiences: client id + api:// + Application ID URI. + auds := []string{} + add := func(a string) { + a = strings.TrimRight(strings.TrimSpace(a), "/") + if a == "" { + return + } + for _, e := range auds { + if e == a { + return + } + } + auds = append(auds, a) + } + if clientID := strings.TrimSpace(os.Getenv("ENTRA_CLIENT_ID")); clientID != "" { + add(clientID) + add("api://" + clientID) + } + add(os.Getenv("ENTRA_APPLICATION_ID_URI")) + if len(cfg.Audiences) == 0 { + cfg.Audiences = auds + } +} + +// deriveOkta fills the Okta issuer, JWKS URL and accepted audiences from OKTA_* +// env, mirroring auth_server/providers/okta.py (org vs custom auth server). +func deriveOkta(cfg *Config) { + domain := strings.TrimSpace(os.Getenv("OKTA_DOMAIN")) + domain = strings.TrimPrefix(domain, "https://") + domain = strings.TrimRight(domain, "/") + authServerID := strings.TrimSpace(os.Getenv("OKTA_AUTH_SERVER_ID")) + if domain != "" { + base := "https://" + domain + if authServerID != "" { + if len(cfg.Issuers) == 0 { + cfg.Issuers = []string{fmt.Sprintf("%s/oauth2/%s", base, authServerID)} + } + if cfg.JWKSURL == "" { + cfg.JWKSURL = fmt.Sprintf("%s/oauth2/%s/v1/keys", base, authServerID) + } + } else { + if len(cfg.Issuers) == 0 { + cfg.Issuers = []string{base} + } + if cfg.JWKSURL == "" { + cfg.JWKSURL = base + "/oauth2/v1/keys" + } + } + } + // accepted audiences: web client id + M2M client id + M2M allowed audiences. + auds := []string{} + add := func(a string) { + a = strings.TrimSpace(a) + if a == "" { + return + } + for _, e := range auds { + if e == a { + return + } + } + auds = append(auds, a) + } + add(os.Getenv("OKTA_CLIENT_ID")) + add(os.Getenv("OKTA_M2M_CLIENT_ID")) + for _, a := range parseList(os.Getenv("OKTA_M2M_ALLOWED_AUDIENCES")) { + add(a) + } + if len(cfg.Audiences) == 0 { + cfg.Audiences = auds + } +} + // loadConfig reads configuration from the environment and validates the signing key. // It exits the process (fail closed) when SECRET_KEY is present but weak/invalid. func loadConfig() Config { @@ -246,6 +344,14 @@ func loadConfig() Config { // COGNITO_* env. Access tokens are client_id-bound (no aud), so there is // no audience list; scopes come from cognito:groups or the scope claim. deriveCognito(&cfg) + } else if cfg.Provider == "entra" { + // Entra: dual issuers (v2 + v1) + accepted audiences (client id, + // api://, Application ID URI) from ENTRA_* env. + deriveEntra(&cfg) + } else if cfg.Provider == "okta" { + // Okta: org vs custom-auth-server issuer/JWKS + accepted audiences + // (client ids + M2M allowed audiences) from OKTA_* env. + deriveOkta(&cfg) } else { // Keycloak (default): auto-derive JWKS_URL and the accepted issuer list // (external/internal/localhost realm URLs) + audience list from KEYCLOAK_*, diff --git a/go-validate/config_test.go b/go-validate/config_test.go index e2aa4f382..ef178ab34 100644 --- a/go-validate/config_test.go +++ b/go-validate/config_test.go @@ -113,6 +113,88 @@ func TestLoadConfig_CognitoWildcard(t *testing.T) { } } +func TestLoadConfig_EntraDerivation(t *testing.T) { + t.Setenv("SECRET_KEY", "unit-test-secret-32-bytes-xxxxxxxxxx") + t.Setenv("AUTH_PROVIDER", "entra") + t.Setenv("ENTRA_TENANT_ID", "tenant-123") + t.Setenv("ENTRA_CLIENT_ID", "client-abc") + t.Setenv("ENTRA_APPLICATION_ID_URI", "api://mcp-gateway") + t.Setenv("JWKS_URL", "") + t.Setenv("VALIDATE_ISSUER", "") + t.Setenv("VALIDATE_AUDIENCE", "") + cfg := loadConfig() + if cfg.Provider != "entra" { + t.Fatalf("provider should be entra, got %q", cfg.Provider) + } + for _, want := range []string{ + "https://login.microsoftonline.com/tenant-123/v2.0", + "https://sts.windows.net/tenant-123/", + } { + if !containsStr(cfg.Issuers, want) { + t.Fatalf("entra issuers %v missing %q", cfg.Issuers, want) + } + } + if cfg.JWKSURL != "https://login.microsoftonline.com/tenant-123/discovery/v2.0/keys" { + t.Fatalf("entra jwks wrong: %q", cfg.JWKSURL) + } + for _, want := range []string{"client-abc", "api://client-abc", "api://mcp-gateway"} { + if !containsStr(cfg.Audiences, want) { + t.Fatalf("entra audiences %v missing %q", cfg.Audiences, want) + } + } + if !cfg.FastPathReady { + t.Fatal("entra fast path should be ready") + } +} + +func TestLoadConfig_OktaDerivation(t *testing.T) { + t.Setenv("SECRET_KEY", "unit-test-secret-32-bytes-xxxxxxxxxx") + t.Setenv("AUTH_PROVIDER", "okta") + t.Setenv("OKTA_DOMAIN", "dev-123.okta.com") + t.Setenv("OKTA_AUTH_SERVER_ID", "aus1") + t.Setenv("OKTA_CLIENT_ID", "okta-web") + t.Setenv("OKTA_M2M_CLIENT_ID", "okta-m2m") + t.Setenv("OKTA_M2M_ALLOWED_AUDIENCES", "api://ai-registry") + t.Setenv("JWKS_URL", "") + t.Setenv("VALIDATE_ISSUER", "") + t.Setenv("VALIDATE_AUDIENCE", "") + cfg := loadConfig() + if cfg.Provider != "okta" { + t.Fatalf("provider should be okta, got %q", cfg.Provider) + } + if len(cfg.Issuers) != 1 || cfg.Issuers[0] != "https://dev-123.okta.com/oauth2/aus1" { + t.Fatalf("okta issuer wrong: %v", cfg.Issuers) + } + if cfg.JWKSURL != "https://dev-123.okta.com/oauth2/aus1/v1/keys" { + t.Fatalf("okta jwks wrong: %q", cfg.JWKSURL) + } + for _, want := range []string{"okta-web", "okta-m2m", "api://ai-registry"} { + if !containsStr(cfg.Audiences, want) { + t.Fatalf("okta audiences %v missing %q", cfg.Audiences, want) + } + } + if !cfg.FastPathReady { + t.Fatal("okta fast path should be ready") + } +} + +func TestLoadConfig_OktaOrgServer(t *testing.T) { + t.Setenv("SECRET_KEY", "unit-test-secret-32-bytes-xxxxxxxxxx") + t.Setenv("AUTH_PROVIDER", "okta") + t.Setenv("OKTA_DOMAIN", "dev-123.okta.com") + t.Setenv("OKTA_AUTH_SERVER_ID", "") // org server (no custom auth server) + t.Setenv("OKTA_CLIENT_ID", "okta-web") + t.Setenv("JWKS_URL", "") + t.Setenv("VALIDATE_ISSUER", "") + cfg := loadConfig() + if len(cfg.Issuers) != 1 || cfg.Issuers[0] != "https://dev-123.okta.com" { + t.Fatalf("okta org issuer wrong: %v", cfg.Issuers) + } + if cfg.JWKSURL != "https://dev-123.okta.com/oauth2/v1/keys" { + t.Fatalf("okta org jwks wrong: %q", cfg.JWKSURL) + } +} + func TestLoadConfig_NoKeycloak_FallbackOnly(t *testing.T) { t.Setenv("SECRET_KEY", "") t.Setenv("KEYCLOAK_URL", "") diff --git a/go-validate/entra.go b/go-validate/entra.go new file mode 100644 index 000000000..806f56dde --- /dev/null +++ b/go-validate/entra.go @@ -0,0 +1,68 @@ +package main + +// Microsoft Entra ID fast path. Mirrors auth_server/providers/entra.py: +// +// - JWKS at //discovery/v2.0/keys. +// - TWO accepted issuers: v2 //v2.0 and v1 +// https://sts.windows.net// (M2M/v1 tokens use the v1 issuer). +// - Accepted audiences: the client id, api://, and the operator's +// Application ID URI. Verified as a closed allowlist (never a wildcard). +// - id_token replay guard: an Entra id_token shares the JWKS/issuer and has +// aud == client_id, so signature+issuer+audience do NOT distinguish it from +// an access token. Reject on id_token-only claims (nonce/at_hash/c_hash) -- +// here we defer such tokens to Python (which rejects them), never accept. +// - Groups: the "groups" claim for user tokens; for M2M tokens Entra puts +// membership in "roles", so fall back to roles when groups is empty. + +// verifyEntra verifies an Entra access token: standard RS256 + issuer-list + +// audience-list, then the id_token-only-claim guard. A token carrying an +// id_token-only claim is deferred to Python (errUnknownKey -> fallback), matching +// the authoritative path which rejects it, so the fast path never accepts a token +// Python would reject. +func verifyEntra( + token string, + ks *keysetCache, + issuers []string, + audiences []string, +) (*Claims, error) { + c, err := verifyRS256(token, ks, issuers, audiences) + if err != nil { + return nil, err + } + for _, idTokenOnly := range []string{"nonce", "at_hash", "c_hash"} { + if c.hasClaim(idTokenOnly) { + return nil, errUnknownKey // id_token presented as access token -> defer to Python + } + } + return c, nil +} + +// entraGroups returns the group memberships: the "groups" claim for user tokens, +// or the "roles" claim for M2M tokens (which carry membership there). +func entraGroups(c *Claims) []string { + if len(c.Groups) > 0 { + return c.Groups + } + return c.Roles +} + +// mapEntraClaims turns verified Entra claims into a caller identity. Username is +// preferred_username (falling back to sub); the client is in azp. +func mapEntraClaims( + c *Claims, + fallbackClientID string, +) identity { + username := c.Username + if username == "" { + username = c.Sub + } + clientID := c.Azp + if clientID == "" { + clientID = fallbackClientID + } + return identity{ + Sub: c.Sub, + Username: username, + ClientID: clientID, + } +} diff --git a/go-validate/entra_test.go b/go-validate/entra_test.go new file mode 100644 index 000000000..0398b04c6 --- /dev/null +++ b/go-validate/entra_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + "time" +) + +const ( + eIssV2 = "https://login.microsoftonline.com/tenant-123/v2.0" + eIssV1 = "https://sts.windows.net/tenant-123/" + eAud = "client-abc" +) + +var eIssuers = []string{eIssV2, eIssV1} +var eAuds = []string{eAud, "api://client-abc"} + +func entraClaims() map[string]any { + return map[string]any{ + "iss": eIssV2, "aud": eAud, "exp": time.Now().Add(time.Hour).Unix(), + "sub": "u-1", "preferred_username": "alice@corp", "azp": "client-abc", + "groups": []string{"admins"}, "scope": "User.Read", + } +} + +func TestVerifyEntra(t *testing.T) { + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + ks := testKeyset(&priv.PublicKey, "kid1") + + t.Run("valid v2 access token", func(t *testing.T) { + tok := mintRS256(t, priv, "kid1", entraClaims()) + c, err := verifyEntra(tok, ks, eIssuers, eAuds) + if err != nil || c.Username != "alice@corp" { + t.Fatalf("valid entra token failed: err=%v", err) + } + }) + t.Run("v1 issuer accepted", func(t *testing.T) { + cl := entraClaims() + cl["iss"] = eIssV1 + tok := mintRS256(t, priv, "kid1", cl) + if _, err := verifyEntra(tok, ks, eIssuers, eAuds); err != nil { + t.Fatalf("v1 issuer should be accepted: %v", err) + } + }) + t.Run("api:// audience accepted", func(t *testing.T) { + cl := entraClaims() + cl["aud"] = "api://client-abc" + tok := mintRS256(t, priv, "kid1", cl) + if _, err := verifyEntra(tok, ks, eIssuers, eAuds); err != nil { + t.Fatalf("api:// audience should be accepted: %v", err) + } + }) + t.Run("id_token (nonce) -> fallback", func(t *testing.T) { + cl := entraClaims() + cl["nonce"] = "abc123" // id_token-only claim + tok := mintRS256(t, priv, "kid1", cl) + if _, err := verifyEntra(tok, ks, eIssuers, eAuds); err != errUnknownKey { + t.Fatalf("id_token must be deferred to Python (errUnknownKey), got %v", err) + } + }) + t.Run("wrong audience -> fallback", func(t *testing.T) { + cl := entraClaims() + cl["aud"] = "some-other-app" + tok := mintRS256(t, priv, "kid1", cl) + if _, err := verifyEntra(tok, ks, eIssuers, eAuds); err != errUnknownKey { + t.Fatalf("unlisted audience must fall back, got %v", err) + } + }) +} + +func TestEntraGroups_RolesFallbackForM2M(t *testing.T) { + // User token: groups claim wins. + u := &Claims{Groups: []string{"g1"}, Roles: []string{"r1"}} + if got := entraGroups(u); len(got) != 1 || got[0] != "g1" { + t.Fatalf("user token should use groups, got %v", got) + } + // M2M token: no groups -> roles are the membership. + m := &Claims{Roles: []string{"App.Admin"}} + if got := entraGroups(m); len(got) != 1 || got[0] != "App.Admin" { + t.Fatalf("M2M token should use roles, got %v", got) + } +} + +func TestMapEntraClaims(t *testing.T) { + c := &Claims{Sub: "s-1", Username: "bob@corp", Azp: "cli-x"} + id := mapEntraClaims(c, "fallback-client") + if id.Username != "bob@corp" || id.ClientID != "cli-x" { + t.Fatalf("map wrong: %+v", id) + } + // azp absent -> fall back to configured client id. + c2 := &Claims{Sub: "s-2"} + id2 := mapEntraClaims(c2, "fallback-client") + if id2.Username != "s-2" || id2.ClientID != "fallback-client" { + t.Fatalf("fallback map wrong: %+v", id2) + } +} diff --git a/go-validate/jwt.go b/go-validate/jwt.go index 614da4fd9..c8a1a416a 100644 --- a/go-validate/jwt.go +++ b/go-validate/jwt.go @@ -68,7 +68,35 @@ type Claims struct { CognitoGroups []string `json:"cognito:groups"` TokenUse string `json:"token_use"` CognitoUsername string `json:"username"` - raw map[string]any + // Entra: M2M tokens carry group membership in "roles" (not "groups"). + Roles []string `json:"roles"` + // Okta: client id is in "cid"; scopes in "scp" (array or string). + Cid string `json:"cid"` + Scp json.RawMessage `json:"scp"` + raw map[string]any +} + +// scpOrScope returns the token's scopes, preferring Okta's "scp" (array or +// string) and falling back to the space-delimited "scope" claim. +func (c *Claims) scpOrScope() []string { + if len(c.Scp) > 0 { + var arr []string + if json.Unmarshal(c.Scp, &arr) == nil && len(arr) > 0 { + return arr + } + var s string + if json.Unmarshal(c.Scp, &s) == nil && s != "" { + return strings.Fields(s) + } + } + return strings.Fields(c.Scope) +} + +// hasClaim reports whether a raw claim key is present (used for id_token-only +// discriminators like nonce/at_hash/c_hash). +func (c *Claims) hasClaim(key string) bool { + _, ok := c.raw[key] + return ok } // audContains reports whether the token audience matches want (aud may be a string diff --git a/go-validate/main.go b/go-validate/main.go index c91d0f57f..c3b50f05b 100644 --- a/go-validate/main.go +++ b/go-validate/main.go @@ -161,10 +161,89 @@ func (s *server) resolveFastPath( r *http.Request, tok string, ) (identity, []string, []string, string, int) { - if s.cfg.Provider == "cognito" { + switch s.cfg.Provider { + case "cognito": return s.resolveCognito(tok) + case "entra": + return s.resolveEntra(tok) + case "okta": + return s.resolveOkta(tok) + default: + return s.resolveKeycloak(tok) + } +} + +// resolveScopes computes (groups, scopes) the way server.py finalizes them for +// group-based providers (entra/okta/keycloak): when the token carries groups, +// map them to scopes via DocumentDB; otherwise try M2M enrichment +// (idp_m2m_clients) and, failing that, use the token's own scope claim. Returns +// ok=false only when groups are present but the scope snapshot can't be read +// (caller must fall back to Python). +func (s *server) resolveScopes( + groups []string, + clientID string, + tokenScopes []string, +) ([]string, []string, bool) { + if len(groups) > 0 { + if s.scopes == nil { + return nil, nil, false + } + sc, ok := s.scopes.resolve(groups, clientID) + if !ok { + return nil, nil, false + } + return groups, sc, true + } + // No groups: a known M2M client enriches to groups->scopes; otherwise the + // token's own scope claim is authoritative (server.py's else branch). + if s.scopes != nil { + if sc, ok := s.scopes.resolve(nil, clientID); ok { + return nil, sc, true + } + } + return nil, tokenScopes, true +} + +// resolveEntra: verify an Entra access token (dual issuers, audience allowlist, +// id_token guard), map claims (groups or M2M roles), then resolve scopes. +func (s *server) resolveEntra( + tok string, +) (identity, []string, []string, string, int) { + claims, err := verifyEntra(tok, s.ks, s.cfg.Issuers, s.cfg.Audiences) + switch err { + case nil: + case errInvalidToken: + return identity{}, nil, nil, "", vUnauthorized + default: + return identity{}, nil, nil, "", vFallback + } + ident := mapEntraClaims(claims, os.Getenv("ENTRA_CLIENT_ID")) + grps, scopes, ok := s.resolveScopes(entraGroups(claims), ident.ClientID, strings.Fields(claims.Scope)) + if !ok { + return identity{}, nil, nil, "", vFallback + } + return ident, grps, scopes, "entra", vOK +} + +// resolveOkta: verify an Okta access token (single issuer, audience allowlist), +// map claims (sub/cid), then resolve scopes (scp/scope). +func (s *server) resolveOkta( + tok string, +) (identity, []string, []string, string, int) { + claims, err := verifyRS256(tok, s.ks, s.cfg.Issuers, s.cfg.Audiences) + switch err { + case nil: + case errInvalidToken: + return identity{}, nil, nil, "", vUnauthorized + default: + return identity{}, nil, nil, "", vFallback + } + ident := mapOktaClaims(claims, os.Getenv("OKTA_CLIENT_ID")) + grps, scopes, ok := s.resolveScopes(claims.Groups, ident.ClientID, claims.scpOrScope()) + if !ok { + return identity{}, nil, nil, "", vFallback } - return s.resolveKeycloak(tok) + return ident, grps, scopes, "okta", vOK } // resolveKeycloak: RS256 verify against the issuer/audience lists, then group->scope diff --git a/go-validate/main_test.go b/go-validate/main_test.go index 768bff724..6000ef639 100644 --- a/go-validate/main_test.go +++ b/go-validate/main_test.go @@ -305,6 +305,36 @@ func TestMetrics_DegradedWhenReadyButJWKSUnhealthy(t *testing.T) { } } +func TestResolveScopes(t *testing.T) { + res := &scopeResolver{} + res.snap.Store(&scopeSnapshot{ + scopes: []scopeDoc{{ID: "mcp-servers-unrestricted/read", GroupMappings: []string{"admins"}}}, + m2mGroups: map[string][]string{"svc-client": {"admins"}}, + }) + s := &server{scopes: res} + + // Groups present -> group->scope mapping (token scopes ignored). + grps, sc, ok := s.resolveScopes([]string{"admins"}, "anyclient", []string{"ignored"}) + if !ok || len(sc) != 1 || sc[0] != "mcp-servers-unrestricted/read" || len(grps) != 1 { + t.Fatalf("groups path wrong: grps=%v sc=%v ok=%v", grps, sc, ok) + } + // No groups + known M2M client -> enriched group->scope. + _, sc2, ok2 := s.resolveScopes(nil, "svc-client", []string{"tokenscope"}) + if !ok2 || len(sc2) != 1 || sc2[0] != "mcp-servers-unrestricted/read" { + t.Fatalf("M2M enrichment path wrong: sc=%v ok=%v", sc2, ok2) + } + // No groups + unknown client -> token scope claim is authoritative. + _, sc3, ok3 := s.resolveScopes(nil, "unknown-client", []string{"tok-a", "tok-b"}) + if !ok3 || len(sc3) != 2 || sc3[0] != "tok-a" { + t.Fatalf("token-scope fallback wrong: sc=%v ok=%v", sc3, ok3) + } + // Groups present but no snapshot -> caller must fall back. + sNoDB := &server{scopes: nil} + if _, _, ok4 := sNoDB.resolveScopes([]string{"admins"}, "c", nil); ok4 { + t.Fatal("groups present + no snapshot must return ok=false (fallback)") + } +} + func TestMissingReason(t *testing.T) { // Nothing set -> every prerequisite named. got := missingReason(Config{}) diff --git a/go-validate/okta.go b/go-validate/okta.go new file mode 100644 index 000000000..0c3eebbcf --- /dev/null +++ b/go-validate/okta.go @@ -0,0 +1,36 @@ +package main + +// Okta fast path. Mirrors auth_server/providers/okta.py: +// +// - Org vs custom-authorization-server URLs: with OKTA_AUTH_SERVER_ID set the +// issuer is https:///oauth2/ and JWKS /v1/keys; without +// it the org issuer is https:// and JWKS /oauth2/v1/keys. +// - Accepted audiences: the web client id + M2M client id + any operator- +// configured M2M audiences (custom auth servers mint M2M tokens whose aud is +// an API identifier, not a client id). Closed allowlist, verified. +// - Groups: the "groups" claim. Client id: the "cid" claim. Username: "sub". +// Scopes: "scp" (array/string) or "scope". +// +// Okta access tokens are standard RS256 with a single issuer, so verification is +// plain verifyRS256; only the claim mapping differs from Keycloak. + +// mapOktaClaims turns verified Okta claims into a caller identity. Okta uses +// "sub" as the principal and "cid" as the client id. +func mapOktaClaims( + c *Claims, + fallbackClientID string, +) identity { + username := c.Sub + if username == "" { + username = c.Username + } + clientID := c.Cid + if clientID == "" { + clientID = fallbackClientID + } + return identity{ + Sub: c.Sub, + Username: username, + ClientID: clientID, + } +} diff --git a/go-validate/okta_test.go b/go-validate/okta_test.go new file mode 100644 index 000000000..dd1dfbf09 --- /dev/null +++ b/go-validate/okta_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "testing" + "time" +) + +const oIss = "https://dev-123.okta.com/oauth2/aus1" +const oAud = "okta-client-1" + +func oktaClaims() map[string]any { + return map[string]any{ + "iss": oIss, "aud": oAud, "exp": time.Now().Add(time.Hour).Unix(), + "sub": "okta-user-1", "cid": "okta-client-1", + "groups": []string{"admins"}, "scp": []string{"registry.read"}, + } +} + +func TestVerifyOkta(t *testing.T) { + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + ks := testKeyset(&priv.PublicKey, "kid1") + issuers := []string{oIss} + auds := []string{oAud} + + t.Run("valid access token", func(t *testing.T) { + tok := mintRS256(t, priv, "kid1", oktaClaims()) + c, err := verifyRS256(tok, ks, issuers, auds) + if err != nil || c.Cid != "okta-client-1" { + t.Fatalf("valid okta token failed: err=%v", err) + } + }) + t.Run("wrong audience -> fallback", func(t *testing.T) { + cl := oktaClaims() + cl["aud"] = "other" + tok := mintRS256(t, priv, "kid1", cl) + if _, err := verifyRS256(tok, ks, issuers, auds); err != errUnknownKey { + t.Fatalf("unlisted audience must fall back, got %v", err) + } + }) +} + +func TestMapOktaClaims(t *testing.T) { + c := &Claims{Sub: "okta-user-1", Cid: "cli-1"} + id := mapOktaClaims(c, "fallback") + if id.Username != "okta-user-1" || id.ClientID != "cli-1" { + t.Fatalf("okta map wrong: %+v", id) + } + // cid absent -> fall back to configured client id. + c2 := &Claims{Sub: "u2"} + if id2 := mapOktaClaims(c2, "fallback"); id2.ClientID != "fallback" { + t.Fatalf("okta fallback client id wrong: %+v", id2) + } +} + +func TestScpOrScope(t *testing.T) { + // scp as array + c := &Claims{Scp: json.RawMessage(`["a","b"]`)} + if got := c.scpOrScope(); len(got) != 2 || got[0] != "a" { + t.Fatalf("scp array parse wrong: %v", got) + } + // scp as string + c2 := &Claims{Scp: json.RawMessage(`"x y"`)} + if got := c2.scpOrScope(); len(got) != 2 || got[1] != "y" { + t.Fatalf("scp string parse wrong: %v", got) + } + // fall back to scope + c3 := &Claims{Scope: "s1 s2 s3"} + if got := c3.scpOrScope(); len(got) != 3 { + t.Fatalf("scope fallback wrong: %v", got) + } +} diff --git a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf index 7821da613..50e2bccbc 100755 --- a/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf +++ b/terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf @@ -763,6 +763,17 @@ module "ecs_service_auth" { { name = "COGNITO_CLIENT_ID", value = var.cognito_client_id }, { name = "COGNITO_M2M_CLIENT_IDS", value = var.cognito_m2m_client_ids }, { name = "IDE_OAUTH_CLIENT_ID", value = var.ide_oauth_client_id }, + # Entra (used when AUTH_PROVIDER=entra): + { name = "ENTRA_TENANT_ID", value = var.entra_tenant_id }, + { name = "ENTRA_CLIENT_ID", value = var.entra_client_id }, + { name = "ENTRA_LOGIN_BASE_URL", value = var.entra_login_base_url }, + { name = "ENTRA_APPLICATION_ID_URI", value = var.entra_application_id_uri }, + # Okta (used when AUTH_PROVIDER=okta): + { name = "OKTA_DOMAIN", value = var.okta_domain }, + { name = "OKTA_CLIENT_ID", value = var.okta_client_id }, + { name = "OKTA_M2M_CLIENT_ID", value = var.okta_m2m_client_id }, + { name = "OKTA_AUTH_SERVER_ID", value = var.okta_auth_server_id }, + { name = "OKTA_M2M_ALLOWED_AUDIENCES", value = var.okta_m2m_allowed_audiences }, # Keycloak (used when AUTH_PROVIDER=keycloak): { name = "KEYCLOAK_URL", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}" : "" }, { name = "KEYCLOAK_EXTERNAL_URL", value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}" : "" }, From 2d9c9fa0db42489f87931d0b76bc6b8dca5a015b Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Fri, 21 Aug 2026 02:31:51 +0000 Subject: [PATCH 24/26] fix(ci): resolve helm nil-pointer, reserved-env-sync, and detect-secrets Rebaselined on main (merge) surfaced three failing checks, all from this branch: - Helm render nil-pointer: the stack values set auth-server.fastPath to a comment-only block (YAML null), which nilled the subchart's fastPath map -> 'nil pointer evaluating interface {}.enabled' at service.yaml. Set it to an empty map ({}) so subchart defaults survive, and make the templates nil-safe with (.Values.fastPath).enabled in service.yaml + deployment.yaml. Fixes both 'Helm Unit Tests' and 'Reserved Env Name List Sync' (the latter only failed because the render crashed; VALIDATE_UPSTREAM_URL is already in the reserved list). - detect-secrets: regenerated .secrets.baseline via the documented command (detect-secrets v1.5.0, --slim, same excludes) so the test-fixture 'secrets' in go-validate/ and charts/*/tests/ are baselined. No .env/real secrets included. Verified locally: helm unittest 195 pass, reserved-env-sync check passes (registry 119 / auth 59 / mcpgw 17 all reserved), detect-secrets-hook passes, go test + terraform validate ok. --- .secrets.baseline | 273 ++++++++++-------- charts/auth-server/templates/deployment.yaml | 2 +- charts/auth-server/templates/service.yaml | 2 +- charts/mcp-gateway-registry-stack/values.yaml | 6 +- 4 files changed, 159 insertions(+), 124 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 5a68ad5f5..fced6be46 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -90,10 +90,6 @@ { "path": "detect_secrets.filters.allowlist.is_line_allowlisted" }, - { - "path": "detect_secrets.filters.common.is_baseline_file", - "filename": ".secrets.baseline" - }, { "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2 @@ -135,15 +131,15 @@ "results": { ".env.example": [ { - "type": "Basic Auth Credentials", + "type": "Private Key", "filename": ".env.example", - "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", + "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", "is_verified": false }, { - "type": "Private Key", + "type": "Basic Auth Credentials", "filename": ".env.example", - "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", + "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false }, { @@ -187,13 +183,13 @@ { "type": "Secret Keyword", "filename": "api/registry_management.py", - "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", + "hashed_secret": "fca71afec681b7c2932610046e8e524820317e47", "is_verified": false }, { "type": "Secret Keyword", "filename": "api/registry_management.py", - "hashed_secret": "fca71afec681b7c2932610046e8e524820317e47", + "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", "is_verified": false } ], @@ -223,13 +219,21 @@ { "type": "Secret Keyword", "filename": "charts/auth-server/tests/extra_env_test.yaml", - "hashed_secret": "78eaf27d3b60b8aed36fab730eab411cccf9bd45", + "hashed_secret": "88476b6dceae199c780d7437198aa3710ac7ed77", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/tests/extra_env_test.yaml", - "hashed_secret": "88476b6dceae199c780d7437198aa3710ac7ed77", + "hashed_secret": "78eaf27d3b60b8aed36fab730eab411cccf9bd45", + "is_verified": false + } + ], + "charts/auth-server/tests/go_validate_test.yaml": [ + { + "type": "Secret Keyword", + "filename": "charts/auth-server/tests/go_validate_test.yaml", + "hashed_secret": "18b8871337bc0ddce96fabd95f40184158b150ed", "is_verified": false } ], @@ -237,13 +241,13 @@ { "type": "Secret Keyword", "filename": "charts/auth-server/tests/m2m_provider_secret_test.yaml", - "hashed_secret": "5458da12886ca7542a380eae44cdc8af7878c428", + "hashed_secret": "8d858d09eabb17ed3ce3aa2b8f8a12e6c6e9f27c", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/tests/m2m_provider_secret_test.yaml", - "hashed_secret": "8d858d09eabb17ed3ce3aa2b8f8a12e6c6e9f27c", + "hashed_secret": "5458da12886ca7542a380eae44cdc8af7878c428", "is_verified": false }, { @@ -273,55 +277,55 @@ { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", - "hashed_secret": "1a205997a828de0c82b0e8b2b9712a97f6738749", + "hashed_secret": "8d44de1035672968b3e922b3d15e08c1dce4f9b6", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", - "hashed_secret": "20ab933de8537779a343db947f203175c1787df9", + "hashed_secret": "78eaf27d3b60b8aed36fab730eab411cccf9bd45", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", - "hashed_secret": "439402fe994e10d329c647053a32e328435dbdd1", + "hashed_secret": "9f79d5059cb6d28f43bc0a0a4cfb5fe41997f77e", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", - "hashed_secret": "746cdd091254f03d46f6c7a69426aee2a915a349", + "hashed_secret": "1a205997a828de0c82b0e8b2b9712a97f6738749", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", - "hashed_secret": "78eaf27d3b60b8aed36fab730eab411cccf9bd45", + "hashed_secret": "e06d2622ad3f7baae3a8dbe9a151185ebadc4a00", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", - "hashed_secret": "8701937f7403c98656ca9313f75123cbe810a77b", + "hashed_secret": "746cdd091254f03d46f6c7a69426aee2a915a349", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", - "hashed_secret": "8d44de1035672968b3e922b3d15e08c1dce4f9b6", + "hashed_secret": "439402fe994e10d329c647053a32e328435dbdd1", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", - "hashed_secret": "9f79d5059cb6d28f43bc0a0a4cfb5fe41997f77e", + "hashed_secret": "20ab933de8537779a343db947f203175c1787df9", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", - "hashed_secret": "e06d2622ad3f7baae3a8dbe9a151185ebadc4a00", + "hashed_secret": "8701937f7403c98656ca9313f75123cbe810a77b", "is_verified": false }, { @@ -369,13 +373,13 @@ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/keycloak-admin-secret.yaml", - "hashed_secret": "7785fd10808212759cbef921c93c10bc9993844d", + "hashed_secret": "e3568c17ddb547dd50c4b4990152e9ad46ac29ea", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/keycloak-admin-secret.yaml", - "hashed_secret": "e3568c17ddb547dd50c4b4990152e9ad46ac29ea", + "hashed_secret": "7785fd10808212759cbef921c93c10bc9993844d", "is_verified": false } ], @@ -383,13 +387,13 @@ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/keycloak-pg-secret.yaml", - "hashed_secret": "7785fd10808212759cbef921c93c10bc9993844d", + "hashed_secret": "e3568c17ddb547dd50c4b4990152e9ad46ac29ea", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/keycloak-pg-secret.yaml", - "hashed_secret": "e3568c17ddb547dd50c4b4990152e9ad46ac29ea", + "hashed_secret": "7785fd10808212759cbef921c93c10bc9993844d", "is_verified": false } ], @@ -413,13 +417,13 @@ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/shared-secret.yaml", - "hashed_secret": "94c6c8fdccfc8f4fe660af892feaabdc8d8d2201", + "hashed_secret": "e3568c17ddb547dd50c4b4990152e9ad46ac29ea", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/shared-secret.yaml", - "hashed_secret": "e3568c17ddb547dd50c4b4990152e9ad46ac29ea", + "hashed_secret": "94c6c8fdccfc8f4fe660af892feaabdc8d8d2201", "is_verified": false } ], @@ -427,27 +431,27 @@ { "type": "Base64 High Entropy String", "filename": "charts/mcp-gateway-registry-stack/tests/ingress_topology_test.yaml", - "hashed_secret": "331bd1c1ebbac84bfdb8ba2548dccc6f9be7e6b6", + "hashed_secret": "f9679097ee060a0d9703dd613d0ff46fe87768b4", "is_verified": false }, { "type": "Base64 High Entropy String", "filename": "charts/mcp-gateway-registry-stack/tests/ingress_topology_test.yaml", - "hashed_secret": "f9679097ee060a0d9703dd613d0ff46fe87768b4", + "hashed_secret": "331bd1c1ebbac84bfdb8ba2548dccc6f9be7e6b6", "is_verified": false } ], "charts/mcp-gateway-registry-stack/tests/mongodb_password_test.yaml": [ { - "type": "Basic Auth Credentials", + "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/tests/mongodb_password_test.yaml", - "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", + "hashed_secret": "e68f26376dfc18e4bdd65d27e1d37a98a5d29aec", "is_verified": false }, { - "type": "Secret Keyword", + "type": "Basic Auth Credentials", "filename": "charts/mcp-gateway-registry-stack/tests/mongodb_password_test.yaml", - "hashed_secret": "383ae184075bf5d112bc72f10d18761211eaef49", + "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", "is_verified": false }, { @@ -459,7 +463,7 @@ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/tests/mongodb_password_test.yaml", - "hashed_secret": "e68f26376dfc18e4bdd65d27e1d37a98a5d29aec", + "hashed_secret": "383ae184075bf5d112bc72f10d18761211eaef49", "is_verified": false } ], @@ -467,19 +471,19 @@ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/values.yaml", - "hashed_secret": "3332a7683e9039d8dc7b0230b8f195effbde59d5", + "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/values.yaml", - "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", + "hashed_secret": "f880fa90169f5214a7e9c6a817b3f31aeb71f5c7", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/values.yaml", - "hashed_secret": "d7edae22c30f3f6a1990bfd68bd0244cf95ab376", + "hashed_secret": "3332a7683e9039d8dc7b0230b8f195effbde59d5", "is_verified": false }, { @@ -491,7 +495,7 @@ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/values.yaml", - "hashed_secret": "f880fa90169f5214a7e9c6a817b3f31aeb71f5c7", + "hashed_secret": "d7edae22c30f3f6a1990bfd68bd0244cf95ab376", "is_verified": false } ], @@ -515,13 +519,13 @@ { "type": "Secret Keyword", "filename": "charts/mcpgw/values.yaml", - "hashed_secret": "36267a66933bcd4df227db8c99734613d5beb6c9", + "hashed_secret": "aa90ae690498f4d84834974d12a9990b594e338e", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/mcpgw/values.yaml", - "hashed_secret": "aa90ae690498f4d84834974d12a9990b594e338e", + "hashed_secret": "36267a66933bcd4df227db8c99734613d5beb6c9", "is_verified": false }, { @@ -543,13 +547,13 @@ { "type": "Secret Keyword", "filename": "charts/mongodb-configure/templates/secret.yaml", - "hashed_secret": "872555da6f179fdcb9496bead16aa050d15b80a0", + "hashed_secret": "902ff5b10c04ba08defd538075c5e6c8cc1a977f", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/mongodb-configure/templates/secret.yaml", - "hashed_secret": "902ff5b10c04ba08defd538075c5e6c8cc1a977f", + "hashed_secret": "872555da6f179fdcb9496bead16aa050d15b80a0", "is_verified": false }, { @@ -571,13 +575,13 @@ { "type": "Secret Keyword", "filename": "charts/registry/tests/m2m_provider_secret_test.yaml", - "hashed_secret": "5458da12886ca7542a380eae44cdc8af7878c428", + "hashed_secret": "8d858d09eabb17ed3ce3aa2b8f8a12e6c6e9f27c", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/tests/m2m_provider_secret_test.yaml", - "hashed_secret": "8d858d09eabb17ed3ce3aa2b8f8a12e6c6e9f27c", + "hashed_secret": "5458da12886ca7542a380eae44cdc8af7878c428", "is_verified": false }, { @@ -607,39 +611,53 @@ { "type": "Secret Keyword", "filename": "charts/registry/tests/standalone_render_test.yaml", - "hashed_secret": "5458da12886ca7542a380eae44cdc8af7878c428", + "hashed_secret": "8d858d09eabb17ed3ce3aa2b8f8a12e6c6e9f27c", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/tests/standalone_render_test.yaml", + "hashed_secret": "5458da12886ca7542a380eae44cdc8af7878c428", + "is_verified": false + } + ], + "charts/registry/tests/validate_upstream_test.yaml": [ + { + "type": "Secret Keyword", + "filename": "charts/registry/tests/validate_upstream_test.yaml", "hashed_secret": "8d858d09eabb17ed3ce3aa2b8f8a12e6c6e9f27c", "is_verified": false + }, + { + "type": "Secret Keyword", + "filename": "charts/registry/tests/validate_upstream_test.yaml", + "hashed_secret": "5458da12886ca7542a380eae44cdc8af7878c428", + "is_verified": false } ], "charts/registry/values.yaml": [ { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "1a205997a828de0c82b0e8b2b9712a97f6738749", + "hashed_secret": "c83acc39662eea92bcfbd9dc69d4dbe5fc0f2951", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "20ab933de8537779a343db947f203175c1787df9", + "hashed_secret": "eadecfbb5155f2279b9468ae7095d795d4c31eaa", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "439402fe994e10d329c647053a32e328435dbdd1", + "hashed_secret": "d93c7388831740ddd2f456178a638ff928bc2c64", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "746cdd091254f03d46f6c7a69426aee2a915a349", + "hashed_secret": "db5f71d7c067045961370a578eb1519155fa0c7f", "is_verified": false }, { @@ -651,61 +669,61 @@ { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "8701937f7403c98656ca9313f75123cbe810a77b", + "hashed_secret": "9f79d5059cb6d28f43bc0a0a4cfb5fe41997f77e", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "9f79d5059cb6d28f43bc0a0a4cfb5fe41997f77e", + "hashed_secret": "1a205997a828de0c82b0e8b2b9712a97f6738749", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "ba2d2bfc8327652b19caa309752e8a69da799570", + "hashed_secret": "e06d2622ad3f7baae3a8dbe9a151185ebadc4a00", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "c83acc39662eea92bcfbd9dc69d4dbe5fc0f2951", + "hashed_secret": "746cdd091254f03d46f6c7a69426aee2a915a349", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "d7edae22c30f3f6a1990bfd68bd0244cf95ab376", + "hashed_secret": "439402fe994e10d329c647053a32e328435dbdd1", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "d93c7388831740ddd2f456178a638ff928bc2c64", + "hashed_secret": "20ab933de8537779a343db947f203175c1787df9", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "db5f71d7c067045961370a578eb1519155fa0c7f", + "hashed_secret": "ba2d2bfc8327652b19caa309752e8a69da799570", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "e06d2622ad3f7baae3a8dbe9a151185ebadc4a00", + "hashed_secret": "8701937f7403c98656ca9313f75123cbe810a77b", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "eadecfbb5155f2279b9468ae7095d795d4c31eaa", + "hashed_secret": "fa0c892adb7c24fd16f30ba06ff1307bc0e9347d", "is_verified": false }, { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", - "hashed_secret": "fa0c892adb7c24fd16f30ba06ff1307bc0e9347d", + "hashed_secret": "d7edae22c30f3f6a1990bfd68bd0244cf95ab376", "is_verified": false } ], @@ -735,13 +753,13 @@ { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", - "hashed_secret": "4ad9c5ebcdbd110afa5ca680854dd5bd72314bb8", + "hashed_secret": "9b45b018ce366a8d8b440df12fadc183406c92d6", "is_verified": false }, { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", - "hashed_secret": "61da47b9d42215793e5604b478982f4cb21fdee1", + "hashed_secret": "4ad9c5ebcdbd110afa5ca680854dd5bd72314bb8", "is_verified": false }, { @@ -753,19 +771,19 @@ { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", - "hashed_secret": "9b45b018ce366a8d8b440df12fadc183406c92d6", + "hashed_secret": "c8883fc592bf698b29fd2304fa1ad570df1f9abf", "is_verified": false }, { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", - "hashed_secret": "aa684a0841bf2d1fd7e9b774262fcddc9920ffc6", + "hashed_secret": "61da47b9d42215793e5604b478982f4cb21fdee1", "is_verified": false }, { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", - "hashed_secret": "c8883fc592bf698b29fd2304fa1ad570df1f9abf", + "hashed_secret": "aa684a0841bf2d1fd7e9b774262fcddc9920ffc6", "is_verified": false } ], @@ -831,6 +849,22 @@ "is_verified": false } ], + "go-validate/jwt_test.go": [ + { + "type": "Secret Keyword", + "filename": "go-validate/jwt_test.go", + "hashed_secret": "dc1e45e5fbccea8d62acb500815f633806edb9fa", + "is_verified": false + } + ], + "go-validate/scopes.go": [ + { + "type": "Basic Auth Credentials", + "filename": "go-validate/scopes.go", + "hashed_secret": "347cd9c53ff77d41a7b22aa56c7b4efaf54658e3", + "is_verified": false + } + ], "infra/lib/registry/constructs/keycloak-database.ts": [ { "type": "Secret Keyword", @@ -849,61 +883,61 @@ { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "0685fbab3da08d424888cbd13fe5b4310afc51f9", + "hashed_secret": "4c7b23bba3bd58312c1869b08cf03a98b920a0b6", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "14373203290439404839aea5d511e2e4a8fed9ae", + "hashed_secret": "251d7333767b2099909e4415895b0cde1d40c14f", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "1c9cd03017328f538f348bbb19815bfab8c11482", + "hashed_secret": "2f5a53266dd4a62742f526a4f16aa4b63fd8cb17", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "251d7333767b2099909e4415895b0cde1d40c14f", + "hashed_secret": "c3b1c09e10e59c4dec1e15c126f07ab262d23e6e", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "2f5a53266dd4a62742f526a4f16aa4b63fd8cb17", + "hashed_secret": "6b8769d7c3e4c2e52c51088ce337e194a660734c", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "4c7b23bba3bd58312c1869b08cf03a98b920a0b6", + "hashed_secret": "a5652edd67a4b445f5b8e2e40dada0444e990bcf", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "6514306575aa50b09f056aad4af05afd98471ee3", + "hashed_secret": "1c9cd03017328f538f348bbb19815bfab8c11482", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "6b8769d7c3e4c2e52c51088ce337e194a660734c", + "hashed_secret": "eff7857df9209080ee0c76e2ee1a58fd28fc6c62", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "a5652edd67a4b445f5b8e2e40dada0444e990bcf", + "hashed_secret": "6514306575aa50b09f056aad4af05afd98471ee3", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "c3b1c09e10e59c4dec1e15c126f07ab262d23e6e", + "hashed_secret": "0685fbab3da08d424888cbd13fe5b4310afc51f9", "is_verified": false }, { @@ -915,13 +949,13 @@ { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "e3e55648a10331890b4e59e3ed33738526a1f427", + "hashed_secret": "14373203290439404839aea5d511e2e4a8fed9ae", "is_verified": false }, { "type": "Secret Keyword", "filename": "infra/lib/registry/registry-config.ts", - "hashed_secret": "eff7857df9209080ee0c76e2ee1a58fd28fc6c62", + "hashed_secret": "e3e55648a10331890b4e59e3ed33738526a1f427", "is_verified": false } ], @@ -945,31 +979,31 @@ { "type": "Secret Keyword", "filename": "keycloak/README.md", - "hashed_secret": "001c1654cb8dff7c4ddb1ae6d2203d0dd15a6096", + "hashed_secret": "534c57bf48f9277e7ee50c5febcdb3dab99f0051", "is_verified": false }, { "type": "Secret Keyword", "filename": "keycloak/README.md", - "hashed_secret": "354b3a4b7715d3694c88a4fa7db49e41de86568e", + "hashed_secret": "001c1654cb8dff7c4ddb1ae6d2203d0dd15a6096", "is_verified": false }, { "type": "Secret Keyword", "filename": "keycloak/README.md", - "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", + "hashed_secret": "354b3a4b7715d3694c88a4fa7db49e41de86568e", "is_verified": false }, { "type": "Secret Keyword", "filename": "keycloak/README.md", - "hashed_secret": "534c57bf48f9277e7ee50c5febcdb3dab99f0051", + "hashed_secret": "7b0e6379ca79d9a02abc556232d503a86c37012e", "is_verified": false }, { "type": "Secret Keyword", "filename": "keycloak/README.md", - "hashed_secret": "7b0e6379ca79d9a02abc556232d503a86c37012e", + "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false } ], @@ -993,13 +1027,13 @@ { "type": "Secret Keyword", "filename": "keycloak/setup/setup-federation-service-account.sh", - "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", + "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false }, { "type": "Secret Keyword", "filename": "keycloak/setup/setup-federation-service-account.sh", - "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", + "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", "is_verified": false } ], @@ -1111,13 +1145,13 @@ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/README.md", - "hashed_secret": "145f85ed29830a933e12fb56dcfb94ce29172f65", + "hashed_secret": "4d0d3c53f51abc7660789000a958332860aa8280", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/README.md", - "hashed_secret": "19a4df734b1b7b83858d6002352ba67c91f1f4b5", + "hashed_secret": "145f85ed29830a933e12fb56dcfb94ce29172f65", "is_verified": false }, { @@ -1129,7 +1163,7 @@ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/README.md", - "hashed_secret": "4d0d3c53f51abc7660789000a958332860aa8280", + "hashed_secret": "19a4df734b1b7b83858d6002352ba67c91f1f4b5", "is_verified": false }, { @@ -1213,19 +1247,19 @@ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/init-keycloak.sh", - "hashed_secret": "1d53cb3d4139b23445aa6caca91daf01cdfe8570", + "hashed_secret": "4da7ca8d90c1c53b092ad7150bb2d52f8f8bf3ff", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/init-keycloak.sh", - "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", + "hashed_secret": "1d53cb3d4139b23445aa6caca91daf01cdfe8570", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/init-keycloak.sh", - "hashed_secret": "4da7ca8d90c1c53b092ad7150bb2d52f8f8bf3ff", + "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", "is_verified": false }, { @@ -1239,25 +1273,25 @@ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/post-deployment-setup.sh", - "hashed_secret": "1d53cb3d4139b23445aa6caca91daf01cdfe8570", + "hashed_secret": "6eef6648406c333a4035cd5e60d0bf2ecf2606d7", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/post-deployment-setup.sh", - "hashed_secret": "4da7ca8d90c1c53b092ad7150bb2d52f8f8bf3ff", + "hashed_secret": "e3eba309413812b94096a6477501e13853a616b4", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/post-deployment-setup.sh", - "hashed_secret": "6eef6648406c333a4035cd5e60d0bf2ecf2606d7", + "hashed_secret": "4da7ca8d90c1c53b092ad7150bb2d52f8f8bf3ff", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/post-deployment-setup.sh", - "hashed_secret": "e3eba309413812b94096a6477501e13853a616b4", + "hashed_secret": "1d53cb3d4139b23445aa6caca91daf01cdfe8570", "is_verified": false } ], @@ -1305,112 +1339,111 @@ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "01b1a021a74c4b51fe616e4c1487962a96ccaa78", + "hashed_secret": "b81a4503bd668cde97ef070bfe9cf2baca9872e0", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "28d87938dd56fa9f81fdd109aa97f14b98bb3fd0", + "hashed_secret": "f60d623e416a938ffa3a98bba1d5cdcd38eba18a", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "319f2473faba769797a9662abb2ac61d360a0742", + "hashed_secret": "01b1a021a74c4b51fe616e4c1487962a96ccaa78", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "41b9210716e431c5418cc3563da73f4dea8c1198", + "hashed_secret": "e3eba309413812b94096a6477501e13853a616b4", "is_verified": false }, { - "type": "Secret Keyword", + "type": "Base64 High Entropy String", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "69bc2d1e992c7e4d52e438d3ed2c589cd2a4b1bb", + "hashed_secret": "786cb94d07e7f41c3c6bb32b438bda24b42de0b3", "is_verified": false }, { - "type": "Base64 High Entropy String", + "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "786cb94d07e7f41c3c6bb32b438bda24b42de0b3", "is_verified": false }, { - "type": "Secret Keyword", + "type": "Basic Auth Credentials", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "786cb94d07e7f41c3c6bb32b438bda24b42de0b3", + "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "788b6b2bfd50bb3353254fb8a62d7388cf6f7aa6", + "hashed_secret": "fc4673eca8fbc033ac714fbf5fdae4293c7b6309", "is_verified": false }, { - "type": "Basic Auth Credentials", + "type": "Base64 High Entropy String", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", + "hashed_secret": "e5575d5cd84e9e2f6620e721e2b71b88cdb47bba", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "a6778f1880744bd1a342a8e3789135412d8f9da2", + "hashed_secret": "41b9210716e431c5418cc3563da73f4dea8c1198", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "b81a4503bd668cde97ef070bfe9cf2baca9872e0", + "hashed_secret": "db58a212ea84148c6ecdc459fb7c1a851dc703d4", "is_verified": false }, { - "type": "Private Key", + "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", + "hashed_secret": "c303df00cd0a72b21c62900b758b06fc541664ce", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "c303df00cd0a72b21c62900b758b06fc541664ce", + "hashed_secret": "28d87938dd56fa9f81fdd109aa97f14b98bb3fd0", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "db58a212ea84148c6ecdc459fb7c1a851dc703d4", + "hashed_secret": "a6778f1880744bd1a342a8e3789135412d8f9da2", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "e3eba309413812b94096a6477501e13853a616b4", + "hashed_secret": "788b6b2bfd50bb3353254fb8a62d7388cf6f7aa6", "is_verified": false }, { - "type": "Base64 High Entropy String", + "type": "Private Key", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "e5575d5cd84e9e2f6620e721e2b71b88cdb47bba", + "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "f60d623e416a938ffa3a98bba1d5cdcd38eba18a", + "hashed_secret": "319f2473faba769797a9662abb2ac61d360a0742", "is_verified": false }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", - "hashed_secret": "fc4673eca8fbc033ac714fbf5fdae4293c7b6309", + "hashed_secret": "69bc2d1e992c7e4d52e438d3ed2c589cd2a4b1bb", "is_verified": false } ] - }, - "generated_at": "2026-07-20T16:17:14Z" + } } diff --git a/charts/auth-server/templates/deployment.yaml b/charts/auth-server/templates/deployment.yaml index a92c594ec..ce1cf7ac5 100644 --- a/charts/auth-server/templates/deployment.yaml +++ b/charts/auth-server/templates/deployment.yaml @@ -248,7 +248,7 @@ spec: timeoutSeconds: 3 failureThreshold: 3 - {{- if or .Values.fastPath.enabled (dig "fastPath" "enabled" false .Values.global) }} + {{- if or (.Values.fastPath).enabled (dig "fastPath" "enabled" false .Values.global) }} # Go /validate fast-path sidecar (issue #1652). Shares the pod network with # auth-server, so AUTH_FALLBACK_URL is localhost:8888. Reuses the same # secrets (SECRET_KEY, DOCUMENTDB_*, AUTH_SERVER_NGINX_MARKER_SECRET). diff --git a/charts/auth-server/templates/service.yaml b/charts/auth-server/templates/service.yaml index 7e75cab5e..3a37b936f 100644 --- a/charts/auth-server/templates/service.yaml +++ b/charts/auth-server/templates/service.yaml @@ -14,7 +14,7 @@ spec: targetPort: http protocol: TCP name: http - {{- if or .Values.fastPath.enabled (dig "fastPath" "enabled" false .Values.global) }} + {{- if or (.Values.fastPath).enabled (dig "fastPath" "enabled" false .Values.global) }} - port: 8899 targetPort: govalidate protocol: TCP diff --git a/charts/mcp-gateway-registry-stack/values.yaml b/charts/mcp-gateway-registry-stack/values.yaml index e84f71860..8611fdc00 100644 --- a/charts/mcp-gateway-registry-stack/values.yaml +++ b/charts/mcp-gateway-registry-stack/values.yaml @@ -559,8 +559,10 @@ auth-server: # the sidecar AND routes /validate). It engages automatically once enabled - # issuers/audiences/JWKS auto-derive from Keycloak. Override only to narrow the # derived sets (comma/space-separated LISTs); never set audience to "account". - fastPath: - # audience: "mcp-gateway mcp-gateway-m2m" # optional override; empty auto-derives + # NOTE: keep this an empty map ({}), NOT a comment-only block (which is YAML null + # and would nil out the subchart's fastPath defaults -> nil-pointer at render). + # To override: fastPath: { audience: "mcp-gateway mcp-gateway-m2m" } + fastPath: {} app: replicas: 2 # set to > 1 replica for high availability # Session cookie Domain attribute. See registry.app.sessionCookieDomain From 1632fdd5b3304d49323977a66082547e6b0e6094 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Fri, 21 Aug 2026 11:10:51 +0000 Subject: [PATCH 25/26] docs(fast-path): independent testing guide (e2e + stress + pingmcp) A self-contained guide a tester can follow to confirm the /validate fast path works end to end, independently: - confirm the sidecar is healthy (mode=fast-path, jwks_healthy, /metrics) - run the pre-release e2e suite (non-breaking check) - register the bundled pingmcp fast upstream, incl. adding pingmcp-server to SSRF_ALLOWED_HOSTS (required or registration/health-check is blocked) - mint a REAL IdP RS256 token (Keycloak/Cognito) and prove fastpath_ok increments (the .token HS256 falls back by design) - load-test RPS against /pingmcp/ and read fastpath_ok vs fallback - notes on the direct-/validate vs through-gateway measurement difference Refs #1652 --- go-validate/TESTING.md | 244 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 go-validate/TESTING.md diff --git a/go-validate/TESTING.md b/go-validate/TESTING.md new file mode 100644 index 000000000..07d9d691c --- /dev/null +++ b/go-validate/TESTING.md @@ -0,0 +1,244 @@ +# Independent Testing Guide — Go `/validate` Fast-Path Sidecar + +This guide lets an independent tester confirm the go-validate fast path (issue #1652, PR #1653) works end to end: functional correctness (e2e suite), that a real IdP token is actually accelerated (not just proxied), and throughput under load (stress test) using the bundled `pingmcp` fast upstream. + +It is written for the **docker-compose** deployment (Keycloak or Entra/Cognito). The same checks apply on ECS/EKS; only how you reach `/metrics` differs (noted where relevant). + +--- + +## 0. What you are validating + +1. **Non-breaking:** with the sidecar deployed, the gateway behaves exactly as before (e2e suite passes). +2. **Fast path engages:** a real IdP **RS256 access token** is verified and served by the Go sidecar itself (not reverse-proxied to Python). +3. **Fail-safe:** anything the fast path can't verify (cookies, self-signed HS256 tokens, other IdPs) transparently falls back to Python — correct results, no errors. +4. **Throughput:** end-to-end RPS/latency through the gateway against a fast upstream (`pingmcp`), so the measurement reflects the auth check, not a slow backend. + +> **Key fact before you start:** the fast path only accelerates **RS256 access tokens** from the configured IdP (Keycloak / Cognito / Entra / Okta). A repo `.token` file is usually a **self-signed HS256** registry token — it will **fall back to Python by design** and will NOT move the `fastpath_ok` counter. To exercise the fast path you must mint a real IdP token (Part D). + +--- + +## 1. Prerequisites + +- Docker + Docker Compose v2.24+ (`docker compose version`) +- `uv` (for the Python e2e script), `go` 1.24+ (only if you build the load tool), `curl`, `jq` +- The repo checked out on the PR branch, with a working `.env` (copy from `.env.example` and fill IdP config) +- The stack running: `./build_and_run.sh` (compose builds the `go-validate` sidecar automatically) + +--- + +## 2. Confirm the sidecar is deployed and healthy + +The compose `registry` routes nginx `/validate` to `go-validate:8899` by default (`VALIDATE_UPSTREAM_URL`). Confirm the sidecar came up in fast-path mode: + +```bash +# Container is running +docker compose ps go-validate + +# Startup log: mode + provider + the accepted issuers/audiences it derived +docker compose logs go-validate | grep -E "listening on|accepted (issuers|client_ids)" +# Expect e.g.: +# go-validate listening on :8899 | mode=fast-path | provider=keycloak | fallback=http://auth-server:8888 +# accepted issuers=[...] | accepted audiences=[...] + +# Health + metrics (reached inside the compose network) +docker compose exec go-validate /go-validate -healthcheck && echo "health OK" +docker compose exec registry curl -s http://go-validate:8899/metrics +``` + +In `/metrics`, before any traffic you should see: +``` +govalidate_fastpath_ready 1 # configured to accelerate +govalidate_jwks_healthy 1 # IdP JWKS loaded +govalidate_jwks_refresh_failures_total 0 +govalidate_fastpath_ok 0 +govalidate_fallback 0 +``` + +- **`fastpath_ready 0`** ⇒ the sidecar is proxying only (missing config). The startup log prints a loud `WARN ... FALLBACK-ONLY mode: missing ...` naming exactly what to set. +- **`ready 1` + `jwks_healthy 0`** ⇒ degraded (IdP JWKS unreachable); it still falls back safely. + +> On ECS/EKS the sidecar has no exposed port; read `/metrics` via `aws ecs execute-command ... --container auth-server --command "curl -s http://localhost:8899/metrics"` (or `kubectl exec`). + +--- + +## 3. Part A — End-to-end functional suite (the pre-release gate) + +This is the same suite run before every release. It exercises registry CRUD, search, security scan, and an external MCP server through the gateway — all of whose auth checks pass through the fast-path sidecar (falling back for the self-signed `.token`). + +```bash +uv run python tests/e2e_release_test.py \ + --registry-url http://localhost \ + --token-file .token +``` + +**Expected:** `*** ALL TESTS PASSED ***` (8/8) on a clean compose stack. +- If you test against a shared/managed deployment where the token lacks the `publish_skill` permission, test 5 (Skill CRUD) may return `403`, and a strict-MCP upstream may return `405` on test 8 — those are deployment/permission specifics, not fast-path regressions. On a local compose stack it should be a clean 8/8. + +This proves **non-breaking**: the sidecar in front of `/validate` did not change any observable behavior. + +--- + +## 4. Part B — Register the `pingmcp` fast upstream (+ SSRF allowlist) + +`pingmcp` is a minimal, dependency-free Go streamable-http MCP server with a single `echo` tool. It exists so the load test is bounded by the **auth check**, not a slow upstream. Source: `servers/pingmcp/` (canonical: https://github.com/aarora79/pingmcp). + +### 4a. Start pingmcp (opt-in `benchmark` profile) + +```bash +docker compose --profile benchmark up -d --build pingmcp-server +# It listens on pingmcp-server:8100 in-cluster (also published to 127.0.0.1:8100). +``` + +### 4b. Allow it through the SSRF guard (REQUIRED) + +The registry runs every registered `proxy_pass_url` through an SSRF guard that blocks private/loopback hosts. `pingmcp-server` is an internal host, so you must allowlist it or **registration/health-check will be blocked**. In your `.env`: + +```bash +# comma-separated exact hostnames/IPs (least privilege) +SSRF_ALLOWED_HOSTS=pingmcp-server +``` + +Then restart the registry so it picks up the new allowlist: +```bash +docker compose up -d registry +``` + +### 4c. Register it in the gateway + +A ready-made registration payload ships at `cli/examples/pingmcp.json` (path `/pingmcp/`, `proxy_pass_url http://pingmcp-server:8100/`, `auth_scheme: none`). + +```bash +uv run python api/registry_management.py \ + --registry-url http://localhost \ + --token-file .token \ + register --config cli/examples/pingmcp.json +``` + +> Note: the global flags (`--registry-url`, `--token-file`) come **before** the `register` subcommand. + +Confirm it's reachable through the gateway (any authenticated request works; `405`/`403` still means the request traversed `/validate`): +```bash +curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost/pingmcp/mcp \ + -H "Authorization: Bearer $(cat .token)" \ + -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1.0"}}}' +``` + +--- + +## 5. Part C — Mint a REAL IdP token (this is what exercises the fast path) + +The `.token` file is self-signed HS256 → it always falls back. To trigger the fast path you need an RS256 access token from the configured IdP. Pick the one matching your `AUTH_PROVIDER`. + +### Keycloak (default compose) +```bash +CID=$(grep -E '^KEYCLOAK_M2M_CLIENT_ID=' .env | cut -d= -f2-) +CSEC=$(grep -E '^KEYCLOAK_M2M_CLIENT_SECRET=' .env | cut -d= -f2-) +REALM=$(grep -E '^KEYCLOAK_REALM=' .env | cut -d= -f2-) +TOK=$(curl -s -X POST "http://localhost:8080/realms/$REALM/protocol/openid-connect/token" \ + -d grant_type=client_credentials -d "client_id=$CID" --data-urlencode "client_secret=$CSEC" \ + | jq -r .access_token) +echo "$TOK" > /tmp/idp.token +``` + +### Cognito (M2M) +```bash +# Needs the M2M app-client id + secret and the Cognito domain; scope = a resource-server scope. +TOK=$(curl -s -X POST "https://.auth..amazoncognito.com/oauth2/token" \ + -d grant_type=client_credentials -d "client_id=" \ + --data-urlencode "client_secret=" \ + --data-urlencode "scope=mcp-servers-unrestricted/read mcp-servers-unrestricted/execute" \ + | jq -r .access_token) +echo "$TOK" > /tmp/idp.token +``` + +Sanity-check it is RS256 and from the expected issuer: +```bash +python3 -c "import sys,json,base64; h,p,_=open('/tmp/idp.token').read().strip().split('.'); \ +d=lambda s: json.loads(base64.urlsafe_b64decode(s+'='*(-len(s)%4))); \ +print('alg=',d(h)['alg'],'iss=',d(p).get('iss'),'aud=',d(p).get('aud'))" +# alg should be RS256; iss/aud must match the sidecar's 'accepted issuers/audiences' from step 2. +``` + +--- + +## 6. Part D — Prove the fast path serves a real token, then measure RPS + +### 6a. One request → confirm `fastpath_ok` increments + +```bash +# capture the counter before +docker compose exec registry curl -s http://go-validate:8899/metrics | grep -E "fastpath_ok|fallback" + +# one authenticated request through the gateway with the REAL IdP token +curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost/pingmcp/mcp \ + -H "Authorization: Bearer $(cat /tmp/idp.token)" \ + -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1.0"}}}' + +# capture again — fastpath_ok should have gone UP +docker compose exec registry curl -s http://go-validate:8899/metrics | grep -E "fastpath_ok|fallback" +``` + +**Pass criteria:** `govalidate_fastpath_ok` increases. That means the Go sidecar verified the token, resolved scopes, minted the internal token, and answered `/validate` itself — no fallback to Python. + +> Cross-check the negative: repeat with `--token-file .token` (HS256) and confirm `govalidate_fallback` increments instead — proving the fail-safe path. + +### 6b. Load test (RPS) + +Any concurrent HTTP driver works. Example with [`hey`](https://github.com/rakyll/hey) (`go install github.com/rakyll/hey@latest`): + +```bash +hey -n 2000 -c 50 -m POST \ + -H "Authorization: Bearer $(cat /tmp/idp.token)" \ + -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"lt","version":"1.0"}}}' \ + http://localhost/pingmcp/mcp +``` + +No-tools fallback (pure bash, lower throughput but zero deps): +```bash +TOK=$(cat /tmp/idp.token) +time (for i in $(seq 1 500); do + curl -s -o /dev/null -X POST http://localhost/pingmcp/mcp \ + -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' & + [ $((i % 25)) -eq 0 ] && wait +done; wait) +``` + +After the run, confirm the load went through the fast path (not fallback): +```bash +docker compose exec registry curl -s http://go-validate:8899/metrics | grep -E "fastpath_ok|fallback|unauthorized" +``` +`fastpath_ok` should account for the bulk of the requests. + +### 6c. Compare against Python (optional, the deck's apples-to-apples) + +The headline numbers (Python 65–551 rps → Go 3k–18k rps, p99 ~2s → ~130ms) were measured by hitting **`/validate` directly on the host** (isolating the auth-check CPU cost), not through nginx/CloudFront. To reproduce that comparison: +- Point the driver at `http://auth-server:8888/validate` (Python) vs `http://go-validate:8899/validate` (Go) from inside the compose network, sending the IdP token in the `X-Authorization` header plus `X-Original-URL: http://localhost/pingmcp/mcp`. +- A run through the public gateway URL instead measures network/RTT, so its absolute RPS will be far lower and is **not** comparable to those figures. + +--- + +## 7. Expected results summary + +| Check | Pass criteria | +|---|---| +| Sidecar health (step 2) | `fastpath_ready 1`, `jwks_healthy 1`, `jwks_refresh_failures 0` | +| E2E suite (Part A) | 8/8 on a clean compose stack (non-breaking) | +| pingmcp reachable (Part B) | request traverses `/validate` (200/403/405 all count) | +| Real token fast-pathed (Part D 6a) | `govalidate_fastpath_ok` increments | +| Fail-safe (Part D 6a negative) | HS256 `.token` increments `govalidate_fallback`, request still succeeds | +| Load test (Part D 6b) | `fastpath_ok` accounts for the load; no `unauthorized`/errors; latency stable | + +--- + +## 8. Gotchas + +- **`.token` is HS256 → always falls back.** Use a real IdP token (Part C) to see `fastpath_ok` move. This is the single most common source of "it's not accelerating" confusion. +- **Register a server before load-testing its path**, and **allowlist its host** in `SSRF_ALLOWED_HOSTS` (Part B) or registration/health-check is blocked. +- **`iss`/`aud` must match** the sidecar's `accepted issuers/audiences` (step 2 startup log). A mismatch fails safe (fallback) with no error — check the log, not just the HTTP status. +- If you **recreate the `go-validate` container**, its IP changes and nginx may cache the old one — `docker compose exec registry nginx -s reload` (or restart registry) after recreating the sidecar. +- **Turn it off** to A/B: set `VALIDATE_UPSTREAM_URL=http://auth-server:8888` in `.env` and restart registry — nginx then routes `/validate` straight to Python (no sidecar), useful for a before/after comparison. From b6dcfaf849dae37698fea77c26081fa7fa9bd7d7 Mon Sep 17 00:00:00 2001 From: Amit Arora Date: Fri, 21 Aug 2026 11:13:04 +0000 Subject: [PATCH 26/26] docs(fast-path): drop 'independent tester' phrasing from TESTING.md Reword the title and intro so the guide reads as general ('anyone') rather than scoped to an independent tester. --- go-validate/TESTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go-validate/TESTING.md b/go-validate/TESTING.md index 07d9d691c..442a660f5 100644 --- a/go-validate/TESTING.md +++ b/go-validate/TESTING.md @@ -1,6 +1,6 @@ -# Independent Testing Guide — Go `/validate` Fast-Path Sidecar +# Testing Guide — Go `/validate` Fast-Path Sidecar -This guide lets an independent tester confirm the go-validate fast path (issue #1652, PR #1653) works end to end: functional correctness (e2e suite), that a real IdP token is actually accelerated (not just proxied), and throughput under load (stress test) using the bundled `pingmcp` fast upstream. +This guide lets anyone confirm the go-validate fast path (issue #1652, PR #1653) works end to end: functional correctness (e2e suite), that a real IdP token is actually accelerated (not just proxied), and throughput under load (stress test) using the bundled `pingmcp` fast upstream. It is written for the **docker-compose** deployment (Keycloak or Entra/Cognito). The same checks apply on ECS/EKS; only how you reach `/metrics` differs (noted where relevant).