diff --git a/backend/chainlit/auth/cookie.py b/backend/chainlit/auth/cookie.py index 1cb4cc5ace..23fd045c6f 100644 --- a/backend/chainlit/auth/cookie.py +++ b/backend/chainlit/auth/cookie.py @@ -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( @@ -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. + + 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 + ) + + class OAuth2PasswordBearerWithCookie(SecurityBase): """ OAuth2 password flow with cookie support with fallback to bearer token. @@ -132,11 +148,13 @@ def set_auth_cookie(request: Request, response: Response, token: str): response.set_cookie( key=k, value=chunk, + path=_cookie_path, httponly=True, secure=_cookie_secure, samesite=_cookie_samesite, max_age=config.project.user_session_timeout, ) + _delete_legacy_cookies(response, k) existing_cookies.discard(k) else: @@ -144,11 +162,13 @@ def set_auth_cookie(request: Request, response: Response, token: str): 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) @@ -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): @@ -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): @@ -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) diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index 6592af08fd..5ef8e4ce0a 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -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, @@ -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"} diff --git a/backend/tests/auth/test_cookie.py b/backend/tests/auth/test_cookie.py index 5f5c3848a5..8ac42aed71 100644 --- a/backend/tests/auth/test_cookie.py +++ b/backend/tests/auth/test_cookie.py @@ -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