From 14ab1d9100fa21796b74434881a3daf97c6d4db0 Mon Sep 17 00:00:00 2001 From: basil-chen <192173459+rastagan-git@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:45:06 +0800 Subject: [PATCH] fix(proxy): resume MCP OAuth return_to after /ui/login password sign-in /sso/key/generate (no SSO configured) persists the /authorize return_to in the sealed litellm_cp_return_to cookie and its deprecation banner sends users to /ui/login/, which signs in through /v2/login. /v2/login never read the cookie, so the user landed on the dashboard and the native client's loopback callback never got its code. Resume through the same resumer /login and the SSO callback use, and clear the one-shot cookie. Fixes #48090 --- litellm/proxy/proxy_server.py | 33 +++++++++- .../proxy_server/test_routes_login_sso.py | 63 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index df948b69a67..87442ba0a58 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -18531,6 +18531,30 @@ async def login(request: Request): return redirect_response +async def _resume_return_to_url(request: Request, jwt_token: str) -> str | None: + """Where a JSON sign-in should send the browser for the one-shot ``litellm_cp_return_to`` cookie, + or None to land on the dashboard. Goes through the same resumer as /login and the SSO callback, + so the two shapes it accepts (same-origin path, control-plane URL) stay in sync across every + sign-in route. Never raises: a stale cookie must not fail a completed sign-in.""" + from litellm.proxy.management_endpoints.ui_sso import read_return_to_cookie, sso_return_to_redirect + + return_to: Final = read_return_to_cookie(request) + if not return_to: + return None + try: + resumed: Final = await sso_return_to_redirect( + return_to=return_to, + jwt_token=jwt_token, + redis_usage_cache=redis_usage_cache, + user_api_key_cache=user_api_key_cache, + request=request, + ) + except Exception: # noqa: BLE001 # resuming must NEVER block a completed sign-in + verbose_proxy_logger.info("Ignoring stale litellm_cp_return_to cookie; landing on dashboard") + return None + return None if resumed is None else resumed.headers.get("location") + + @router.post("/v2/login", include_in_schema=False) # hidden helper for UI logins via API async def login_v2(request: Request): global premium_user, general_settings, master_key @@ -18564,14 +18588,21 @@ async def login_v2(request: Request): litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/") litellm_dashboard_ui += "/ui?login=success" + # The deprecated /sso/key/generate form points users at this login page (/ui/login/), so a + # return_to it preserved (e.g. the MCP gateway's /authorize round-trip) has to be honored here + # too, or the native client's loopback callback never gets its code. + redirect_url: Final = await _resume_return_to_url(request=request, jwt_token=jwt_token) or litellm_dashboard_ui + # Token is included in the response body so the UI can set a JS-accessible # cookie even when a reverse proxy (e.g. nginx-ingress) adds HttpOnly to the # server-set cookie, which would otherwise cause an infinite login redirect. json_response: Final = JSONResponse( - content={"redirect_url": litellm_dashboard_ui, "token": jwt_token}, + content={"redirect_url": redirect_url, "token": jwt_token}, status_code=status.HTTP_200_OK, ) set_session_token_cookie(json_response, request, jwt_token) + if "litellm_cp_return_to" in request.cookies: + json_response.delete_cookie(key="litellm_cp_return_to") return json_response except Exception as e: verbose_proxy_logger.exception("litellm.proxy.proxy_server.login_v2(): Exception occurred - %s", e) diff --git a/tests/unit/proxy/proxy_server/test_routes_login_sso.py b/tests/unit/proxy/proxy_server/test_routes_login_sso.py index 43629b926ad..8cf1335e336 100644 --- a/tests/unit/proxy/proxy_server/test_routes_login_sso.py +++ b/tests/unit/proxy/proxy_server/test_routes_login_sso.py @@ -499,6 +499,69 @@ def test_login_form_ignores_open_redirect_return_to(client, monkeypatch): assert "/ui" in location # dashboard fallback +def test_login_v2_honors_same_origin_return_to_cookie(client, monkeypatch): + """/sso/key/generate's deprecation banner sends password users to /ui/login/, which signs in via + /v2/login. A return_to the banner page preserved (the MCP gateway /authorize round-trip) must come + back as redirect_url, or the native client's loopback callback never gets its code (#48090).""" + _install_login_mocks(monkeypatch) + return_to = "/authorize?response_type=code&client_id=llm_dcrc_abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2F" + response = client.post( + "/v2/login", + json={"username": "admin", "password": "password"}, + cookies={"litellm_cp_return_to": _sealed_return_to(return_to)}, + ) + set_cookie = response.headers.get("set-cookie", "") + assert response.status_code == 200 + assert response.json()["redirect_url"] == return_to + assert "token=" in set_cookie + assert 'litellm_cp_return_to=""' in set_cookie # one-shot: cleared after use + + +def test_login_v2_honors_control_plane_return_to_cookie(client, monkeypatch): + """/v2/login resumes through the same resumer as /login, so a control-plane return_to gets the + one-time login code instead of the dashboard.""" + import litellm.proxy.proxy_server as ps + + _install_login_mocks(monkeypatch) + monkeypatch.setitem(ps.general_settings, "control_plane_url", "https://cp.example.com") + response = client.post( + "/v2/login", + json={"username": "admin", "password": "password"}, + cookies={"litellm_cp_return_to": _sealed_return_to("https://cp.example.com/console")}, + ) + redirect_url = response.json()["redirect_url"] + assert response.status_code == 200 + assert redirect_url.startswith("https://cp.example.com/console") + assert "code=" in redirect_url and "login=success" in redirect_url + + +@pytest.mark.parametrize( + "cookie", + [ + pytest.param("/authorize?client_id=llm_dcrc_abc", id="plaintext-not-sealed-here"), + pytest.param("sealed:https://evil.example.com/steal", id="open-redirect"), + pytest.param("sealed:https://old-cp.example.com/console", id="stale-control-plane"), + ], +) +def test_login_v2_falls_back_to_dashboard_for_unusable_return_to(client, monkeypatch, cookie): + """A return_to the resumer won't honor never fails the sign-in: /v2/login lands on the dashboard + and clears the cookie.""" + import litellm.proxy.proxy_server as ps + + _install_login_mocks(monkeypatch) + monkeypatch.setitem(ps.general_settings, "control_plane_url", "https://new-cp.example.com") + if cookie.startswith("sealed:"): + cookie = _sealed_return_to(cookie.removeprefix("sealed:")) + response = client.post( + "/v2/login", + json={"username": "admin", "password": "password"}, + cookies={"litellm_cp_return_to": cookie}, + ) + assert response.status_code == 200 + assert response.json()["redirect_url"].endswith("/ui?login=success") + assert 'litellm_cp_return_to=""' in response.headers.get("set-cookie", "") + + # --------------------------------------------------------------------------- # Failed-login accounting across the login routes (LIT-5285) # ---------------------------------------------------------------------------