🪝 fix: MCP Refresh token on OAuth Discovery Failure#12266
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR changes MCP OAuth token refresh behavior to require successful OAuth metadata discovery (and a token endpoint), removing the previous legacy /token fallback behavior, and updates unit tests accordingly.
Changes:
- Replace
/tokenfallback behavior during token refresh with hard failures when OAuth discovery/token endpoint is missing. - Update MCP OAuth handler tests to expect thrown errors (and no refresh HTTP call) when discovery fails.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/api/src/mcp/oauth/handler.ts | Removes legacy /token fallback in refresh paths; throws when discovery/token endpoint missing. |
| packages/api/src/mcp/tests/handler.test.ts | Updates refresh tests to assert errors instead of fallback refresh calls. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
Comment on lines
862
to
867
| const oauthMetadata = await this.discoverWithOriginFallback(serverUrl, fetchFn); | ||
|
|
||
| if (!oauthMetadata) { | ||
| /** | ||
| * No metadata discovered - use fallback /token endpoint. | ||
| * This mirrors the MCP SDK's behavior for legacy servers without .well-known endpoints. | ||
| */ | ||
| logger.warn( | ||
| `[MCPOAuth] No OAuth metadata discovered for token refresh, using fallback /token endpoint`, | ||
| ); | ||
| tokenUrl = new URL('/token', metadata.serverUrl).toString(); | ||
| authMethods = ['client_secret_basic', 'client_secret_post', 'none']; | ||
| throw new Error('No OAuth metadata discovered for token refresh'); | ||
| } else if (!oauthMetadata.token_endpoint) { | ||
| throw new Error('No token endpoint found in OAuth metadata'); |
Comment on lines
1032
to
1037
| const oauthMetadata = await this.discoverWithOriginFallback(serverUrl, fetchFn); | ||
|
|
||
| let tokenUrl: URL; | ||
| if (!oauthMetadata?.token_endpoint) { | ||
| /** | ||
| * No metadata or token_endpoint discovered - use fallback /token endpoint. | ||
| * This mirrors the MCP SDK's behavior for legacy servers without .well-known endpoints. | ||
| */ | ||
| logger.warn( | ||
| `[MCPOAuth] No OAuth metadata or token endpoint found, using fallback /token endpoint`, | ||
| ); | ||
| tokenUrl = new URL('/token', metadata.serverUrl); | ||
| throw new Error('No OAuth token endpoint found for token refresh'); | ||
| } else { |
b4fe1c2 to
c4e4d3b
Compare
When OAuth metadata discovery fails, refresh logic was falling back to POSTing refresh tokens to /token on the MCP resource server URL instead of the authorization server. A malicious MCP server could exploit this by blocking .well-known discovery to harvest refresh tokens. Changes: - Replace unsafe /token fallback with hard error in both refresh paths - Thread stored token_endpoint (SSRF-validated during initial flow) through the refresh chain so legacy servers without .well-known still work after the first successful auth - Fix revokeOAuthToken to always SSRF-validate the revocation URL, including the /revoke fallback path - Redact refresh token and credentials from debug-level log output - Split branch 2 compound condition for consistent error messages
- Add storedTokenEndpoint fallback tests for both refresh branches - Add missing test for branch 2 metadata-without-token_endpoint case - Rename misleading test name to match actual mock behavior - Split auto-discovered throw test into undefined vs missing-endpoint - Remove redundant afterEach mockFetch.mockClear() calls (already covered by jest.clearAllMocks() in beforeEach)
c4e4d3b to
008e805
Compare
jcbartle
pushed a commit
to jcbartle/LibreChat
that referenced
this pull request
May 11, 2026
* 🔒 fix: Prevent token leaks to MCP server on OAuth discovery failure When OAuth metadata discovery fails, refresh logic was falling back to POSTing refresh tokens to /token on the MCP resource server URL instead of the authorization server. A malicious MCP server could exploit this by blocking .well-known discovery to harvest refresh tokens. Changes: - Replace unsafe /token fallback with hard error in both refresh paths - Thread stored token_endpoint (SSRF-validated during initial flow) through the refresh chain so legacy servers without .well-known still work after the first successful auth - Fix revokeOAuthToken to always SSRF-validate the revocation URL, including the /revoke fallback path - Redact refresh token and credentials from debug-level log output - Split branch 2 compound condition for consistent error messages * ✅ test: Add stored endpoint fallback tests and improve refresh coverage - Add storedTokenEndpoint fallback tests for both refresh branches - Add missing test for branch 2 metadata-without-token_endpoint case - Rename misleading test name to match actual mock behavior - Split auto-discovered throw test into undefined vs missing-endpoint - Remove redundant afterEach mockFetch.mockClear() calls (already covered by jest.clearAllMocks() in beforeEach)
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.
Summary
Fixed a security vulnerability in the MCP OAuth token refresh flow where a malicious MCP server could harvest refresh tokens by blocking
.well-knownmetadata discovery, and resolved a companion vulnerability in the token revocation path./tokenfallback in bothrefreshOAuthTokensbranches (storedclientInfoand auto-discovered) with hard errors when OAuth metadata discovery fails and no stored endpoint is available, preventing refresh tokens from being POSTed to an unverified MCP resource server endpoint.storedTokenEndpointandstoredAuthMethods(persisted from the initial OAuth flow's SSRF-validated metadata) throughMCPTokenStorage.getTokens()→MCPConnectionFactory.createRefreshTokensFunction()→MCPOAuthHandler.refreshOAuthTokens(), so legacy MCP servers without.well-knownsupport remain functional after first authentication without reintroducing the token-leak vector.revokeOAuthTokento unconditionally runvalidateOAuthUrlon the constructed revocation URL, including the/revokefallback path, which previously bypassed SSRF validation entirely whenrevocationEndpointwas absent.refresh_tokenvalue andAuthorizationheader from debug-level log output; replaced withgrant_typeand ahas_auth_headerboolean.!oauthMetadata?.token_endpointguard in branch 2 into separate!oauthMetadataand!oauthMetadata.token_endpointchecks, aligning error messages with branch 1 for consistent observability.stored token endpoint fallbacksub-describe indescribe('refreshOAuthTokens')covering both branches: stored endpoint used when discovery fails, and hard-throw preserved when neither discovery nor stored endpoint is available.token_endpointfield.afterEachmockFetch.mockClear()calls from two describe blocks already covered byjest.clearAllMocks()inbeforeEach.@see branch 1 commentJSDoc tag in branch 2 with a plain//comment referencing the security rationale above it.Change Type
Testing
All changes are covered by the existing and new Jest unit tests in
packages/api/src/mcp/__tests__/handler.test.ts. 76 tests pass across the three MCP OAuth test suites (handler.test.ts,MCPOAuthSecurity.test.ts,MCPOAuthTokenStorage.test.ts).To reproduce the vulnerability this fixes: configure a legacy MCP server without
.well-knownendpoints, complete an initial OAuth flow (which succeeds via fallback metadata), let the access token expire, and observe that the pre-fix code would POST the refresh token to{serverUrl}/token(the resource server) rather than the validated authorization server. Post-fix, the storedtoken_endpointfrom the initial flow is used; if none is stored, the request is rejected with an error rather than sent to an unvalidated endpoint.Test Configuration:
Run from the
packages/apidirectory:Checklist