From 240c3ec0818604918756b223a93681efb5286dcc Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 7 Jul 2026 00:16:39 +0200 Subject: [PATCH] feat(auth): trust the k8s cluster OIDC issuer for ServiceAccount tokens (ADR-0011 D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive: gitea-mcp now validates JWTs from a LIST of issuers — the existing Authentik issuer (DEX_ISSUER_URL) AND, when K8S_ISSUER_URL is set, the in-cluster k8s OIDC issuer for audience-bound ServiceAccount tokens. Lets in-cluster pods authenticate with kubelet-rotated projected SA tokens instead of a static bearer. - config: K8S_ISSUER_URL + K8S_MCP_AUDIENCE. - cmd/gitea-mcp/k8soidc.go: HTTP client that fetches the k8s OIDC discovery/JWKS with the cluster CA + this pod's SA bearer (k3s requires an authed fetch; anonymous is 401). - main.go: build the issuer list; switch NewJWTValidator -> NewMultiJWTValidator. The k8s issuer is best-effort — if its client can't be built (not in a pod) or its discovery is unreachable at startup, it is DROPPED and we fall back so Authentik/static auth is never taken down. Smoke-tested: off-pod it logs the skip and starts static-only; static-bearer /mcp returns 400 (auth passed), not 401. - bump mcp-chassis v0.3.0 -> v0.5.0 (multi-issuer + per-issuer HTTPClient). Refs infra ADR-0011; enables retiring the in-cluster static bearer. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/gitea-mcp/k8soidc.go | 55 +++++++++++++++++++++++++++++++++++++++ cmd/gitea-mcp/main.go | 31 +++++++++++++++++++++- go.mod | 2 +- go.sum | 4 +-- internal/config/config.go | 4 +++ 5 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 cmd/gitea-mcp/k8soidc.go diff --git a/cmd/gitea-mcp/k8soidc.go b/cmd/gitea-mcp/k8soidc.go new file mode 100644 index 0000000..e7383b9 --- /dev/null +++ b/cmd/gitea-mcp/k8soidc.go @@ -0,0 +1,55 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "os" + "strings" + "time" +) + +const ( + saCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + saTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret +) + +// k8sBearerRT adds this pod's ServiceAccount bearer to every request. k3s serves +// its OIDC discovery/JWKS over the cluster CA and requires an AUTHENTICATED +// request (anonymous is 401), so the JWKS fetch must carry a token — ADR-0011. +type k8sBearerRT struct { + token string + base http.RoundTripper +} + +func (b k8sBearerRT) RoundTrip(r *http.Request) (*http.Response, error) { + r.Header.Set("Authorization", "Bearer "+b.token) + return b.base.RoundTrip(r) +} + +// k8sOIDCClient builds an HTTP client that can reach the in-cluster k8s OIDC +// discovery + JWKS: it trusts the cluster CA and carries this pod's SA bearer. +// Returns an error (not running in a pod, files unreadable) so the caller can +// skip the k8s issuer without disabling other auth. +func k8sOIDCClient() (*http.Client, error) { + ca, err := os.ReadFile(saCAFile) + if err != nil { + return nil, fmt.Errorf("read cluster CA: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(ca) { + return nil, fmt.Errorf("parse cluster CA: no certs in %s", saCAFile) + } + tok, err := os.ReadFile(saTokenFile) + if err != nil { + return nil, fmt.Errorf("read SA token: %w", err) + } + return &http.Client{ + Timeout: 15 * time.Second, + Transport: k8sBearerRT{ + token: strings.TrimSpace(string(tok)), + base: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}, + }, + }, nil +} diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index d78ed34..a80bd7a 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -37,7 +37,36 @@ func main() { ctx := context.Background() - jwtValidator, jwtInitErr := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience) + // Build the trusted-issuer list. Authentik (DEX_ISSUER_URL) for interactive/ + // web clients; the in-cluster k8s OIDC issuer for ServiceAccount tokens + // (ADR-0011 D1). The k8s issuer is ADDITIVE and best-effort: if its client + // can't be built (not in a pod) or its discovery is unreachable at startup, + // it is dropped so existing Authentik/static auth is never taken down. + var issuers []chassisauth.IssuerConfig + if cfg.DexIssuerURL != "" { + issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.DexIssuerURL, Audience: cfg.MCPAudience}) + } + if cfg.K8sIssuerURL != "" { + if client, cerr := k8sOIDCClient(); cerr != nil { + logger.Warn("k8s OIDC client init failed; SA-token auth disabled (other auth unaffected)", "err", cerr) + } else { + issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.K8sIssuerURL, Audience: cfg.K8sAudience, HTTPClient: client}) + } + } + + jwtValidator, jwtInitErr := chassisauth.NewMultiJWTValidator(ctx, issuers) + if jwtInitErr != nil && cfg.K8sIssuerURL != "" && len(issuers) > 1 { + // A configured issuer (likely the in-cluster k8s OIDC) was unreachable at + // startup. Don't let it take down Authentik JWT auth — retry without it. + logger.Warn("multi-issuer init failed; retrying without the k8s issuer", "err", jwtInitErr) + pub := make([]chassisauth.IssuerConfig, 0, len(issuers)) + for _, ic := range issuers { + if ic.IssuerURL != cfg.K8sIssuerURL { + pub = append(pub, ic) + } + } + jwtValidator, jwtInitErr = chassisauth.NewMultiJWTValidator(ctx, pub) + } if jwtInitErr != nil { logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr) } diff --git a/go.mod b/go.mod index a10af02..52abc75 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.d-ma.be/mathias/gitea-mcp go 1.26.2 require ( - git.d-ma.be/mathias/mcp-chassis v0.3.0 + git.d-ma.be/mathias/mcp-chassis v0.5.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/stretchr/testify v1.11.1 ) diff --git a/go.sum b/go.sum index 4d0b523..509ccf9 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.d-ma.be/mathias/mcp-chassis v0.3.0 h1:lV/vDsjrDeZojT7lhcwolM1lMZpsnEKEvf4kEHrxIa0= -git.d-ma.be/mathias/mcp-chassis v0.3.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8= +git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8= +git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= diff --git a/internal/config/config.go b/internal/config/config.go index 2a29429..a18d1c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,8 @@ type Config struct { DexIssuerURL string // DEX_ISSUER_URL, e.g. https://auth.d-ma.be; empty disables JWT auth MCPAudience string // MCP_AUDIENCE, JWT audience claim to validate, e.g. claude-ai MCPResourceURL string // MCP_RESOURCE_URL, this server's public URL for /.well-known metadata + K8sIssuerURL string // K8S_ISSUER_URL, in-cluster OIDC issuer for ServiceAccount-token auth (ADR-0011 D1); empty disables + K8sAudience string // K8S_MCP_AUDIENCE, required audience claim for k8s SA tokens } func Load() (Config, error) { @@ -28,6 +30,8 @@ func Load() (Config, error) { DexIssuerURL: os.Getenv("DEX_ISSUER_URL"), MCPAudience: os.Getenv("MCP_AUDIENCE"), MCPResourceURL: os.Getenv("MCP_RESOURCE_URL"), + K8sIssuerURL: os.Getenv("K8S_ISSUER_URL"), + K8sAudience: os.Getenv("K8S_MCP_AUDIENCE"), } return cfg, nil }