feat: /healthz reports JWT validator status (refs #6)
/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) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,9 +29,9 @@ func main() {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
jwtValidator, jwtInitErr := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||||
if err != nil {
|
if jwtInitErr != nil {
|
||||||
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
|
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||||
@@ -95,10 +95,7 @@ func main() {
|
|||||||
auth.CallerMiddleware(mcpSrv),
|
auth.CallerMiddleware(mcpSrv),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
if cfg.DexIssuerURL != "" {
|
if cfg.DexIssuerURL != "" {
|
||||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
||||||
|
|||||||
Reference in New Issue
Block a user