Skip to content

Discover auth-server metadata before eager token refresh - #3241

Open
mfadul24 wants to merge 1 commit into
modelcontextprotocol:mainfrom
mfadul24:fix/oauth-refresh-before-discovery
Open

Discover auth-server metadata before eager token refresh#3241
mfadul24 wants to merge 1 commit into
modelcontextprotocol:mainfrom
mfadul24:fix/oauth-refresh-before-discovery

Conversation

@mfadul24

@mfadul24 mfadul24 commented Aug 3, 2026

Copy link
Copy Markdown

On a cold start with a cached-but-expired token (reusing a stored refresh token before any 401), async_auth_flow refreshed before discovery ran, so oauth_metadata was None and _refresh_token fell back to {origin}/token. That drops any issuer path and 404s on servers whose token endpoint lives under a path (e.g. .../oauth2/api/v1/token) — the refresh fails, tokens are cleared, and the client is forced into interactive re-auth it may not be able to complete. Discover AS metadata before the eager refresh.

Adds _discover_oauth_metadata (pure discovery, driven through the auth flow) and _refresh_with_discovery, plus a regression test.

Fixes #3240

Motivation and Context

Hit this against a hosted MCP server whose authorization server isn't at the resource origin (token endpoint https://host/oauth2/api/v1/token, not https://host/token). A headless client with cached tokens disconnects every time the short-lived access token expires: on reconnect the eager refresh at the top of async_auth_flow runs before any 401/discovery, oauth_metadata is None, and _refresh_token uses urljoin(get_authorization_base_url(server_url), "/token"){scheme}://{netloc}/token → 404 → _handle_refresh_response clears the tokens → the flow drops to interactive auth a background client can't complete.

The same path-stripping fallback exists in _get_token_endpoint, _perform_authorization_code_grant (/authorize) and DCR (/register), but those run inside the 401 branch after discovery, so metadata is already populated there — refresh is the one that fires before discovery, which is why it's the acute, silent case. This PR fixes the refresh path; the other fallbacks are left as-is.

How Has This Been Tested?

  • New regression test in tests/client/test_auth.py: cold start with an expired-but-refreshable token and no prior discovery, against an AS whose token_endpoint is under a path; asserts the refresh request targets the discovered endpoint, not {origin}/token.
  • uv run pytest tests/client/test_auth.py → 141 passed, 1 xfailed. uv run ruff check / ruff format --check clean.
  • Reproduced the original break and confirmed the fix end-to-end against a real hosted MCP server (Interactive Brokers) whose token endpoint lives under /oauth2/....

Breaking Changes

None. On a cold start with no cached metadata the eager refresh now issues discovery requests before the refresh; once metadata is known it's a no-op, so the fast path (already-valid or already-discovered) is unchanged.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

_discover_oauth_metadata is a pure-discovery async generator (PRM → AS metadata) that yields its requests through the outer httpx auth flow — noside-channel client — and only populates protected_resource_metadata / auth_server_url / oauth_metadata. _refresh_with_discovery wraps it + the existing refresh so async_auth_flow drives one sub-flow and stays under the module's complexity cap. It reuses the existing build_*_discovery_urls /handle_*_response / validate_metadata_issuer helpers, so discovery behaves exactly like the 401 path.

On a cold start with a cached-but-expired token (reusing a stored refresh
token before any 401), async_auth_flow refreshed before discovery ran, so
oauth_metadata was None and _refresh_token fell back to {origin}/token.
That drops any issuer path and 404s on servers whose token endpoint lives
under a path (e.g. .../oauth2/api/v1/token) — the refresh fails, tokens are
cleared, and the client is forced into interactive re-auth it may not be
able to complete. Discover AS metadata before the eager refresh.

Adds _discover_oauth_metadata (pure discovery, driven through the auth
flow) and _refresh_with_discovery, plus a regression test.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/client/auth/oauth2.py">

<violation number="1" location="src/mcp/client/auth/oauth2.py:633">
P1: A cold-start refresh can send credentials bound to an old authorization server to a newly discovered one. `_refresh_with_discovery` refreshes immediately after PRM/ASM discovery, but omits the issuer-binding check that the 401 discovery path uses before any token request. When `client_info.issuer` differs from the discovered AS, clear the bound client/tokens and skip refresh so the subsequent authorization flow registers/authenticates against the new issuer instead.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

except StopAsyncIteration:
break

refresh_response = yield await self._refresh_token()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A cold-start refresh can send credentials bound to an old authorization server to a newly discovered one. _refresh_with_discovery refreshes immediately after PRM/ASM discovery, but omits the issuer-binding check that the 401 discovery path uses before any token request. When client_info.issuer differs from the discovered AS, clear the bound client/tokens and skip refresh so the subsequent authorization flow registers/authenticates against the new issuer instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/auth/oauth2.py, line 633:

<comment>A cold-start refresh can send credentials bound to an old authorization server to a newly discovered one. `_refresh_with_discovery` refreshes immediately after PRM/ASM discovery, but omits the issuer-binding check that the 401 discovery path uses before any token request. When `client_info.issuer` differs from the discovered AS, clear the bound client/tokens and skip refresh so the subsequent authorization flow registers/authenticates against the new issuer instead.</comment>

<file context>
@@ -577,6 +577,64 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None
+                except StopAsyncIteration:
+                    break
+
+        refresh_response = yield await self._refresh_token()
+        if not await self._handle_refresh_response(refresh_response):
+            # Refresh failed, need full re-authentication
</file context>

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.

OAuth token refresh hits the wrong endpoint when the auth server lives under a path

1 participant