fix(mcp): handle OAuth IdP error responses in /callback (LIT-2750)#28743
Conversation
[Infra] Promote internal staging to main
[Infra] Promote internal staging to main
chore(ci): promote internal staging to main
Per RFC 6749 section 4.1.2.1, when the IdP rejects an OAuth authorization request it redirects back to the client with ?error=...&error_description=... and no code. The MCP /callback handler declared code and state as required query params, so FastAPI rejected such error responses with a 422 before the handler ran -- stranding the MCP client waiting on the loopback. This change: - Makes code and state optional and accepts the RFC-defined error, error_description, and error_uri params. - When state decodes to a trusted client redirect_uri, propagates the error params back to that URI with the client's original (un-wrapped) state preserved, so the client's OAuth library can surface the failure. - When state is missing/undecryptable or the encoded redirect_uri is no longer trusted, renders a 400 HTML page with the (HTML-escaped) error details instead of leaking to an attacker-controlled redirect. - Preserves the existing success path (code + state -> 302 to validated client redirect_uri with original state). Fixes LIT-2750.
…s (LIT-2750) Adds a new test module covering the LIT-2750 fix: the MCP OAuth /callback endpoint must accept IdP error responses (e.g. ?error=access_denied) per RFC 6749 section 4.1.2.1 instead of returning a 422 because ``code`` is missing. Coverage: - IdP error with no state -> 400 HTML page surfacing the error. - HTML escaping of user-controlled error / error_description fields. - IdP error with a trusted (loopback) state -> 302 propagating error / error_description / original client state to the client. - IdP error with an untrusted redirect_uri encoded in state -> 400 inline (no open-redirect to attacker-controlled origin). - IdP error with an undecryptable state -> 400 HTML fallback. - Bare GET /callback with no params -> 400 HTML (not Pydantic 422). - Success path (code + state) still 302 to validated client redirect_uri with the original (un-wrapped) state preserved.
|
|
Greptile SummaryFixes a 422 returned by the MCP OAuth
Confidence Score: 5/5Safe to merge — changes are scoped to the MCP OAuth callback handler, the success path is preserved, and the new error path re-uses the existing redirect-URI allowlist. The change is narrowly scoped: it makes two query params optional and adds a well-guarded error branch. Open-redirect risk is mitigated by reusing _get_validated_client_redirect_uri, XSS risk is handled by html.escape, and exception handling falls back to an inline HTML page rather than propagating. All new branches are covered by dedicated regression tests. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py | Makes code/state optional on /callback and adds RFC 6749 §4.1.2.1 error-path handling: propagates IdP errors to trusted redirect URIs, falls back to an HTML error page for untrusted/missing state, and HTML-escapes all IdP-supplied fields to prevent XSS. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_callback_oauth_error_responses.py | New regression test file with 7 unit tests covering IdP error paths, XSS escaping, open-redirect prevention, undecryptable state fallback, bare GET 400, and success path regression; uses only mocks, no real network calls. |
Reviews (2): Last reviewed commit: "refactor(mcp): drop unused _OAUTH_ERROR_..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
The tuple was leftover scaffolding from an earlier draft of the LIT-2750 fix; nothing references it. The explanatory RFC 6749 §4.1.2.1 comment block above the callback handler covers the same intent.
|
Addressed the P2: dropped the unused @greptileai review |
2bdfae1
into
BerriAI:litellm_oss_staging_250526
Summary
Bug: When an IdP rejects an MCP OAuth authorization request, it redirects back to the proxy's
/callbackwith?error=access_denied&error_description=…&state=…and nocode(per RFC 6749 §4.1.2.1). The handler declaredcodeandstateas required FastAPI query params, so FastAPI rejected the error response with a 422 / Pydantic validation blob before the handler ran. The user landed on an opaque JSON error page and the MCP loopback client kept waiting on the listener until it timed out. The same 422 also fires when an SSO redirect chain drops the original/authorizequery params.Fix (
litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py):codeandstateoptional, and accept the RFC-definederror,error_description,error_uriparams.statedecodes to a trusted clientredirect_uri, propagate the OAuth error back to that URI with the client's original (un-wrapped)statepreserved, so the client's OAuth library can surface the failure (RFC 6749 §4.1.2.1).stateis missing/undecryptable or the encodedredirect_uriis no longer trusted, render a 400 HTML page with the (HTML-escaped) error instead of leaking to an attacker-controlled redirect.code+state→ 302 to validated clientredirect_uriwith originalstate.Closes LIT-2750 (Linear).
Originally reported in the Slack thread linked from the Linear ticket: a user without all the IdP entitlements got bounced into an MCP OAuth flow that returned 422 instead of an actionable error.
Security considerations
errorpath re-uses the existing_get_validated_client_redirect_uriallowlist (loopback / same-origin / ops-allowlisted). An untrustedredirect_uriencoded in a stale state falls back to the inline HTML page rather than 302-ing the user to an attacker-controlled origin. Regression test:test_idp_error_with_untrusted_redirect_uri_does_not_open_redirect.erroranderror_descriptiongo throughhtml.escapebefore being rendered in the HTML fallback. Regression test:test_idp_error_html_escapes_user_controlled_fields.decode_state_hashraises (e.g. salt key rotated, tampered state), the handler returns 400 HTML instead of propagating the exception. Regression test:test_idp_error_with_undecryptable_state_falls_back_to_html.Changes
litellm/proxy/_experimental/mcp_server/discoverable_endpoints.pyhtml as _html._render_oauth_error_html(error, description)helper.callback(request, code: str, state: str)with optionalcode/stateand newerror/error_description/error_uriparams; branch by (error present | bare GET | success).tests/test_litellm/proxy/_experimental/mcp_server/test_callback_oauth_error_responses.py(new)Manual testing evidence
Reproduced the original 422 against the unmodified endpoint, then verified each behaviour against the patched endpoint using a
fastapi.testclient.TestClientmounted with the discoverable router (LITELLM_SALT_KEY=sk-test-salt-for-LIT-2750).1. IdP error, no state — was 422 Pydantic, now 400 HTML
2. IdP error + trusted (loopback) state — propagates back to client redirect_uri
The wrapped (encrypted) state passed in is not present in the location header — only the original client state is round-tripped, exactly how the MCP client's OAuth library expects.
3. Bare
GET /callback(no params) — was 422 Pydantic, now 400 HTML4. Success path — unchanged, still 302 to validated client redirect_uri
5. Untrusted
redirect_uriencoded in state — no open redirectThe proxy logs the rejection (
MCP OAuth: rejecting redirect_uri 'https://attacker.example.com/steal') and surfaces the error inline rather than 302-ing to the attacker URL.6. HTML escapes IdP-supplied fields — no XSS via
error/error_descriptionTest plan
test_callback_oauth_error_responses.pycover the bug + each fallback branch.test_discoverable_endpoints.pycontinue to pass (no behaviour change for the success path or/authorize//tokenendpoints).Type
No UI surface changes — this is a server-side handler fix.
Agent session: https://litellm-agent-platform.onrender.com/agents/9cbb91a6-e66d-43c5-92ed-68a570429527