Skip to content

fix(oauth): serialize token refresh per server and classify refresh errors (Spec 113-a) - #1498

Merged
Dumbris merged 10 commits into
mainfrom
113-a-oauth-refresh-robustness
Oct 6, 2026
Merged

Dumbris merged 10 commits into
mainfrom
113-a-oauth-refresh-robustness

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Problem

Gaps G1 and G2 of the Anthropic MCP-proxy talk audit ("How Anthropic uses MCP for its own product", https://www.youtube.com/watch?v=e8DMLtP5ibk), re-verified in the Spec 113 research:

  • G1: OAuth token refresh was not serialized. mcp-go's OAuthHandler.getValidToken refreshes with no lock whenever the token store reports an expired token, and the RefreshManager's proactive path (RefreshOAuthTokenDirect, two sub-paths) refreshes independently. With an authorization server that rotates refresh tokens, N concurrent callers at expiry sent N refresh requests with the same refresh token. Providers with reuse detection revoke the whole grant, so the user has to sign in again. PersistentTokenStore.SaveToken also read the record and wrote it in two transactions, so a concurrent DCR write was lost. The new test reproduces both bugs on main.
  • G1+: the RefreshManager looked up token records by display name, but records are keyed by GenerateServerKey(name, url). The token-age log and the oauth.token_refreshed event never fired.
  • G2: refresh errors were classified by substring. invalid_client fell through to failed_other, so a dead DCR client was retried up to 50 times. 5xx and 429 were not told apart from other errors.

What changed

  • oauth.RefreshCoordinator (new, process-wide): runs at most one flight per server key. The flight runs on a detached context with a 30 s timeout. Inside the flight it first re-reads the record, and if the refresh token was already rotated it returns the stored token with no network call. All waiters get the same token or the same error. After a terminal failure it latches, and after a transient failure reactive callers get a cooldown that mirrors the RefreshManager backoff. The latch and cooldown are keyed by grant generation (refresh-token and client-id fingerprint), so a login clears them. A flight whose generation was superseded during the request (for example by a login) has its result discarded and causes no side effects (FR-006a). Each flight logs one Info line with server, trigger, outcome class and whether the network call was skipped.

  • Reactive path: core binds the live handler's refresh function to the token store handed to mcp-go (c.bindOAuthRefresher(oauthConfig) after each OAuth c.client =). A bound GetToken:

    • refreshes through the coordinator inside the refresh margin;
    • returns the token with ExpiresAt zeroed, so mcp-go's unlocked refreshToken can never run;
    • on terminal failure returns an error wrapping transport.ErrOAuthAuthorizationRequired;
    • on transient failure returns oauth.ErrTokenRefreshTransient.

    Unbound stores (read-only probes) behave as before. The CLI in-memory store uses the same coordinator, keyed by server name.

  • Proactive path: RefreshOAuthTokenDirect runs a coordinator flight with the same refresh function. That function uses handler.RefreshToken when the handler has a usable client id and the stored DCR credentials otherwise, and never sends an empty client_id.

  • Storage: BoltDB.UpdateOAuthToken (one read-modify-write transaction) and ClearOAuthClientCredentialsIf (compare-and-clear). SaveToken and the manual refresh persist through UpdateOAuthToken.

  • Classification: ClassifyRefreshError reads the RFC 6749 §5.2 code from transport.OAuthError and the HTTP status from the typed RefreshHTTPError or mcp-go's fixed non-JSON text. It also recognises network error types. Substring matching is kept only as the last fallback, and each pattern is tested.

    Class Handling
    invalid_grant Terminal
    invalid_client, DCR client Registration cleared once (compare-and-clear on the client id and the refresh token the request used); terminal with "sign in again to re-register". A second rejection before any successful token response, from refresh or from the login code exchange, is terminal with "rejects new client registrations".
    invalid_client, static client Terminal; credentials untouched; message points at oauth.client_id / oauth.client_secret
    unauthorized_client, unsupported_grant_type, invalid_scope Terminal
    server_error, temporarily_unavailable, 5xx, 429, network Existing backoff
    Anything else Existing backoff, then the existing give-up

    New metric labels: failed_invalid_client, failed_server_error. Existing labels are unchanged.

  • RefreshManager: looks up records by server key, treats every terminal class as terminal and reports each one once, and learns about terminal outcomes of reactive flights through the coordinator completion hook.

  • Docs: docs/features/oauth-authentication.md covers refresh serialization, the error classes and the metric labels.

Tests

  • New: refresh_errors_test.go (table covering every class and fallback pattern), refresh_coordinator_test.go (single flight with N=50, stale observed token, shared failure, cancelled initiator, parallel keys, terminal latch, transient cooldown, stale flight after login, invalid_client DCR/static/connect-flow/compare-and-clear, login-exchange rule), persistent_token_store_refresh_test.go, persistent_token_store_race_test.go, memory_token_store_refresh_test.go, refresh_manager_invalid_client_test.go, storage/bbolt_oauth_update_test.go.
  • SC-001: internal/upstream/core/oauth_refresh_race_test.go uses a rotating httptest AS that revokes the grant on reuse, a protected Streamable HTTP MCP server, and a real mcp-go client. 50 concurrent CallTools plus one concurrent RefreshOAuthTokenDirect on an expired token produce exactly 1 token request, all calls succeed, and the request carries a non-empty client_id. It covers both sub-paths and passes under -race -count=20. With the binding disabled, the same test fails: the grant is revoked and the calls get "authorization required".
  • go test -race passed for internal/oauth/..., internal/storage/..., internal/upstream/..., internal/observability/..., internal/runtime/... and internal/appctx/.... The coordinator, store and storage tests also pass with -count=20.
  • go build ./cmd/mcpproxy and go build -tags server -o /dev/null ./cmd/mcpproxy both succeed.
  • I ran golangci-lint v2 with .github/.golangci.yml, bare and with --build-tags server. Neither run reports anything in changed or new files. Both runs do report findings in files this PR does not touch (for example redactview.go, connection_stdio.go and tests/oauthserver/jwks.go).
  • ./scripts/test-api-e2e.sh against an isolated instance (LISTEN_PORT=18347): passed. On the first run the launcher-lifecycle reconnect check timed out once; the rerun passed fully, as did origin/main on the same port.
  • No REST, OAS or config change, so make swagger was not needed (FR-013).

Assumptions

  • Refresh margin for short tokens: half the lifetime, capped at 30 s. The spec's "half their lifetime or 30 s" is read as the smaller of the two, so a 30 s token is not refreshed on every request. Lifetime is estimated from the record's Updated time.
  • Still-valid access token: if a refresh fails while the current access token is still valid (inside the 5 min grace), GetToken keeps serving it. The terminal or transient error is returned only once the token has actually expired, so an AS outage does not fail calls early. The RefreshManager is told about terminal outcomes at once through the hook.
  • Proactive fresh window: a proactive refresh within MinRefreshInterval (5 s, the RefreshManager's own scheduling floor) of a token save, for a token with more than 5 s left, is answered from storage (see the CI note below).
  • No login-flow exemption for the DCR clear (deviation from FR-009's wording): tryOAuthAuth holds a connection-wide OAuth flow while its own reactive refresh runs, so the exemption kept a rejected registration forever (every sign-in reloaded it and skipped DCR). The compare-and-clear on client id plus refresh token already protects any registration or grant a login saved. A code exchange that still uses the cleared client is told to sign in again, not that the AS rejects new registrations.
  • Cooldown scope: the transient cooldown applies to reactive callers only. The proactive path keeps its own unchanged backoff (10 s doubling to a 5 min cap, DefaultMaxRetries 50).
  • DCR handler mismatch: when the live handler's client id differs from a newer stored DCR client id, the stored credentials are used. When neither is available the refresh is terminal (ErrNoClientCredentials) and never sends an empty client_id.
  • Binding sites: the handler is bound at the five OAuth client-creation sites in connection_oauth.go (one helper call each), not at a single site as the plan suggested, because there is no shared post-creation point. The three authorization-code exchange sites call a one-line annotation helper, which implements the FR-009 login-flow rule.
  • RefreshManager lookup: RefreshManager knows only the display name, so FR-010 matches the record by DisplayName (most recently updated wins), after trying a legacy record keyed by the bare name.
  • Reconnect fallback: upstream.Manager.RefreshOAuthToken is unchanged and keeps its reconnect fallback on failure.

CI shuffle failure (run 37307361769)

TestOAuthRefreshRace_OneTokenRequestPerExpiry/stored_DCR_credentials_sub-path saw 2 token requests, both valid (rt-0 then rt-1, no replay). Root cause was in production code: RefreshOAuthTokenDirect reads the record when it runs, not when the RefreshManager timer fired. When the reactive flight had already rotated the token, the proactive caller observed the new refresh token, so the FR-003 "already rotated" check could not tell, and a second flight rotated the just-minted grant. In production that is the timer firing while a reactive refresh (or a login) completes, before OnTokenSaved reschedules. Fix: the coordinator records the last token save per key and answers a proactive request within the fresh window from storage. TestOAuthRefreshRace_LateProactiveAfterReactiveFlight reproduces the CI ordering deterministically for both sub-paths (it failed before the fix). The race test's AS now holds the token request for 50 ms so callers overlap the flight. Verified with the CI seed (-shuffle=1791202369572320301 -count=20), GOMAXPROCS=1 -count=50 and -count=50 -cpu 1,2,4 under -race.

Review fixes (opencode gpt-6.1-sol)

Stale-flight side effects (latch re-read, DCR compare-and-clear now also on the refresh token, schedule binding for terminal outcomes and retries, logout during a flight or a grace refresh hands out no token, DCR bookkeeping after a concurrent login); DCR recovery during a connect attempt; secret redaction of refresh errors (pattern scrub plus the exact sent credentials in raw, JSON- and URL-escaped spellings, decoded JSON error fields, error code field); short-token handling (CLI memory store save time, proactive window); oauth.extra_params.client_id grants stay refreshable; long non-JSON bodies keep their RFC 6749 code, while 5xx/429 stay transient.

Spec: #1495
Review: opencode github-copilot/gpt-6.1-sol, 8 rounds, clean (round 8 VERDICT: CLEAN); follow-ups: proactive refreshes are not held by the reactive transient cooldown (by design, RefreshManager has its own backoff); the still-valid access token is served during the refresh margin even after a terminal refresh error (the hook applies the terminal outcome to the schedule at once).

…rrors (Spec 113-a)

Serialize every OAuth refresh flight per server key across mcp-go's reactive
refresh (the token store it reads before each request) and the RefreshManager's
proactive path, including the stored-DCR-credentials sub-path. A rotating
authorization server now sees one refresh request per expiry instead of one per
concurrent caller, which on reuse-detecting providers revoked the whole grant.

- oauth.RefreshCoordinator: single-flight per server key on a detached 30 s
  context; re-reads the record and skips the network when another flight
  already rotated the token; waiters share the token or the error; terminal
  latch and transient cooldown keyed by the grant generation; FR-006a
  compare-and-swap so a login that supersedes a flight always wins.
- PersistentTokenStore (and the CLI in-memory store) refresh through the
  coordinator once core binds the live handler, and hand mcp-go a token with
  a zero ExpiresAt so its unlocked refreshToken can never run.
- SaveToken and the manual refresh path persist via one BBolt read-modify-write
  (storage.UpdateOAuthToken), fixing the lost DCR-credential update.
- ClassifyRefreshError: RFC 6749 section 5.2 error code (transport.OAuthError),
  HTTP status (typed RefreshHTTPError / mcp-go's fixed text), network error
  types; substring matching only as the last fallback. invalid_client clears a
  DCR registration once (compare-and-clear) and is terminal; static clients
  are terminal with an actionable message; 5xx/429/network keep the backoff.
- RefreshManager reads token records by server key (refreshed event fired
  never before), handles all terminal classes, and learns about reactive
  terminal outcomes through the coordinator completion hook.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: a245f4a
Status: ✅  Deploy successful!
Preview URL: https://25aa9efb.mcpproxy-docs.pages.dev
Branch Preview URL: https://113-a-oauth-refresh-robustne.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Oct 5, 2026 •

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: 113-a-oauth-refresh-robustness

Available Artifacts

  • archive-darwin-amd64 (31 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (27 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (27 MB)
  • installer-dmg-darwin-arm64 (24 MB)
  • smart-mcp-proxymcpproxy-goV2M2AV.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 37326870262 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

…ave (Spec 113-a SC-001)

The shuffle CI run saw two token requests per expiry in
TestOAuthRefreshRace_OneTokenRequestPerExpiry. Cause: RefreshOAuthTokenDirect
reads the record when it runs, not when the RefreshManager timer fired. When
a reactive flight rotated the token first, the proactive caller observed the
NEW refresh token, so the FR-003 "already rotated" check could not tell, and
a second flight rotated the just-minted grant again.

The coordinator now records when a token was last saved for a key (a
successful flight or NoteTokenSaved from a login/persist). A proactive
request within MinRefreshInterval (5 s, the RefreshManager's own scheduling
floor) of that save, for a non-expired token, is answered from storage
without a network request. Reactive callers are unaffected.

Adds a deterministic reproduction (reactive refresh, then a late proactive
refresh) for both refresh sub-paths, plus coordinator unit tests.
…cts, DCR recovery, secret scrubbing)

- Terminal latch: a login that saved a new grant between the flight's
  post-refresh read and the latch read supersedes the flight; no latch, no
  completion hook (FR-006a).
- DCR compare-and-clear also requires the stored refresh token to be the one
  the failed request used, so a login that reused the client id keeps it.
- Drop the "login flow active" exemption from the invalid_client clear: a
  connect attempt (tryOAuthAuth holds a flow) running its own refresh left
  the rejected registration in place and every sign-in reloaded it. The CAS
  protects registrations a login saved. A code exchange that still uses the
  cleared client id is told to sign in again, not that the AS rejects new
  registrations.
- RefreshManager only fails the schedule it recorded the failure on; a
  schedule replaced by a login's OnTokenSaved keeps running.
- ScrubRefreshError: mcp-go embeds error_description / raw bodies in refresh
  errors; the handler sub-path now returns a scrubbed, capped message with
  the original chain kept for classification.
- CLI in-memory store records carry the save time, so short tokens are not
  refreshed on every request.
- A client id given only via oauth.extra_params refreshes through the
  handler (OAuthTransportWrapper injects it) instead of ErrNoClientCredentials.
- Tests: the race-test AS holds the token request 50 ms so callers overlap the
  flight; the DCR lost-update test checks write errors and token fields.
…ential echo, capped classification)

- Terminal outcomes are applied only to the schedule they belong to: a
  proactive attempt only fails the schedule it started from, and a reactive
  completion hook carries the failed record's expiry and only fails a
  schedule built for that token. A login whose OnTokenSaved replaced the
  schedule after the flight's last generation check keeps running (FR-006a).
- Refresh errors remove the exact credentials the request sent (refresh
  token, client secret) before pattern scrubbing, on both sub-paths; an
  opaque value echoed without a key=value shape was left in logs/events.
- Classification reads the unscrubbed, uncapped text under a scrubbed error,
  so a long non-JSON body does not hide invalid_grant from the fallback.
…extra_params secret, classify long bodies)

- The stored-DCR refresh redacts the credentials it sent from the error
  code field as well as the detail, and caps it.
- A client_secret given via oauth.extra_params (injected into every token
  request by OAuthTransportWrapper) is redacted from refresh errors on both
  sub-paths.
- A non-JSON error body longer than the detail cap takes its RFC 6749 code
  from the whole body, so invalid_grant still latches instead of retrying.
…3-a review round 4)

The RFC 6749 code is only inferred from a non-JSON error body for non-5xx,
non-429 responses; a gateway page that merely mentions invalid_grant stays
transient instead of latching the grant.
…short-token proactive, stale bookkeeping)

- A flight whose record was deleted (logout) or emptied while it ran hands
  out no token: its persist was discarded, so its result is too.
- The proactive fresh-window skip applies only while the stored token has
  more than the window left; a very short token scheduled at expiry minus
  MinRefreshInterval is genuinely due.
- The DCR "re-registration used" mark is only recorded when no token was
  saved since the flight read the state, so a login that re-registered in
  between is not marked as rejected.
- A transient retry only reschedules the schedule that failed; a login's
  new schedule keeps its own timer.
- "Still valid" in the reactive path is judged when the flight returns, so
  a token that expired during a slow failed refresh is not handed out.
- The live handler's DCR client secret is redacted from refresh errors too.
…fresh, escaped credential echo)

- The reactive "access token still valid" fallback only serves the token
  while the store still holds it; a logout (or login) during the flight
  yields an error instead of the pre-logout token.
- Refresh errors redact the sent credentials in their raw, JSON-escaped and
  URL-escaped spellings (an AS may echo them inside a JSON
  error_description or a form-encoded body).
…a review round 7)

- The stored-DCR refresh renders a JSON error body from its decoded
  error / error_description fields, so whatever JSON escaping the AS chose
  is undone before the sent credentials are matched.
- SentValueSpellings also covers the plain (non-HTML-escaping) and
  PHP-style ("\/") JSON spellings for the handler sub-path's raw bodies.
Dumbris added a commit that referenced this pull request Oct 6, 2026
#1498 drops the 'unless a login flow is active' exemption for the DCR clear:
tryOAuthAuth holds a connection-wide flow during its own reactive refresh, so
the exemption kept a rejected registration forever. The compare-and-clear on
client id and refresh token protects anything a login saved.
@Dumbris
Dumbris enabled auto-merge (squash) October 6, 2026 17:24
@Dumbris
Dumbris merged commit da3e57b into main Oct 6, 2026
57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants