auth: silent JWT degradation when Dex is unreachable #6

Closed
opened 2026-05-14 08:44:09 +00:00 by mathias · 2 comments
Owner

When Dex is unreachable at startup, NewJWTValidator returns nil, nil and emits a warning. All subsequent JWT validation calls silently return false, falling through to the static token check. The resulting 401 gives the caller no indication of whether their JWT was invalid or Dex was unavailable.

Files:

  • cmd/gitea-mcp/main.go:30-33 — silent nil return on Dex failure
  • internal/auth/jwt.go:61-63 — Validate returns false when validator is nil

Suggestions:

  • Expose a GET /healthz that reports JWT validator status (enabled/disabled/last-error)
  • Log Dex availability state on each auth attempt when the validator is nil so ops can correlate with Dex outage windows
  • Consider returning a 503 with WWW-Authenticate: Bearer error="temporarily_unavailable" when Dex is known-down vs a generic 401
When Dex is unreachable at startup, `NewJWTValidator` returns `nil, nil` and emits a warning. All subsequent JWT validation calls silently return `false`, falling through to the static token check. The resulting 401 gives the caller no indication of whether their JWT was invalid or Dex was unavailable. **Files:** - `cmd/gitea-mcp/main.go:30-33` — silent nil return on Dex failure - `internal/auth/jwt.go:61-63` — Validate returns false when validator is nil **Suggestions:** - Expose a `GET /healthz` that reports JWT validator status (enabled/disabled/last-error) - Log Dex availability state on each auth attempt when the validator is nil so ops can correlate with Dex outage windows - Consider returning a `503` with `WWW-Authenticate: Bearer error="temporarily_unavailable"` when Dex is known-down vs a generic 401
Author
Owner

Partial fix landed in 668e8fa.

Finding during scope eval: cited file internal/auth/jwt.go:61-63 is gone — commit 658f4ba migrated the JWT validator and Bearer middleware to the external mcp-chassis v0.1.0 module (gitea.d-ma.be/mathias/mcp-chassis/auth). The silent-degradation behaviour persists post-migration but the code lives in two repos now.

What this commit covers (gitea-mcp scope):

  • /healthz now emits JSON with three-state JWT status: disabled (DEX_ISSUER_URL unset), enabled (validator initialized), degraded (configured but init failed — only static-token auth currently accepted).
  • last_error surfaces the NewJWTValidator error string so ops can correlate degraded with Dex outage windows without tailing pod logs.
  • Tests cover all three states (cmd/gitea-mcp/healthz_test.go).

Deferred to follow-up (mcp-chassis scope):

  1. Per-attempt log of Dex validator state when validator == nil — lives in BearerMiddleware, would log on every request that hits the chassis (mcp-chassis/auth/bearer.go:59-64).
  2. 503 with WWW-Authenticate: Bearer error="temporarily_unavailable" when Dex is known-down — Validate would need to distinguish "validator not configured" from "validator unhealthy"; touches the auth contract for every MCP consumer (gitea-mcp, brain-mcp, future template-go-agent spawn).

Both items require coordinated changes in chassis + a v0.1.1 tag + go.mod bumps in every consumer. Worth opening a chassis-side issue. Leaving this one open until that's done; close when chassis ships.

Partial fix landed in 668e8fa. **Finding during scope eval:** cited file `internal/auth/jwt.go:61-63` is gone — commit 658f4ba migrated the JWT validator and Bearer middleware to the external `mcp-chassis` v0.1.0 module (`gitea.d-ma.be/mathias/mcp-chassis/auth`). The silent-degradation behaviour persists post-migration but the code lives in two repos now. **What this commit covers (gitea-mcp scope):** - `/healthz` now emits JSON with three-state JWT status: `disabled` (DEX_ISSUER_URL unset), `enabled` (validator initialized), `degraded` (configured but init failed — only static-token auth currently accepted). - `last_error` surfaces the `NewJWTValidator` error string so ops can correlate `degraded` with Dex outage windows without tailing pod logs. - Tests cover all three states (`cmd/gitea-mcp/healthz_test.go`). **Deferred to follow-up (mcp-chassis scope):** 1. Per-attempt log of Dex validator state when `validator == nil` — lives in `BearerMiddleware`, would log on every request that hits the chassis (`mcp-chassis/auth/bearer.go:59-64`). 2. `503` with `WWW-Authenticate: Bearer error="temporarily_unavailable"` when Dex is known-down — `Validate` would need to distinguish "validator not configured" from "validator unhealthy"; touches the auth contract for every MCP consumer (gitea-mcp, brain-mcp, future template-go-agent spawn). Both items require coordinated changes in chassis + a v0.1.1 tag + `go.mod` bumps in every consumer. Worth opening a chassis-side issue. Leaving this one open until that's done; close when chassis ships.
Author
Owner

Closed across two layers.

Already shipped in gitea-mcp (present before this pass): /healthz reports the JWT validator state — disabled (DEX_ISSUER_URL unset) / enabled / degraded (configured but init failed) with last_error — and startup logs a warning when the validator fails to initialize. That covers the "silent degradation is now observable" asks (newHealthzHandler, TestHealthzHandler).

New in mcp-chassis v0.3.0 (adopted by gitea-mcp v0.5.0): the runtime Dex-outage case is now distinct from a bad token. JWTValidator.Validate tags a JWKS/Dex fetch failure with the exported ErrUnavailable sentinel, and BearerMiddleware maps it to HTTP 503 with WWW-Authenticate: Bearer error="temporarily_unavailable" (RFC 6750 §3.1) instead of a generic 401 — so a caller can tell "Dex is down, retry" from "your token is invalid". Covered by TestValidate_JWKSUnreachable_IsUnavailable + TestBearerMiddleware_DexDown_503.

Scope note: the 503 fires for a runtime JWKS-fetch failure. The startup-degraded case (validator nil because Dex was down at boot) still returns 401 at the middleware, because BearerMiddleware receives a nil validator and can't distinguish "JWT disabled" from "degraded" without a signature change across all chassis consumers — /healthz surfaces that state instead. If you want startup-degraded to also emit 503, that's a follow-up chassis signature change; noting rather than silently doing it.

Closed across two layers. **Already shipped in gitea-mcp** (present before this pass): `/healthz` reports the JWT validator state — `disabled` (DEX_ISSUER_URL unset) / `enabled` / `degraded` (configured but init failed) with `last_error` — and startup logs a warning when the validator fails to initialize. That covers the "silent degradation is now observable" asks (`newHealthzHandler`, `TestHealthzHandler`). **New in mcp-chassis v0.3.0** (adopted by gitea-mcp v0.5.0): the runtime Dex-outage case is now distinct from a bad token. `JWTValidator.Validate` tags a JWKS/Dex fetch failure with the exported `ErrUnavailable` sentinel, and `BearerMiddleware` maps it to **HTTP 503 with `WWW-Authenticate: Bearer error="temporarily_unavailable"`** (RFC 6750 §3.1) instead of a generic 401 — so a caller can tell "Dex is down, retry" from "your token is invalid". Covered by `TestValidate_JWKSUnreachable_IsUnavailable` + `TestBearerMiddleware_DexDown_503`. **Scope note:** the 503 fires for a *runtime* JWKS-fetch failure. The *startup-degraded* case (validator nil because Dex was down at boot) still returns 401 at the middleware, because `BearerMiddleware` receives a nil validator and can't distinguish "JWT disabled" from "degraded" without a signature change across all chassis consumers — `/healthz` surfaces that state instead. If you want startup-degraded to also emit 503, that's a follow-up chassis signature change; noting rather than silently doing it.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/gitea-mcp#6