fix(health): stop judging header-auth servers by a stale OAuth token record - #1262
Merged
Conversation
…record A server that once logged in with OAuth (autodiscovery, no `oauth` block) and was then switched to a static `Authorization` header keeps its old record in the oauth_tokens bucket: nothing removes it because the config never carried an OAuth block whose removal could have cleared it. The server-list projection built an `oauth` object from that dead record and, once its expiry passed, health reported the connected, tool-serving server as unhealthy / "Token expired" / login forever. After logout the in-memory refresh schedule outlived the token and the same server flipped to "Refresh token expired". - config: add ServerConfig.HasStaticAuthorizationHeader (case-insensitive name, non-blank value). OAuth populates that very header and the headers-auth strategy runs first, so the two are mutually exclusive. - runtime: a server with no `oauth` block and a static Authorization header no longer consults the stored token for its projection. The record is left alone (logout still removes it); it is simply not evidence for such a server. - health: refresh state only applies when OAuthRequired — it is a property of the token being refreshed. - runtime: TriggerOAuthLogout drops the refresh schedule alongside the token, as server removal already did. Closes #1172 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review round 1 (opencode/astra): a rejected static Authorization header can fall through to the OAuth strategy, which then authenticates the connection with the stored token — and the header-based gate would have hidden that token's state. Record the strategy that won each HTTP/SSE connect attempt (core.Client.AuthStrategy, via a shared runAuthStrategies loop replacing the two identical ones), expose it as managed.Client.ConnectedWithOAuth, and let the runtime consult the stored token whenever the live connection was made with OAuth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot the calculator Review round 2 (opencode/astra): guarding the calculator's refresh-state branch on OAuthRequired hid genuine refresh failures on the two surfaces (Server.GetAllServers for the tray, the MCP upstream_servers list) that only learn OAuthRequired from an explicit `oauth` block — an autodiscovery OAuth server with a failed refresh would have read healthy there. Revert the calculator change and move the decision to where the evidence is: Runtime.StoredOAuthTokenInPlay(name, cfg) answers "is the oauth_tokens record (and the RefreshManager schedule built from it) evidence about this server", and Runtime.HealthRefreshState wraps GetRefreshState with it. All three CalculateHealth call sites now read refresh state through that seam, so a header-authenticated server's stale schedule is withheld everywhere and an autodiscovery server's genuine failure is kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying mcpproxy-docs with
|
| Latest commit: |
18998ac
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://76cdb5e5.mcpproxy-docs.pages.dev |
| Branch Preview URL: | https://fix-1172-stale-oauth-token-h.mcpproxy-docs.pages.dev |
Contributor
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 34675616136 --repo smart-mcp-proxy/mcpproxy-go
|
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
After switching an upstream from OAuth to a static
Authorizationheader ("oauth": nullin the config), the server keeps working — connected, tools served — but/api/v1/serversreports it asunhealthy/"Token expired"/action: loginindefinitely. AfterPOST /api/v1/servers/<name>/logout+ restart it flipped to"Refresh token expired"instead. Reporter confirmed on v0.63.0 and v0.64.0 with a natural A/B across ten upstreams of the same provider: the eight with a leftoveroauth_tokensrecord were red, the two without were healthy.Root cause
Two records outlive the config change and both were treated as evidence:
Runtime.GetAllServerslooks up theoauth_tokensbucket for every URL-addressed server (so that autodiscovery OAuth servers, which have nooauthblock, still show their token state). Nothing ever removed the reporter's records: the config-load path only clears OAuth state when an explicitoauthblock changes (config.OAuthConfigChanged), and an autodiscovery server goesnil → nil. The dead record synthesised anoauthobject withtoken_expires_atin the past, which setoauth_status: "expired", and the health calculator's OAuth branch reported "Token expired".RefreshManager.Startbuilds an in-memory schedule from every stored token; a fully-expired one is parked inRefreshStateFailed. Once the token stopped driving the OAuth branch (or after logout removed it), that schedule surfaced as "Refresh token expired".TriggerOAuthLogoutcleared the token but never dropped the schedule (server removal already did).Fix
The principle: a token record is only evidence about a server that can actually be using it. OAuth and a static
Authorizationheader are mutually exclusive on the wire — the OAuth transport populates that very header, and the headers-auth strategy runs before OAuth is ever attempted — so a server with nooauthblock and a staticAuthorizationheader has stopped using OAuth. The live connection has the final say: if the configured header were rejected and the OAuth strategy rescued the connection with the stored token, the token is in play and is reported as for any OAuth server.config.ServerConfig.HasStaticAuthorizationHeader()— case-insensitive name, non-blank value. OnlyAuthorizationcounts;X-API-Key-style headers do not contradict OAuth, so they are deliberately not treated as evidence.core.Client.AuthStrategy()— the two identical HTTP/SSE strategy loops are folded intorunAuthStrategies, which records the winner ("headers","no-auth",AuthStrategyOAuth); reset at the start of each connect attempt and on disconnect. Read atomically becauseConnectholdsc.mufor the whole attempt (an OAuth flow can take minutes) and the projection must not block on it.managed.Client.ConnectedWithOAuth()exposes it.Runtime.StoredOAuthTokenInPlay(name, cfg)— the single decision:cfg == nil || cfg.OAuth != nil || !HasStaticAuthorizationHeader()→ true; otherwise only if the live connection was made with OAuth.Runtime.GetAllServersskips the stored-token lookup when the token is not in play; the laterIsOAuthError(LastError)branch is untouched, so a header-auth server that genuinely gets a 401 still receives the login CTA.Runtime.HealthRefreshState(name, cfg)wrapsRefreshManager.GetRefreshStatewith the same decision, and all threeCalculateHealthcall sites (REST viaRuntime.GetAllServers, the Go tray viaServer.GetAllServers, the MCPupstream_serverslist) read refresh state through it. The calculator itself is unchanged.TriggerOAuthLogoutnow callsrefreshManager.OnTokenCleared, as server removal already did.Deletion decision — the stale record is ignored, not deleted. Explicit-OAuth →
nullalready clears state on config load (OAuthConfigChanged, unchanged). For the reporter's autodiscovery case there is no "OAuth removed" transition to hook — the only new signal is the header — and deleting a credential the user obtained through an interactive login on the strength of a config edit (possibly transient, possibly tool-written) is a destructive step the projection does not need. Once ignored the record is inert:logoutstill removes it, and the existing startup orphan cleanup removes it when the server is deleted. If a user later drops the header, the token is consulted again exactly as before.Testing
TDD: every test below was written first and observed failing (three of the runtime tests failed with "Token expired" / "unhealthy" / a surviving schedule; the scope-control test passed pre-fix, as intended).
internal/runtime/stale_oauth_token_health_test.goTestGetAllServers_StaticAuthHeaderIgnoresStaleOAuthToken— the reporter's case: stale expired record +Authorizationheader + connected →healthy,action: none,oauth: null,authenticated: false, nooauth_status/token_expires_at.TestGetAllServers_StaticAuthHeaderIgnoresStaleRefreshState— same, withRefreshManager.Starthaving parked the record inFailed→ not "Refresh token expired".TestGetAllServers_AutodiscoveryOAuthStillReportsExpiredToken— scope control: noAuthorizationheader (a non-credential header only) → still "Token expired" / login.TestHealthRefreshState_SharedSeam— the seam all three surfaces use: withheld for header-auth, reported for autodiscovery, explicitoauthblock wins, nil config keeps history.TestTriggerOAuthLogout_ClearsRefreshSchedule— logout removes the token and the schedule.internal/upstream/core/auth_strategy_test.go—runAuthStrategiesrecords the winner (headers / OAuth-rescue / no-auth), leaves it empty on exhaustion, and still returns a transport error unwrapped without reaching later strategies.internal/config/static_authorization_header_test.go— predicate table incl. case-insensitivity, blank value,X-API-Key,Proxy-Authorization, nil receiver.go test -raceoninternal/runtime/... internal/health/... internal/config/... internal/upstream/...andinternal/server(CI skip regex) — green.go build ./...green.golangci-lint(v2,.github/.golangci.yml) — 0 issues on touched packages.github-copilot/gpt-6-astra), 3 rounds: round 1 found the OAuth-rescue edge (fixed via live strategy); round 2 found that a calculator-level guard hid genuine autodiscovery refresh failures on the tray/MCP surfaces (fixed by moving the decision into the runtime seam and routing all three call sites through it); round 3VERDICT: CLEAN.Closes #1172
🤖 Generated with Claude Code