Skip to content

chore(sso): bind generic SSO state to a session cookie - #26944

Merged
yuneng-berri merged 2 commits into
BerriAI:litellm_internal_stagingfrom
stuxf:fix/sso-state-cookie-binding
May 2, 2026
Merged

chore(sso): bind generic SSO state to a session cookie#26944
yuneng-berri merged 2 commits into
BerriAI:litellm_internal_stagingfrom
stuxf:fix/sso-state-cookie-binding

Conversation

@stuxf

@stuxf stuxf commented May 1, 2026

Copy link
Copy Markdown
Collaborator

Relevant issues

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have Added testing in the `tests/test_litellm/` directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on `make test-unit`
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting `@greptileai` and received a Confidence Score of at least 4/5 before requesting a maintainer review

Type

🐛 Bug Fix

Changes

The Generic SSO PKCE flow used the URL `state` parameter as the cache key for the PKCE `code_verifier` without binding that state to the caller's browser. An attacker who pre-minted a state and cached a verifier under it could hand the resulting login link to a victim; the victim's auth code would then be exchanged with the attacker's verifier on the callback, producing an access token under the attacker's control (Login CSRF / token theft).

The non-PKCE branch is unaffected because it delegates to `fastapi-sso`'s `verify_and_process`, which performs its own session-cookie check. The PKCE branch bypasses that helper — exactly the gap this PR closes.

Two-part fix in `litellm/proxy/management_endpoints/ui_sso.py`:

  • Redirect side — `get_generic_sso_redirect_response` now sets a `litellm_oauth_state` cookie (HttpOnly, SameSite=Lax, 10-min TTL) carrying the state value used in the redirect URL. The cookie is set on the redirect response just like the existing `litellm_cp_return_to` cookie a few lines earlier in the file.
  • Callback side — `get_generic_sso_response` validates `request.cookies.get("litellm_oauth_state")` against `request.query_params.get("state")` via `secrets.compare_digest` before invoking the PKCE token exchange. Mismatch (or either being missing) raises a `ProxyException` with HTTP 400.

The pre-existing TODO above the redirect logic ("state should be a random string and added to the user session with cookie or a cryptographicly signed state that we can verify stateless") is now addressed and removed.

Tests

