From 668e8fa28da50b2a1467cc212282065759dc87ab Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 28 May 2026 21:55:09 +0200 Subject: [PATCH] feat: /healthz reports JWT validator status (refs #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /healthz now returns JSON with three-state JWT status: disabled (DEX_ISSUER_URL unset), enabled (validator initialized), or degraded (configured but init failed — only static-token auth currently accepted). last_error surfaces the init failure so ops can correlate with Dex outage windows. Partial fix for #6. The cited internal/auth/jwt.go moved out of this repo in 658f4ba (mcp-chassis migration); per-attempt logging and 503 + WWW-Authenticate temporarily_unavailable require chassis-side changes and a coordinated v0.1.1 bump across all MCP consumers — tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/gitea-mcp/healthz.go | 46 +++++++++++++++++++++++++++ cmd/gitea-mcp/healthz_test.go | 60 +++++++++++++++++++++++++++++++++++ cmd/gitea-mcp/main.go | 11 +++---- 3 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 cmd/gitea-mcp/healthz.go create mode 100644 cmd/gitea-mcp/healthz_test.go diff --git a/cmd/gitea-mcp/healthz.go b/cmd/gitea-mcp/healthz.go new file mode 100644 index 0000000..f504aee --- /dev/null +++ b/cmd/gitea-mcp/healthz.go @@ -0,0 +1,46 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +type healthStatus struct { + OK bool `json:"ok"` + JWT jwtStatus `json:"jwt"` +} + +// jwtStatus surfaces the runtime state of the Dex JWT validator so ops +// can distinguish "Dex unreachable at startup" from "JWT auth not +// configured" — both previously degraded silently to static-token-only +// (refs hyperguild/gitea-mcp#6). +type jwtStatus struct { + // Status is one of: "disabled" (DEX_ISSUER_URL not set), + // "enabled" (validator initialized), "degraded" (configured but + // init failed; only static-token auth currently accepted). + Status string `json:"status"` + LastError string `json:"last_error,omitempty"` +} + +func newHealthzHandler(dexConfigured, validatorReady bool, initErr error) http.HandlerFunc { + status := healthStatus{OK: true, JWT: jwtStatus{Status: jwtStatusFor(dexConfigured, validatorReady)}} + if initErr != nil { + status.JWT.LastError = initErr.Error() + } + body, _ := json.Marshal(status) + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } +} + +func jwtStatusFor(dexConfigured, validatorReady bool) string { + switch { + case !dexConfigured: + return "disabled" + case validatorReady: + return "enabled" + default: + return "degraded" + } +} diff --git a/cmd/gitea-mcp/healthz_test.go b/cmd/gitea-mcp/healthz_test.go new file mode 100644 index 0000000..4c13d4e --- /dev/null +++ b/cmd/gitea-mcp/healthz_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHealthzHandler(t *testing.T) { + tests := []struct { + name string + dexConfigured bool + validatorReady bool + initErr error + wantStatus string + wantLastError string + }{ + { + name: "disabled when DEX_ISSUER_URL unset", + wantStatus: "disabled", + wantLastError: "", + }, + { + name: "enabled when validator initialized", + dexConfigured: true, + validatorReady: true, + wantStatus: "enabled", + wantLastError: "", + }, + { + name: "degraded when Dex configured but init failed", + dexConfigured: true, + initErr: errors.New("fetch oidc discovery: dial tcp: connection refused"), + wantStatus: "degraded", + wantLastError: "fetch oidc discovery: dial tcp: connection refused", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var got healthStatus + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.True(t, got.OK) + assert.Equal(t, tc.wantStatus, got.JWT.Status) + assert.Equal(t, tc.wantLastError, got.JWT.LastError) + }) + } +} diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index cdaa926..ccaa3ef 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -29,9 +29,9 @@ func main() { ctx := context.Background() - jwtValidator, err := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience) - if err != nil { - logger.Warn("jwt validator init failed; JWT auth disabled", "err", err) + jwtValidator, jwtInitErr := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience) + if jwtInitErr != nil { + logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr) } giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken) @@ -95,10 +95,7 @@ func main() { auth.CallerMiddleware(mcpSrv), ), )) - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - }) + mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr)) if cfg.DexIssuerURL != "" { mux.HandleFunc("GET /.well-known/oauth-protected-resource", chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))