Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions backend/chainlit/auth/cookie.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
)
_cookie_secure = _cookie_samesite == "none"
if _cookie_root_path := os.environ.get("CHAINLIT_ROOT_PATH", None):
_cookie_path = os.environ.get(_cookie_root_path, "/")
_cookie_path = os.environ.get("CHAINLIT_AUTH_COOKIE_PATH", _cookie_root_path)
else:
_cookie_path = os.environ.get("CHAINLIT_AUTH_COOKIE_PATH", "/")
_state_cookie_lifetime = int(
Expand All @@ -34,6 +34,22 @@
_state_cookie_name = "oauth_state"


def _delete_legacy_cookies(response: Response, *names: str):
"""Delete cookies at path='/' left over from pre-scoped versions.

Only acts when _cookie_path != '/' to avoid no-op deletes in
single-app deployments.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would suggest adding a comment here explaining why it was introduced and a suggestion when it may be removed (e.g. next major release).


TODO: Remove this function in the next major release.
"""
if _cookie_path == "/":
return
for name in names:
response.delete_cookie(
key=name, path="/", secure=_cookie_secure, samesite=_cookie_samesite
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 5, 2026

Choose a reason for hiding this comment

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

P2: Unconditional root-path legacy cookie deletion can remove same-named cookies from other apps on the same host, causing cross-app session/logout disruption in multi-app deployments.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/chainlit/auth/cookie.py, line 47:

<comment>Unconditional root-path legacy cookie deletion can remove same-named cookies from other apps on the same host, causing cross-app session/logout disruption in multi-app deployments.</comment>

<file context>
@@ -34,6 +34,20 @@
+        return
+    for name in names:
+        response.delete_cookie(
+            key=name, path="/", secure=_cookie_secure, samesite=_cookie_samesite
+        )
+
</file context>
Fix with Cubic

)


class OAuth2PasswordBearerWithCookie(SecurityBase):
"""
OAuth2 password flow with cookie support with fallback to bearer token.
Expand Down Expand Up @@ -132,23 +148,27 @@ def set_auth_cookie(request: Request, response: Response, token: str):
response.set_cookie(
key=k,
value=chunk,
path=_cookie_path,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
httponly=True,
secure=_cookie_secure,
samesite=_cookie_samesite,
max_age=config.project.user_session_timeout,
)
_delete_legacy_cookies(response, k)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

existing_cookies.discard(k)
else:
# Default (shorter cookies)
response.set_cookie(
key=_auth_cookie_name,
value=token,
path=_cookie_path,
httponly=True,
secure=_cookie_secure,
samesite=_cookie_samesite,
max_age=config.project.user_session_timeout,
)
_delete_legacy_cookies(response, _auth_cookie_name)

existing_cookies.discard(_auth_cookie_name)

Expand All @@ -157,6 +177,7 @@ def set_auth_cookie(request: Request, response: Response, token: str):
response.delete_cookie(
key=k, path=_cookie_path, secure=_cookie_secure, samesite=_cookie_samesite
)
_delete_legacy_cookies(response, k)


def clear_auth_cookie(request: Request, response: Response):
Expand All @@ -172,17 +193,20 @@ def clear_auth_cookie(request: Request, response: Response):
response.delete_cookie(
key=k, path=_cookie_path, secure=_cookie_secure, samesite=_cookie_samesite
)
_delete_legacy_cookies(response, k)


def set_oauth_state_cookie(response: Response, token: str):
response.set_cookie(
_state_cookie_name,
token,
path=_cookie_path,
httponly=True,
samesite=_cookie_samesite,
secure=_cookie_secure,
max_age=_state_cookie_lifetime,
)
_delete_legacy_cookies(response, _state_cookie_name)


def validate_oauth_state_cookie(request: Request, state: str):
Expand All @@ -196,4 +220,5 @@ def validate_oauth_state_cookie(request: Request, state: str):

def clear_oauth_state_cookie(response: Response):
"""Oauth complete, delete state token."""
response.delete_cookie(_state_cookie_name) # Do we set path here?
response.delete_cookie(_state_cookie_name, path=_cookie_path)
_delete_legacy_cookies(response, _state_cookie_name)
5 changes: 4 additions & 1 deletion backend/chainlit/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@

from chainlit.auth import create_jwt, decode_jwt, get_configuration, get_current_user
from chainlit.auth.cookie import (
_cookie_path,
_delete_legacy_cookies,
clear_auth_cookie,
clear_oauth_state_cookie,
set_auth_cookie,
Expand Down Expand Up @@ -771,11 +773,12 @@ async def set_session_cookie(request: Request, response: Response):
response.set_cookie(
key="X-Chainlit-Session-id",
value=session_id,
path="/",
path=_cookie_path,
httponly=True,
secure=not is_local,
samesite="lax" if is_local else "none",
)
_delete_legacy_cookies(response, "X-Chainlit-Session-id")

return {"message": "Session cookie set"}

Expand Down
51 changes: 51 additions & 0 deletions backend/tests/auth/test_cookie.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,57 @@ def test_state_cookie_lifetime_custom(monkeypatch):
assert cookie_module._state_cookie_lifetime == 600


def test_cookie_path_defaults_to_root(monkeypatch):
"""When neither CHAINLIT_ROOT_PATH nor CHAINLIT_AUTH_COOKIE_PATH is set, _cookie_path defaults to '/'."""
monkeypatch.delenv("CHAINLIT_ROOT_PATH", raising=False)
monkeypatch.delenv("CHAINLIT_AUTH_COOKIE_PATH", raising=False)
importlib.reload(cookie_module)
assert cookie_module._cookie_path == "/"


def test_cookie_path_uses_root_path(monkeypatch):
"""When CHAINLIT_ROOT_PATH is set, _cookie_path uses its value."""
monkeypatch.setenv("CHAINLIT_ROOT_PATH", "/app1")
monkeypatch.delenv("CHAINLIT_AUTH_COOKIE_PATH", raising=False)
importlib.reload(cookie_module)
assert cookie_module._cookie_path == "/app1"


def test_cookie_path_explicit_overrides_root_path(monkeypatch):
"""CHAINLIT_AUTH_COOKIE_PATH takes precedence over CHAINLIT_ROOT_PATH."""
monkeypatch.setenv("CHAINLIT_ROOT_PATH", "/app1")
monkeypatch.setenv("CHAINLIT_AUTH_COOKIE_PATH", "/custom")
importlib.reload(cookie_module)
assert cookie_module._cookie_path == "/custom"


def test_delete_legacy_cookies_skips_when_path_is_root(monkeypatch):
"""_delete_legacy_cookies is a no-op when _cookie_path == '/'."""
monkeypatch.delenv("CHAINLIT_ROOT_PATH", raising=False)
monkeypatch.delenv("CHAINLIT_AUTH_COOKIE_PATH", raising=False)
importlib.reload(cookie_module)

response = Response()
cookie_module._delete_legacy_cookies(response, "access_token")
# Response should have no Set-Cookie headers (no delete issued)
assert "set-cookie" not in response.headers


def test_delete_legacy_cookies_deletes_at_root_when_scoped(monkeypatch):
"""_delete_legacy_cookies issues a delete at path='/' when _cookie_path != '/'."""
monkeypatch.setenv("CHAINLIT_ROOT_PATH", "/app1")
monkeypatch.delenv("CHAINLIT_AUTH_COOKIE_PATH", raising=False)
importlib.reload(cookie_module)

response = Response()
cookie_module._delete_legacy_cookies(response, "access_token")
set_cookie_header = response.headers.get("set-cookie", "")
assert "access_token" in set_cookie_header
parts = [part.strip() for part in set_cookie_header.split(";")]
assert "Path=/" in parts
assert 'Max-Age=0' in set_cookie_header


def test_clear_auth_cookie(client):
"""Test cookie clearing removes all chunks."""
# Set initial token
Expand Down