Return 401+WWW-Authenticate when vMCP upstream token is unrefreshable - #5651
Conversation
When a backend's outgoing auth strategy (upstream_inject, token_exchange, aws_sts, obo) required an upstream IDP token that could not be refreshed, the failure reached the MCP client as a generic backend error rather than an HTTP 401 with a WWW-Authenticate re-auth challenge (issue #5507). Two-part fix: 1. Add ErrUpstreamTokenNotFound to wrapBackendError: an explicit errors.Is branch now maps the sentinel to vmcp.ErrAuthenticationFailed, replacing the fragile "authentication failed" substring match that only worked incidentally because the authRoundTripper included that phrase in its error message. A small isAuthorizationRequired helper was extracted at the same time to keep wrapBackendError within the cyclomatic complexity limit. 2. Add upstreamTokenCheckMiddleware to the vMCP server: this middleware runs immediately after AuthMiddleware (once the identity and its UpstreamTokens map are populated) and before the mcp-go SDK handler (while HTTP 401 can still be written). It scans the backend registry for all configured outgoing-auth strategies that depend on an upstream provider token and, if any provider's token is absent from the identity, returns HTTP 401 + WWW-Authenticate Bearer challenge identical to the single-server upstreamswap middleware. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewrite the comment in execution-order terms to avoid confusion between wrapping order and execution order (#5507 review feedback). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5651 +/- ##
=======================================
Coverage 70.34% 70.35%
=======================================
Files 649 649
Lines 66101 66185 +84
=======================================
+ Hits 46500 46562 +62
- Misses 16253 16269 +16
- Partials 3348 3354 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: Security, Error Handling, Test Coverage, Architecture, General Quality (Codex: skipped — CLI not installed)
Consensus Summary
| # | Finding | Score | Severity | Action |
|---|---|---|---|---|
| F1 | firstMissingProvider return value unused — name misleads |
8/10 | LOW | Fix |
| F2 | No log line when 401 short-circuit fires | 9/10 | LOW | Fix |
| F3 | Empty SubjectProviderName creates undocumented silent pass-through assumption |
8/10 | MEDIUM | Fix |
| F4 | Nil-registry guard skips middleware silently — no doc or log | 7/10 | LOW | Fix |
Overall
This PR correctly addresses the stated issue: a missing upstream provider token no longer silently degrades to a JSON-RPC error — it now returns HTTP 401 with a proper RFC 6750 WWW-Authenticate challenge at the HTTP boundary, consistent with the single-server upstreamswap middleware. The pre-check approach (verify all backends' required tokens before dispatch) is the right call for vMCP's fan-out model; the alternative of aggregating per-backend 401s after mcp-go commits to HTTP 200 would require significantly more complexity.
The four consensus findings are polish items, not correctness issues. The most important is F3 (MEDIUM): upstreamProviderName returns "" for token_exchange and aws_sts backends when SubjectProviderName is empty, causing the middleware to silently skip those backends. If SubjectProviderName can be empty at request time (the field is described as optionally auto-populated), those backends would still produce a JSON-RPC error on missing identity.Token — the original problem. The middleware doc comment should document this assumption. F1 and F2 are minor: firstMissingProvider should either use its return value (in a log line or the WWW-Authenticate description) or be renamed to reflect that only the boolean result matters; and a slog.Debug call when the 401 fires would make incident diagnosis faster in vMCP's multi-backend environment. F4 is a one-liner comment on the guard condition.
The wrapBackendError change (explicit errors.Is for ErrUpstreamTokenNotFound) is a clean, well-motivated hardening of the existing fragile substring match. The test suite is thorough: it covers all four strategy types, nil configs, the fan-out partial-failure case, and errors.Is chain traversal through wrapped errors.
Generated with Claude Code
Addresses #5651 review comments: - LOW upstream_token_check.go (3477948741): use missing provider name in slog call - LOW upstream_token_check.go (3477948751): add DebugContext log when 401 fires - MEDIUM upstream_token_check.go (3477948759): document SubjectProviderName empty case in upstreamProviderName - LOW server.go (3477948762): explain both conditions on the nil-registry guard; add debug log
Instead of a separate upstreamTokenCheckMiddleware that cross-referenced the backend registry on every request, surface the 401 directly from TokenValidator.Middleware when GetAllValidTokens reports that a provider's token could not be refreshed. Changes: - TokenReader.GetAllValidTokens: returns a third value ([]string) listing providers whose refresh failed; callers can act on it without consulting the registry - Identity: add FailedUpstreamProviders []string populated during enrichment - TokenValidator.Middleware: when FailedUpstreamProviders is non-empty, write HTTP 401 + WWW-Authenticate and short-circuit before any inner handler - Delete pkg/vmcp/server/upstream_token_check.go (now redundant) - Update mock, all call sites, and tests; add 401-path test in token_test.go
jhrozek
left a comment
There was a problem hiding this comment.
Three related issues in the new upstream-refresh-failure 401 block.
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: security-auth, error-handling, test-coverage, architecture, general-quality
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| F1 | 401 path bypasses buildWWWAuthenticate (no realm/resource_metadata) |
7/10 | MEDIUM | Fix |
| F3 | Generic middleware embeds vMCP-specific fan-out 401 policy | 8/10 | MEDIUM | Document / Discuss |
| F4 | FailedUpstreamProviders set before 401 short-circuit but never reaches context |
8/10 | LOW | Fix |
| F5 | Missing test: multiple providers all failing in one GetAllValidTokens call |
7/10 | MEDIUM | Fix |
| F6 | Missing test: partial failure in Middleware (some providers succeed, one fails) | 8/10 | MEDIUM | Fix |
Overall
This PR correctly addresses the stated bug: when an upstream provider token expires and cannot be refreshed, the failure now surfaces as an HTTP 401 + WWW-Authenticate challenge rather than a generic backend error, giving MCP clients the RFC 6750 signal they need to re-authenticate. The approach — extending GetAllValidTokens to return named failed providers and short-circuiting in TokenValidator.Middleware — cleanly separates infrastructure errors (storage unavailable → non-nil error → 503) from application-level refresh failures (failed []string → 401). Removing upstreamTokenCheckMiddleware in favor of the generic middleware path is a clear simplification, and the explicit ErrUpstreamTokenNotFound sentinel in wrapBackendError closes the fragile string-match fallback.
Two items need attention before merge. F1: the new 401 branch hard-codes a bare Bearer challenge string instead of calling v.buildWWWAuthenticate() — the helper that every other 401 path in this function uses. This omits the realm and resource_metadata fields required by RFC 9728 discovery, which is the mechanism that lets MCP clients find the right auth server for re-authentication. It also uses http.Error (plain text) instead of writeOAuthError (JSON), inconsistent with all other error branches. F3: the "any failed provider = full 401" policy is baked into the generic pkg/auth middleware without acknowledging the fan-out limitation the issue thread called out — a vMCP session with backends needing different providers will reject the whole request even when only one provider fails. The behavior is safe but the trade-off should be documented in a comment.
F5 and F6 are missing test cases that would verify the all-or-nothing rejection policy holds under multi-provider failure and partial-failure scenarios respectively. Both are straightforward to add. F4 is a cleanup: identity.FailedUpstreamProviders is assigned on a transient struct that is discarded when the 401 fires, making the field effectively unreachable from the served context and its doc comment confusing.
Documentation
The TODO(auth) comment removed from pkg/auth/upstreamtoken/types.go tracked a richer per-provider metadata approach (distinguishing "never had a token" from "had a token that expired and couldn't refresh"). This distinction is still unresolved — issue #5507's description explicitly flags it. Consider opening a follow-up issue or restoring an updated TODO before closing #5507.
Generated with Claude Code
Addresses #5651 review comments: - MEDIUM pkg/auth/token.go (3482264308): use buildWWWAuthenticate + writeOAuthError on the refresh-failure 401 path to match all other 401 branches (adds realm, resource_metadata, and JSON body) - MEDIUM pkg/auth/token.go (3482264330): add comment documenting the conservative "any failure = reject" policy and its vMCP fan-out limitation - LOW pkg/auth/identity.go (3482264339): remove FailedUpstreamProviders field — it was assigned before the 401 short-circuit so the identity is always discarded; observability is covered by the slog.WarnContext call
Addresses #5651 review comments: - MEDIUM pkg/auth/upstreamtoken/service_test.go (3482264343): add test for two providers both failing refresh — verifies failed slice contains both names - MEDIUM pkg/auth/token_test.go (3482264350): add test for partial failure in Middleware (one provider succeeds, one fails) — verifies 401 is still returned
Addresses #5651 review comments: - MEDIUM pkg/auth/token.go:1244 (3481032822): remove "subject", identity.Subject from slog.WarnContext — the OIDC sub (often an email) paired with provider names leaks which external services a user accesses; tsid in context is sufficient for incident correlation - MEDIUM pkg/auth/token.go:1247 (3481032822): unify writeOAuthError message to match buildWWWAuthenticate error_description for consistent client-visible text
All call sites pass http.StatusUnauthorized (401); hardcode it in the function and drop the parameter to satisfy the unparam linter.
Summary
When a vMCP backend's upstream provider token expires and cannot be refreshed, the
failure was surfacing as a generic backend error with no HTTP 401 + `WWW-Authenticate`
challenge, leaving MCP clients with no standard signal to re-authenticate. This is
inconsistent with the single-server runner path (`upstreamswap` middleware), which
already returns a proper RFC 6750 challenge in the same situation.
token refresh failed alongside the successful tokens map, and surfaces that signal
in `TokenValidator.Middleware`: when any provider refresh fails the middleware writes
HTTP 401 + `WWW-Authenticate` immediately — before any inner handler runs — so the
MCP client sees a proper re-auth signal instead of a generic JSON-RPC error.
through the enrichment path for observability.
`wrapBackendError` as defense-in-depth, replacing a fragile substring match.
Closes #5507
Type of change
Test plan
Changes
Does this introduce a user-facing change?
Yes. Previously, a tool call made when the user's upstream provider credential had
expired (and was not refreshable) returned a generic JSON-RPC error with no
re-authentication hint. After this change, vMCP returns HTTP 401 with:
```
WWW-Authenticate: Bearer error="invalid_token", error_description="upstream token is no longer valid; re-authentication required"
```
MCP clients that respect RFC 6750 can now detect the condition and prompt the user
to re-authenticate with the upstream provider.
Special notes for reviewers
Where the 401 fires: `TokenValidator.Middleware` in `pkg/auth/token.go` — the
generic OIDC auth middleware, not a vMCP-specific layer. This means any path that uses
`WithUpstreamTokenReader` gets the behaviour for free. The 401 fires before any inner
handler, so the mcp-go SDK never sees the request and cannot commit to HTTP 200.
No registry dependency: The previous approach read the backend registry on every
request to determine which providers were "required". The new approach detects the
failure at the source — inside `GetAllValidTokens` — so no registry lookup is needed
and the signal is exact: 401 fires only when a refresh actually failed, not merely when
a token key happens to be absent.
`ErrUpstreamTokenNotFound` in `wrapBackendError`: Kept as defense-in-depth for
any code path that calls the backend auth strategy directly without going through the
middleware (e.g., background refresh or test harnesses). The explicit sentinel mapping
takes priority over the string-based fallback.
Generated with Claude Code