`tests/test_litellm/proxy/management_endpoints/test_ui_sso.py` adds a `TestPKCEStateCookieBinding` class covering:

  • The redirect response carries a `litellm_oauth_state` cookie with the same state value used in the redirect URL, marked HttpOnly + SameSite=Lax.
  • A PKCE callback whose request has no `litellm_oauth_state` cookie is rejected with a 400 `ProxyException`.
  • A PKCE callback where the URL state and cookie state mismatch (the Login-CSRF shape — attacker-minted state vs. victim's browser cookie) is rejected.
  • A PKCE callback where URL state and cookie state match proceeds past the new check and produces an SSO result (no regression on the legitimate flow).

180/180 tests in `test_ui_sso.py` pass.

The Generic SSO PKCE flow used the URL ``state`` parameter as the
cache key for the PKCE ``code_verifier`` without binding the state
to the caller's browser.  An attacker who pre-minted a state and
cached a verifier under it could hand the resulting login link to a
victim; the victim's auth code would then be exchanged with the
attacker's verifier on the callback, producing an access token
under the attacker's control (Login CSRF / token theft).

The non-PKCE branch is unaffected because it delegates to
fastapi-sso's ``verify_and_process``, which performs its own
session-cookie check.  The PKCE branch bypasses that helper, which
is exactly the gap this commit closes.

Two-part fix in ``ui_sso.py``:

- ``get_generic_sso_redirect_response`` now sets a
  ``litellm_oauth_state`` cookie (HttpOnly, SameSite=Lax, 10-min TTL)
  carrying the state value used in the redirect URL.  The cookie is
  set on the redirect response just like the existing
  ``litellm_cp_return_to`` cookie a few lines earlier in the file.
- ``get_generic_sso_response`` validates ``request.cookies.get(
  "litellm_oauth_state")`` against ``request.query_params.get(
  "state")`` via ``secrets.compare_digest`` before invoking the
  PKCE token exchange.  Mismatch (or either being missing) raises a
  ``ProxyException`` with HTTP 400.

The pre-existing TODO above the redirect logic ("state should be a
random string and added to the user session with cookie") is now
addressed and removed.

Tests cover the redirect-side cookie set, the missing-cookie reject
shape, the URL/cookie-mismatch reject shape, and the matching-cookie
happy path.
@codecov

codecov Bot commented May 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR closes a Login-CSRF / token-theft vulnerability in the Generic SSO PKCE flow by binding the OAuth state parameter to a browser session cookie (litellm_oauth_state). On the redirect side, a HttpOnly, SameSite=Lax cookie carrying the state is set inside the existing PKCE branch; on the callback side, the URL state is compared against the cookie via secrets.compare_digest before the PKCE token exchange is performed. The implementation is well-scoped, defaults the Secure flag to true for unknown request schemes, correctly skips the cookie for non-PKCE flows, and is covered by targeted tests with no real network calls.

Confidence Score: 5/5

Safe to merge — the security fix is correctly implemented with no regressions or new issues.

No P0 or P1 issues found. The cookie is scoped strictly to the PKCE branch (both write and read sides), secrets.compare_digest prevents timing attacks, Secure defaults to true with an explicit HTTP-dev escape hatch, and the request parameter is threaded through every call site. Both previously flagged concerns (non-PKCE cookie scope and the missing Secure flag) are addressed in this PR. Test coverage is solid and mock-only.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/ui_sso.py Security fix: adds litellm_oauth_state HttpOnly cookie (PKCE flows only) on the redirect side and validates it via secrets.compare_digest on the callback side; correctly scoped to the PKCE branch, defaults Secure=True when scheme is unknown, and threads request through all callers.
tests/test_litellm/proxy/management_endpoints/test_ui_sso.py Adds TestPKCEStateCookieBinding covering cookie presence, HttpOnly/SameSite/Secure attributes, non-PKCE omission, HTTP dev mode (no Secure), missing-cookie rejection, state-mismatch rejection, and happy-path acceptance; all tests use mocks with no real network calls.

Reviews (2): Last reviewed commit: "fix(sso): tighten oauth_state cookie — S..." | Re-trigger Greptile

Comment thread litellm/proxy/management_endpoints/ui_sso.py Outdated
Comment thread litellm/proxy/management_endpoints/ui_sso.py Outdated
Two Greptile review findings addressed:

1. (P1, security) The ``litellm_oauth_state`` cookie is the sole
   guard against Login-CSRF in the PKCE flow but was set without the
   ``Secure`` attribute, so a network observer on plain HTTP could
   read and replay it — bypassing the protection this PR adds.

   Thread the originating ``Request`` down through
   ``get_sso_login_redirect`` and ``get_generic_sso_redirect_response``
   and set ``Secure`` based on ``request.url.scheme == "https"``.
   When no request is supplied (programmatic callers / tests) default
   to ``Secure=True`` — production-safe.  Local HTTP dev still works
   because the request scheme is observed at runtime.

2. (P2) The cookie was set unconditionally, but the callback only
   validates it inside the PKCE branch.  Two concurrent SSO sessions
   (one PKCE, one plain) could overwrite each other's state cookie
   and produce spurious 400s for the plain-flow user.

   Move the ``set_cookie`` call inside the existing
   ``if code_verifier and "state" in redirect_params`` block so the
   cookie is only written when PKCE is active and the validation
   will actually fire.

Tests cover both paths: PKCE-on (cookie set with Secure default),
PKCE-off (cookie not set), and HTTP dev request (Secure dropped so
the browser will actually attach the cookie on the callback hop).
@stuxf

stuxf commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

@greptileai please re-review — addressed both findings in 2c852ba:

  • P1 (security): missing Secure flag — threaded the originating Request down through get_sso_login_redirect and get_generic_sso_redirect_response. The cookie now sets Secure=True whenever request.url.scheme == 'https'; when no request is supplied (programmatic callers / tests) the default is also Secure=True for production safety. Local HTTP dev still works because the request scheme is observed at runtime.

  • P2: cookie set unconditionally — moved the set_cookie call inside the existing if code_verifier and 'state' in redirect_params block. The cookie is now only written when PKCE is active (i.e. when the callback will actually validate it), so two concurrent SSO sessions can no longer overwrite each other's state cookie.

Tests added for all three branches: PKCE-on (cookie set + Secure default), PKCE-off (cookie not set), HTTP dev (Secure dropped). 6/6 TestPKCEStateCookieBinding cases pass.

@yuneng-berri
yuneng-berri enabled auto-merge May 2, 2026 01:51
@yuneng-berri
yuneng-berri merged commit 0ff9d65 into BerriAI:litellm_internal_staging May 2, 2026
43 of 44 checks passed
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
chore(sso): bind generic SSO state to a session cookie
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