From cefc8d97ee8a44cfc922ee28b514022b442a76c5 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Fri, 13 Dec 2024 19:55:50 +0000 Subject: [PATCH] Cookie-based auth (#1521) * Add single lint command for convenience. * Allow and document passing arbitrary options to Cypress. * Cleanup of auth module. * Organise imports and formatting. * Log uncaught exceptions in data layer. * Typing cleanup and explicit assertions. * Move auth to module. * Factor out JWT functionality. * Remove deprecated ruff rule. * Bump ruff and use consistent version in CI. * Backend implementation of cookie auth. * Factor out redundant maybe user creating. * Factor out common user param. * Add a get_user() route to allow access to user data. * Import sorting. * Make using cookies the default. * Refuse to serve files unless we are using cookie auth. * Only redirect to `/login` once auth config is available. Prevents redundant redirect. * Expose cookie auth config to frontend. * Refactor of frontend auth, implement cookie authentication. * Header and password auth support. * Consistent typing for useAuth(). * Comment to document useAPI() hook. * Factor out auth types, config, state, settings, session and token management. * Determine auth state based on whether user is set. * Make auth a core component module. * Check `/login/` response for expected output. * Request `/user` when cookieAuth's available. * Use `user` instead of `access_token` to determine whether we're logged in. * Improved API error handling in frontend. * Don't assume errors are always JSON with .detail * Add status to ClientError. * Factor out request error handling. * Consistent handling of errors: either call on401() or throw, never both. * Disable/override on4041 and onError hooks for useApi(). Requests not initiated by the user should not cause user-facing notifications. Plus, 401 on `/user` is now expected behaviour. * Move onErrorRetry logic to useAPI(), prevents infinite recursion. * Cleanup, improvement and cookie auth tests in password_* tests. * Separate assertions and state preparation. * Check for call on /user for cookie auth. * Test for presence of cookie in login reply. * Re-enable user validation for `get_file()`. Closes #1101. --------- Co-authored-by: Willy Douhard --- .github/CONTRIBUTING.md | 10 + .github/workflows/lint-backend.yaml | 1 + .../chainlit/{auth.py => auth/__init__.py} | 50 ++-- backend/chainlit/auth/cookie.py | 124 ++++++++ backend/chainlit/auth/jwt.py | 37 +++ backend/chainlit/cli/__init__.py | 15 +- backend/chainlit/config.py | 5 + backend/chainlit/server.py | 268 +++++++++--------- backend/chainlit/socket.py | 96 +++++-- backend/poetry.lock | 40 +-- backend/pyproject.toml | 4 +- cypress/e2e/header_auth/spec.cy.ts | 66 ++++- cypress/e2e/password_auth/spec.cy.ts | 102 ++++++- cypress/e2e/readme/main.py | 5 + cypress/support/utils.ts | 4 +- .../components/molecules/auth/AuthLogin.tsx | 6 - .../components/organisms/sidebar/index.tsx | 2 +- frontend/src/pages/AuthCallback.tsx | 15 +- frontend/src/pages/Login.tsx | 56 +++- frontend/src/pages/Page.tsx | 5 +- libs/react-client/src/api/hooks/api.ts | 59 +++- libs/react-client/src/api/hooks/auth.ts | 95 ------- libs/react-client/src/api/hooks/auth/index.ts | 47 +++ libs/react-client/src/api/index.tsx | 79 ++++-- libs/react-client/src/auth/config.ts | 29 ++ libs/react-client/src/auth/session.ts | 35 +++ libs/react-client/src/auth/token.ts | 77 +++++ libs/react-client/src/auth/types.ts | 22 ++ libs/react-client/src/auth/user.ts | 46 +++ libs/react-client/src/types/config.ts | 1 + libs/react-client/src/utils/token.ts | 27 +- package.json | 1 + 32 files changed, 1017 insertions(+), 412 deletions(-) rename backend/chainlit/{auth.py => auth/__init__.py} (67%) create mode 100644 backend/chainlit/auth/cookie.py create mode 100644 backend/chainlit/auth/jwt.py delete mode 100644 libs/react-client/src/api/hooks/auth.ts create mode 100644 libs/react-client/src/api/hooks/auth/index.ts create mode 100644 libs/react-client/src/auth/config.ts create mode 100644 libs/react-client/src/auth/session.ts create mode 100644 libs/react-client/src/auth/token.ts create mode 100644 libs/react-client/src/auth/types.ts create mode 100644 libs/react-client/src/auth/user.ts diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0fcf859fc0..1532038177 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -133,3 +133,13 @@ Make sure to run `poetry install` again whenever you've updated the frontend! 1. Find the folder containing the e2e test that you're looking for in `cypress/e2e`. 2. Run `SINGLE_TEST=FOLDER pnpm test` and change FOLDER with the folder from the previous step (example: `SINGLE_TEST=scoped_elements pnpm run test`). + +### Headed/debugging + +Causes the Electron browser to be shown on screen and keeps it open after tests are done. +Extremely useful for debugging! + +```sh +SINGLE_TEST=password_auth CYPRESS_OPTIONS='--headed --no-exit' pnpm test +``` + diff --git a/.github/workflows/lint-backend.yaml b/.github/workflows/lint-backend.yaml index ed4d81b5fb..539eea7871 100644 --- a/.github/workflows/lint-backend.yaml +++ b/.github/workflows/lint-backend.yaml @@ -19,6 +19,7 @@ jobs: - name: Lint with ruff uses: astral-sh/ruff-action@v1 with: + version: '0.8.0' src: ${{ env.BACKEND_DIR }} changed-files: "true" - name: Check formatting with ruff diff --git a/backend/chainlit/auth.py b/backend/chainlit/auth/__init__.py similarity index 67% rename from backend/chainlit/auth.py rename to backend/chainlit/auth/__init__.py index f45bb53b49..bae68f0d11 100644 --- a/backend/chainlit/auth.py +++ b/backend/chainlit/auth/__init__.py @@ -1,21 +1,16 @@ import os -from datetime import datetime, timedelta -from typing import Any, Dict -import jwt from fastapi import Depends, HTTPException -from fastapi.security import OAuth2PasswordBearer from chainlit.config import config from chainlit.data import get_data_layer +from chainlit.logger import logger from chainlit.oauth_providers import get_configured_oauth_providers -from chainlit.user import User -reuseable_oauth = OAuth2PasswordBearer(tokenUrl="/login", auto_error=False) +from .cookie import OAuth2PasswordBearerWithCookie +from .jwt import create_jwt, decode_jwt, get_jwt_secret - -def get_jwt_secret(): - return os.environ.get("CHAINLIT_AUTH_SECRET") +reuseable_oauth = OAuth2PasswordBearerWithCookie(tokenUrl="/login", auto_error=False) def ensure_jwt_secret(): @@ -43,51 +38,39 @@ def get_configuration(): "requireLogin": require_login(), "passwordAuth": config.code.password_auth_callback is not None, "headerAuth": config.code.header_auth_callback is not None, + "cookieAuth": config.project.cookie_auth, "oauthProviders": ( get_configured_oauth_providers() if is_oauth_enabled() else [] ), } -def create_jwt(data: User) -> str: - to_encode: Dict[str, Any] = data.to_dict() - to_encode.update( - { - "exp": datetime.utcnow() - + timedelta(seconds=config.project.user_session_timeout), - } - ) - encoded_jwt = jwt.encode(to_encode, get_jwt_secret(), algorithm="HS256") - return encoded_jwt - - async def authenticate_user(token: str = Depends(reuseable_oauth)): try: - dict = jwt.decode( - token, - get_jwt_secret(), - algorithms=["HS256"], - options={"verify_signature": True}, - ) - del dict["exp"] - user = User(**dict) + user = decode_jwt(token) except Exception as e: raise HTTPException( status_code=401, detail="Invalid authentication token" ) from e + if data_layer := get_data_layer(): + # Get or create persistent user if we've a data layer available. try: persisted_user = await data_layer.get_user(user.identifier) if persisted_user is None: persisted_user = await data_layer.create_user(user) - except Exception: + assert persisted_user + except Exception as e: + logger.exception("Unable to get persisted_user from data layer: %s", e) return user if user and user.display_name: + # Copy ephemeral display_name from authenticated user to persistent user. persisted_user.display_name = user.display_name + return persisted_user - else: - return user + + return user async def get_current_user(token: str = Depends(reuseable_oauth)): @@ -95,3 +78,6 @@ async def get_current_user(token: str = Depends(reuseable_oauth)): return None return await authenticate_user(token) + + +__all__ = ["create_jwt", "get_configuration", "get_current_user"] diff --git a/backend/chainlit/auth/cookie.py b/backend/chainlit/auth/cookie.py new file mode 100644 index 0000000000..41ac990e4c --- /dev/null +++ b/backend/chainlit/auth/cookie.py @@ -0,0 +1,124 @@ +import os +from typing import Literal, Optional, cast + +from fastapi import Request, Response +from fastapi.exceptions import HTTPException +from fastapi.security.base import SecurityBase +from fastapi.security.utils import get_authorization_scheme_param +from starlette.status import HTTP_401_UNAUTHORIZED + +""" Module level cookie settings. """ +_cookie_samesite = cast( + Literal["lax", "strict", "none"], + os.environ.get("CHAINLIT_COOKIE_SAMESITE", "lax"), +) + +assert ( + _cookie_samesite + in [ + "lax", + "strict", + "none", + ] +), "Invalid value for CHAINLIT_COOKIE_SAMESITE. Must be one of 'lax', 'strict' or 'none'." +_cookie_secure = _cookie_samesite == "none" + +_auth_cookie_lifetime = 60 * 60 # 1 hour +_state_cookie_lifetime = 3 * 60 # 3m +_auth_cookie_name = "access_token" +_state_cookie_name = "oauth_state" + + +class OAuth2PasswordBearerWithCookie(SecurityBase): + """ + OAuth2 password flow with cookie support with fallback to bearer token. + """ + + def __init__( + self, + tokenUrl: str, + scheme_name: Optional[str] = None, + auto_error: bool = True, + ): + self.tokenUrl = tokenUrl + self.scheme_name = scheme_name or self.__class__.__name__ + self.auto_error = auto_error + + async def __call__(self, request: Request) -> Optional[str]: + # First try to get the token from the cookie + token = request.cookies.get(_auth_cookie_name) + + # If no cookie, try the Authorization header as fallback + if not token: + # TODO: Only bother to check if cookie auth is explicitly disabled. + authorization = request.headers.get("Authorization") + if authorization: + scheme, token = get_authorization_scheme_param(authorization) + if scheme.lower() != "bearer": + if self.auto_error: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + return None + else: + if self.auto_error: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + return None + + return token + + +def set_auth_cookie(response: Response, token: str): + """ + Helper function to set the authentication cookie with secure parameters + """ + + response.set_cookie( + key=_auth_cookie_name, + value=token, + httponly=True, + secure=_cookie_secure, + samesite=_cookie_samesite, + max_age=_auth_cookie_lifetime, + path="/", # Why is path set here and not below? + ) + + +def clear_auth_cookie(response: Response): + """ + Helper function to clear the authentication cookie + """ + response.delete_cookie(key=_auth_cookie_name, path="/") + + +def set_oauth_state_cookie(response: Response, token: str): + response.set_cookie( + _state_cookie_name, + token, + httponly=True, + samesite=_cookie_samesite, + secure=_cookie_secure, + max_age=_state_cookie_lifetime, + ) + + +def validate_oauth_state_cookie(request: Request, state: str): + """Check the state from the oauth provider against the browser cookie.""" + + oauth_state = request.cookies.get(_state_cookie_name) + + if oauth_state != state: + raise Exception("oauth state does not correspond") + + +def clear_oauth_state_cookie(response: Response): + """Oauth complete, delete state token.""" + response.delete_cookie(_state_cookie_name) # Do we set path here? diff --git a/backend/chainlit/auth/jwt.py b/backend/chainlit/auth/jwt.py new file mode 100644 index 0000000000..79251cfaed --- /dev/null +++ b/backend/chainlit/auth/jwt.py @@ -0,0 +1,37 @@ +import datetime +import os +from typing import Any, Dict, Optional + +import jwt as pyjwt + +from chainlit.config import config +from chainlit.user import User + + +def get_jwt_secret() -> Optional[str]: + return os.environ.get("CHAINLIT_AUTH_SECRET") + + +def create_jwt(data: User) -> str: + to_encode: Dict[str, Any] = data.to_dict() + to_encode.update( + { + "exp": datetime.datetime.utcnow() + + datetime.timedelta(seconds=config.project.user_session_timeout), + } + ) + secret = get_jwt_secret() + assert secret + encoded_jwt = pyjwt.encode(to_encode, secret, algorithm="HS256") + return encoded_jwt + + +def decode_jwt(token: str) -> User: + dict = pyjwt.decode( + token, + get_jwt_secret(), + algorithms=["HS256"], + options={"verify_signature": True}, + ) + del dict["exp"] + return User(**dict) diff --git a/backend/chainlit/cli/__init__.py b/backend/chainlit/cli/__init__.py index 30babb785d..f4671221ce 100644 --- a/backend/chainlit/cli/__init__.py +++ b/backend/chainlit/cli/__init__.py @@ -9,6 +9,7 @@ nest_asyncio.apply() # ruff: noqa: E402 +from chainlit.auth import ensure_jwt_secret from chainlit.cache import init_lc_cache from chainlit.config import ( BACKEND_ROOT, @@ -24,7 +25,18 @@ from chainlit.markdown import init_markdown from chainlit.secret import random_secret from chainlit.telemetry import trace_event -from chainlit.utils import check_file, ensure_jwt_secret +from chainlit.utils import check_file + + +def assert_app(): + if ( + not config.code.on_chat_start + and not config.code.on_message + and not config.code.on_audio_chunk + ): + raise Exception( + "You need to configure at least one of on_chat_start, on_message or on_audio_chunk callback" + ) # Create the main command group for Chainlit CLI @@ -66,6 +78,7 @@ def run_chainlit(target: str): load_module(config.run.module_name) ensure_jwt_secret() + assert_app() # Create the chainlit.md file if it doesn't exist init_markdown(config.root) diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index 18ee6be8db..5a36be2387 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -95,6 +95,9 @@ # Allow users to edit their own messages edit_message = true +# Use httponly cookie for client->server authentication, required to be able to use file upload and elements. +cookie_auth = true + # Authorize users to spontaneously upload files with messages [features.spontaneous_file_upload] enabled = true @@ -327,6 +330,8 @@ class ProjectSettings(DataClassJsonMixin): cache: bool = False # Follow symlink for asset mount (see https://github.com/Chainlit/chainlit/issues/317) follow_symlink: bool = False + # Use httponly cookie for client->server authentication, required to be able to use file upload and elements. + cookie_auth: bool = True @dataclass() diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index 7aeabe5329..99d9e5bf37 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -10,7 +10,7 @@ import webbrowser from contextlib import asynccontextmanager from pathlib import Path -from typing import Any, List, Optional, Union +from typing import List, Optional, Union import socketio from fastapi import ( @@ -34,6 +34,13 @@ from watchfiles import awatch from chainlit.auth import create_jwt, get_configuration, get_current_user +from chainlit.auth.cookie import ( + clear_auth_cookie, + clear_oauth_state_cookie, + set_auth_cookie, + set_oauth_state_cookie, + validate_oauth_state_cookie, +) from chainlit.config import ( APP_ROOT, BACKEND_ROOT, @@ -366,43 +373,109 @@ async def auth(request: Request): return get_configuration() -@router.post("/login") -async def login(form_data: OAuth2PasswordRequestForm = Depends()): - """ - Login a user using the password auth callback. - """ - if not config.code.password_auth_callback: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="No auth_callback defined" +def _get_response_dict(access_token: str) -> dict: + """Get the response dictionary for the auth response.""" + + if not config.project.cookie_auth: + # Legacy auth + return { + "access_token": access_token, + "token_type": "bearer", + } + + return {"success": True} + + +def _get_auth_response(access_token: str, redirect_to_callback: bool) -> Response: + """Get the redirect params for the OAuth callback.""" + + response_dict = _get_response_dict(access_token) + + if redirect_to_callback: + root_path = os.environ.get("CHAINLIT_ROOT_PATH", "") + redirect_url = ( + f"{root_path}/login/callback?{urllib.parse.urlencode(response_dict)}" ) - user = await config.code.password_auth_callback( - form_data.username, form_data.password + return RedirectResponse( + # FIXME: redirect to the right frontend base url to improve the dev environment + url=redirect_url, + status_code=302, + ) + + return JSONResponse(response_dict) + + +def _get_oauth_redirect_error(error: str) -> Response: + """Get the redirect response for an OAuth error.""" + params = urllib.parse.urlencode( + { + "error": error, + } + ) + response = RedirectResponse( + # FIXME: redirect to the right frontend base url to improve the dev environment + url=f"/login?{params}", # Shouldn't there be {root_path} here? ) + return response + + +async def _authenticate_user( + user: Optional[User], redirect_to_callback: bool = False +) -> Response: + """Authenticate a user and return the response.""" if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="credentialssignin", ) - access_token = create_jwt(user) + + # If a data layer is defined, attempt to persist user. if data_layer := get_data_layer(): try: await data_layer.create_user(user) except Exception as e: + # Catch and log exceptions during user creation. + # TODO: Make this catch only specific errors and allow others to propagate. logger.error(f"Error creating user: {e}") - return { - "access_token": access_token, - "token_type": "bearer", - } + access_token = create_jwt(user) + + response = _get_auth_response(access_token, redirect_to_callback) + + if config.project.cookie_auth: + set_auth_cookie(response, access_token) + + return response + + +@router.post("/login") +async def login(response: Response, form_data: OAuth2PasswordRequestForm = Depends()): + """ + Login a user using the password auth callback. + """ + if not config.code.password_auth_callback: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="No auth_callback defined" + ) + + user = await config.code.password_auth_callback( + form_data.username, form_data.password + ) + + return await _authenticate_user(user) @router.post("/logout") async def logout(request: Request, response: Response): """Logout the user by calling the on_logout callback.""" + if config.project.cookie_auth: + clear_auth_cookie(response) + if config.code.on_logout: return await config.code.on_logout(request, response) + return {"success": True} @@ -417,23 +490,7 @@ async def header_auth(request: Request): user = await config.code.header_auth_callback(request.headers) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Unauthorized", - ) - - access_token = create_jwt(user) - if data_layer := get_data_layer(): - try: - await data_layer.create_user(user) - except Exception as e: - logger.error(f"Error creating user: {e}") - - return { - "access_token": access_token, - "token_type": "bearer", - } + return await _authenticate_user(user) @router.get("/auth/oauth/{provider_id}") @@ -465,16 +522,9 @@ async def oauth_login(provider_id: str, request: Request): response = RedirectResponse( url=f"{provider.authorize_url}?{params}", ) - samesite: Any = os.environ.get("CHAINLIT_COOKIE_SAMESITE", "lax") - secure = samesite.lower() == "none" - response.set_cookie( - "oauth_state", - random, - httponly=True, - samesite=samesite, - secure=secure, - max_age=3 * 60, - ) + + set_oauth_state_cookie(response, random) + return response @@ -502,16 +552,7 @@ async def oauth_callback( ) if error: - params = urllib.parse.urlencode( - { - "error": error, - } - ) - response = RedirectResponse( - # FIXME: redirect to the right frontend base url to improve the dev environment - url=f"/login?{params}", - ) - return response + return _get_oauth_redirect_error(error) if not code or not state: raise HTTPException( @@ -519,9 +560,11 @@ async def oauth_callback( detail="Missing code or state", ) - # Check the state from the oauth provider against the browser cookie - oauth_state = request.cookies.get("oauth_state") - if oauth_state != state: + try: + validate_oauth_state_cookie(request, state) + except Exception as e: + logger.exception("Unable to validate oauth state: %1", e) + raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized", @@ -536,34 +579,10 @@ async def oauth_callback( provider_id, token, raw_user_data, default_user ) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Unauthorized", - ) + response = await _authenticate_user(user, redirect_to_callback=True) - access_token = create_jwt(user) + clear_oauth_state_cookie(response) - if data_layer := get_data_layer(): - try: - await data_layer.create_user(user) - except Exception as e: - logger.error(f"Error creating user: {e}") - - params = urllib.parse.urlencode( - { - "access_token": access_token, - "token_type": "bearer", - } - ) - - root_path = os.environ.get("CHAINLIT_ROOT_PATH", "") - - response = RedirectResponse( - # FIXME: redirect to the right frontend base url to improve the dev environment - url=f"{root_path}/login/callback?{params}", - ) - response.delete_cookie("oauth_state") return response @@ -592,16 +611,7 @@ async def oauth_azure_hf_callback( ) if error: - params = urllib.parse.urlencode( - { - "error": error, - } - ) - response = RedirectResponse( - # FIXME: redirect to the right frontend base url to improve the dev environment - url=f"/login?{params}", - ) - return response + return _get_oauth_redirect_error(error) if not code: raise HTTPException( @@ -618,36 +628,20 @@ async def oauth_azure_hf_callback( provider_id, token, raw_user_data, default_user, id_token ) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Unauthorized", - ) + response = await _authenticate_user(user, redirect_to_callback=True) - access_token = create_jwt(user) + clear_oauth_state_cookie(response) - if data_layer := get_data_layer(): - try: - await data_layer.create_user(user) - except Exception as e: - logger.error(f"Error creating user: {e}") + return response - params = urllib.parse.urlencode( - { - "access_token": access_token, - "token_type": "bearer", - } - ) - root_path = os.environ.get("CHAINLIT_ROOT_PATH", "") +GenericUser = Union[User, PersistedUser] +UserParam = Annotated[GenericUser, Depends(get_current_user)] - response = RedirectResponse( - # FIXME: redirect to the right frontend base url to improve the dev environment - url=f"{root_path}/login/callback?{params}", - status_code=302, - ) - response.delete_cookie("oauth_state") - return response + +@router.get("/user") +async def get_user(current_user: UserParam) -> GenericUser: + return current_user _language_pattern = ( @@ -675,7 +669,7 @@ async def project_translations( @router.get("/project/settings") async def project_settings( - current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], + current_user: UserParam, language: str = Query( default="en-US", description="Language code", pattern=_language_pattern ), @@ -726,7 +720,7 @@ async def project_settings( async def update_feedback( request: Request, update: UpdateFeedbackRequest, - current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], + current_user: UserParam, ): """Update the human feedback for a particular message.""" data_layer = get_data_layer() @@ -745,7 +739,7 @@ async def update_feedback( async def delete_feedback( request: Request, payload: DeleteFeedbackRequest, - current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], + current_user: UserParam, ): """Delete a feedback.""" @@ -764,7 +758,7 @@ async def delete_feedback( async def get_user_threads( request: Request, payload: GetThreadsRequest, - current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], + current_user: UserParam, ): """Get the threads page by page.""" @@ -789,7 +783,7 @@ async def get_user_threads( async def get_thread( request: Request, thread_id: str, - current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], + current_user: UserParam, ): """Get a specific thread.""" data_layer = get_data_layer() @@ -808,7 +802,7 @@ async def get_thread_element( request: Request, thread_id: str, element_id: str, - current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], + current_user: UserParam, ): """Get a specific thread element.""" data_layer = get_data_layer() @@ -826,7 +820,7 @@ async def get_thread_element( async def delete_thread( request: Request, payload: DeleteThreadRequest, - current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], + current_user: UserParam, ): """Delete a thread.""" @@ -845,7 +839,7 @@ async def delete_thread( @router.post("/project/file") async def upload_file( - current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], + current_user: UserParam, session_id: str, file: UploadFile, ): @@ -957,10 +951,17 @@ def validate_file_size(file: UploadFile): async def get_file( file_id: str, session_id: str, - # current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], #TODO: Causes 401 error. See https://github.com/Chainlit/chainlit/issues/1472 + current_user: UserParam, ): """Get a file from the session files directory.""" + if not config.project.cookie_auth: + # We cannot make this work safely without cookie auth, so disable it. + raise HTTPException( + status_code=404, + detail="File downloads unavailable.", + ) + from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(session_id) if session_id else None @@ -971,13 +972,12 @@ async def get_file( detail="Unauthorized", ) - # TODO: Causes 401 error. See https://github.com/Chainlit/chainlit/issues/1472 - # if current_user: - # if not session.user or session.user.identifier != current_user.identifier: - # raise HTTPException( - # status_code=401, - # detail="You are not authorized to download files from this session", - # ) + if current_user: + if not session.user or session.user.identifier != current_user.identifier: + raise HTTPException( + status_code=401, + detail="You are not authorized to download files from this session", + ) if file_id in session.files: file = session.files[file_id] @@ -989,7 +989,7 @@ async def get_file( @router.get("/files/{filename:path}") async def serve_file( filename: str, - current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], + current_user: UserParam, ): """Serve a file from the local filesystem.""" diff --git a/backend/chainlit/socket.py b/backend/chainlit/socket.py index 5053262e2f..d0f94125fc 100644 --- a/backend/chainlit/socket.py +++ b/backend/chainlit/socket.py @@ -1,9 +1,12 @@ import asyncio import json import time -from typing import Any, Dict, Literal +from typing import Any, Dict, Literal, Optional, Tuple, Union from urllib.parse import unquote +from starlette.requests import cookie_parser +from typing_extensions import TypeAlias + from chainlit.action import Action from chainlit.auth import get_current_user, require_login from chainlit.chat_context import chat_context @@ -16,8 +19,11 @@ from chainlit.session import WebsocketSession from chainlit.telemetry import trace_event from chainlit.types import InputAudioChunk, InputAudioChunkPayload, MessagePayload +from chainlit.user import PersistedUser, User from chainlit.user_session import user_sessions +WSGIEnvironment: TypeAlias = dict[str, Any] + def restore_existing_session(sid, session_id, emit_fn, emit_call_fn): """Restore a session from the sessionId provided by the client.""" @@ -76,29 +82,57 @@ def load_user_env(user_env): return user_env -@sio.on("connect") +def _get_token_from_auth(auth: dict) -> Optional[str]: + # Not using cookie auth, return token. + token = auth.get("token") + if token: + return token.split(" ")[1] + + return None + + +def _get_token_from_cookie(environ: WSGIEnvironment) -> Optional[str]: + if cookie_header := environ.get("HTTP_COOKIE", None): + cookies = cookie_parser(cookie_header) + return cookies.get("access_token", None) + + return None + + +def _get_token(environ: WSGIEnvironment, auth: dict) -> Optional[str]: + """Take WSGI environ, return access token.""" + + if not config.project.cookie_auth: + return _get_token_from_auth(auth) + + return _get_token_from_cookie(environ) + + +async def _authenticate_connection( + environ, + auth, +) -> Union[Tuple[Union[User, PersistedUser], str], Tuple[None, None]]: + if token := _get_token(environ, auth): + user = await get_current_user(token=token) + if user: + return user, token + + return None, None + + +@sio.on("connect") # pyright: ignore [reportOptionalCall] async def connect(sid, environ, auth): - if ( - not config.code.on_chat_start - and not config.code.on_message - and not config.code.on_audio_chunk - ): - logger.warning( - "You need to configure at least one of on_chat_start, on_message or on_audio_chunk callback" - ) - return False - user = None - token = None - login_required = require_login() - try: - # Check if the authentication is required - if login_required: - token = auth.get("token") - token = token.split(" ")[1] if token else None - user = await get_current_user(token=token) - except Exception: - logger.info("Authentication failed") - return False + user = token = None + + if require_login(): + try: + user, token = await _authenticate_connection(environ, auth) + except Exception as e: + logger.exception("Exception authenticating connection: %s", e) + + if not user: + logger.error("Authentication failed in websocket connect.") + raise ConnectionRefusedError("authentication failed") # Session scoped function to emit to the client def emit_fn(event, data): @@ -141,7 +175,7 @@ def emit_call_fn(event: Literal["ask", "call_fn"], data, timeout): return True -@sio.on("connection_successful") +@sio.on("connection_successful") # pyright: ignore [reportOptionalCall] async def connection_successful(sid): context = init_ws_context(sid) @@ -174,14 +208,14 @@ async def connection_successful(sid): context.session.current_task = task -@sio.on("clear_session") +@sio.on("clear_session") # pyright: ignore [reportOptionalCall] async def clean_session(sid): session = WebsocketSession.get(sid) if session: session.to_clear = True -@sio.on("disconnect") +@sio.on("disconnect") # pyright: ignore [reportOptionalCall] async def disconnect(sid): session = WebsocketSession.get(sid) @@ -215,7 +249,7 @@ async def clear_on_timeout(_sid): asyncio.ensure_future(clear_on_timeout(sid)) -@sio.on("stop") +@sio.on("stop") # pyright: ignore [reportOptionalCall] async def stop(sid): if session := WebsocketSession.get(sid): trace_event("stop_task") @@ -252,7 +286,7 @@ async def process_message(session: WebsocketSession, payload: MessagePayload): await context.emitter.task_end() -@sio.on("edit_message") +@sio.on("edit_message") # pyright: ignore [reportOptionalCall] async def edit_message(sid, payload: MessagePayload): """Handle a message sent by the User.""" session = WebsocketSession.require(sid) @@ -282,7 +316,7 @@ async def edit_message(sid, payload: MessagePayload): await context.emitter.task_end() -@sio.on("client_message") +@sio.on("client_message") # pyright: ignore [reportOptionalCall] async def message(sid, payload: MessagePayload): """Handle a message sent by the User.""" session = WebsocketSession.require(sid) @@ -291,7 +325,7 @@ async def message(sid, payload: MessagePayload): session.current_task = task -@sio.on("window_message") +@sio.on("window_message") # pyright: ignore [reportOptionalCall] async def window_message(sid, data): """Handle a message send by the host window.""" session = WebsocketSession.require(sid) @@ -304,7 +338,7 @@ async def window_message(sid, data): pass -@sio.on("audio_start") +@sio.on("audio_start") # pyright: ignore [reportOptionalCall] async def audio_start(sid): """Handle audio init.""" session = WebsocketSession.require(sid) diff --git a/backend/poetry.lock b/backend/poetry.lock index 5ccb1972ae..e89c85c5b5 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -4440,29 +4440,29 @@ files = [ [[package]] name = "ruff" -version = "0.7.4" +version = "0.8.0" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478"}, - {file = "ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63"}, - {file = "ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc"}, - {file = "ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172"}, - {file = "ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a"}, - {file = "ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd"}, - {file = "ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a"}, - {file = "ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac"}, - {file = "ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6"}, - {file = "ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f"}, - {file = "ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2"}, + {file = "ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"}, + {file = "ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b"}, + {file = "ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426"}, + {file = "ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468"}, + {file = "ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f"}, + {file = "ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6"}, + {file = "ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44"}, ] [[package]] @@ -5722,4 +5722,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "2964359572fb5dc66ff4428b72b89b9154b71f8192a4783887c1073faee7085e" +content-hash = "144a8004c7c06fadece32bf1e1e481265aabd195d5a942edce19b15392e1844f" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 18f7db5b1d..aedd569552 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -87,7 +87,7 @@ pandas = "^2.2.2" moto = "^5.0.14" [tool.poetry.group.dev.dependencies] -ruff = "^0.7.1" +ruff = "^0.8.0" [tool.poetry.group.mypy] optional = true @@ -102,6 +102,7 @@ pandas-stubs = { version = "^2.2.2", python = ">=3.9" } [tool.mypy] python_version = "3.9" + [[tool.mypy.overrides]] module = [ "boto3.dynamodb.types", @@ -121,6 +122,7 @@ ignore_missing_imports = true + [tool.poetry.group.custom-data] optional = true diff --git a/cypress/e2e/header_auth/spec.cy.ts b/cypress/e2e/header_auth/spec.cy.ts index 097d8be123..2f978e8e57 100644 --- a/cypress/e2e/header_auth/spec.cy.ts +++ b/cypress/e2e/header_auth/spec.cy.ts @@ -5,19 +5,65 @@ describe('Header auth', () => { runTestServer(); }); - it('should fail to auth without custom header', () => { - cy.get('.MuiAlert-message').should('exist'); + beforeEach(() => { + cy.visit('/'); }); - it('should be able to auth with custom header', () => { - cy.intercept('*', (req) => { - req.headers['test-header'] = 'test header value'; + describe('without an authorization header', () => { + it('should display an alert message', () => { + cy.get('.MuiAlert-message').should('exist'); }); - cy.visit('/'); - cy.get('.MuiAlert-message').should('not.exist'); - cy.get('.step').eq(0).should('contain', 'Hello admin'); + }); + + describe('with authorization header set', () => { + beforeEach(() => { + cy.intercept('/auth/header', (req) => { + req.headers['test-header'] = 'test header value'; + req.continue(); + }).as('auth'); + + // Only intercept /user _after_ we're logged in. + cy.wait('@auth').then(() => { + cy.intercept('GET', '/user').as('user'); + }); + }); + + const shouldBeLoggedIn = () => { + it('should have an access_token cookie in /auth/header response', () => { + cy.wait('@auth').then((interception) => { + expect(interception.response.statusCode).to.equal(200); - cy.reload(); - cy.get('.step').eq(0).should('contain', 'Hello admin'); + // Response contains `Authorization` cookie, starting with Bearer + expect(interception.response.headers).to.have.property('set-cookie'); + const cookie = interception.response.headers['set-cookie'][0]; + expect(cookie).to.contain('access_token'); + }); + }); + + it('should not display an alert message', () => { + cy.get('.MuiAlert-message').should('not.exist'); + }); + + it("should display 'Hello admin'", () => { + cy.get('.step').eq(0).should('contain', 'Hello admin'); + }); + }; + + shouldBeLoggedIn(); + + // TODO: passing locally but failing on the CI + // it('should request and have access to /user', () => { + // cy.wait('@user').then((interception) => { + // expect(interception.response.statusCode).to.equal(200); + // }); + // }); + + describe('after reloading', () => { + before(() => { + cy.reload(); + }); + + shouldBeLoggedIn(); + }); }); }); diff --git a/cypress/e2e/password_auth/spec.cy.ts b/cypress/e2e/password_auth/spec.cy.ts index ed2e78d2f0..a49a28a6ce 100644 --- a/cypress/e2e/password_auth/spec.cy.ts +++ b/cypress/e2e/password_auth/spec.cy.ts @@ -5,22 +5,94 @@ describe('Password Auth', () => { runTestServer(); }); - it('should fail to login with wrong credentials', () => { - cy.get("input[name='email']").type('user'); - cy.get("input[name='password']").type('user'); - cy.get("button[type='submit']").click(); - cy.get('.MuiAlert-message').should('exist'); - }); + describe('when unauthenticated', () => { + describe('visiting /', () => { + beforeEach(() => { + cy.intercept('GET', '/user').as('user'); + cy.visit('/'); + }); + + it('should attempt to and not not have permission to access /user', () => { + cy.wait('@user').then((interception) => { + expect(interception.response.statusCode).to.equal(401); + }); + }); + + it('should redirect to login dialog', () => { + cy.location('pathname').should('eq', '/login'); + cy.get("input[name='email']").should('exist'); + cy.get("input[name='password']").should('exist'); + }); + }); + + describe('visiting /login', () => { + beforeEach(() => { + cy.visit('/login'); + }); + + describe('submitting incorrect credentials', () => { + it('should fail to login with wrong credentials', () => { + cy.get("input[name='email']").type('user'); + cy.get("input[name='password']").type('user'); + cy.get("button[type='submit']").click(); + cy.get('body').should('contain', 'Unauthorized'); + }); + }); + + describe('submitting correct credentials', () => { + beforeEach(() => { + cy.get("input[name='email']").type('admin'); + cy.get("input[name='password']").type('admin'); + + cy.intercept('POST', '/login').as('login'); + cy.intercept('GET', '/user').as('user'); + cy.get("button[type='submit']").click(); + }); + + const shouldBeLoggedIn = () => { + it('should have an access_token cookie in /login response', () => { + cy.wait('@login').then((interception) => { + expect(interception.response.statusCode).to.equal(200); + + // Response contains `Authorization` cookie, starting with Bearer + expect(interception.response.headers).to.have.property( + 'set-cookie' + ); + const cookie = interception.response.headers['set-cookie'][0]; + expect(cookie).to.contain('access_token'); + }); + }); + + it('should request and have access to /user', () => { + cy.wait('@user').then((interception) => { + expect(interception.response.statusCode).to.equal(200); + }); + }); + + it('should not be on /login', () => { + cy.location('pathname').should('not.contain', '/login'); + }); + + it('should not contain a login form', () => { + cy.get("input[name='email']").should('not.exist'); + cy.get("input[name='password']").should('not.exist'); + }); + + it('should show "Hello admin"', () => { + cy.get('.step').eq(0).should('contain', 'Hello admin'); + }); + }; + + shouldBeLoggedIn(); - it('should be able to login with correct credentials', () => { - cy.visit('/'); - cy.get("input[name='email']").type('admin'); - cy.get("input[name='password']").type('admin'); - cy.get("button[type='submit']").click(); - cy.get('.step').eq(0).should('contain', 'Hello admin'); + describe('after reloading', () => { + beforeEach(() => { + cy.reload(); + }); - cy.reload(); - cy.get("input[name='email']").should('not.exist'); - cy.get('.step').eq(0).should('contain', 'Hello admin'); + shouldBeLoggedIn(); + }); + }); + }); }); }); diff --git a/cypress/e2e/readme/main.py b/cypress/e2e/readme/main.py index e312e4c9a9..9cda17c0e0 100644 --- a/cypress/e2e/readme/main.py +++ b/cypress/e2e/readme/main.py @@ -1 +1,6 @@ import chainlit as cl # noqa: F401 + + +@cl.on_message +async def on_message(msg): + pass diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index 53c5331855..43f9ca19cf 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -19,7 +19,9 @@ export async function runTests(matchName) { // Recording the cypress run is time consuming. Disabled by default. // const recordOptions = ` --record --key ${process.env.CYPRESS_RECORD_KEY} `; return runCommand( - `pnpm exec cypress run --record false --spec "cypress/e2e/${matchName}/spec.cy.ts"` + `pnpm exec cypress run --record false ${ + process.env.CYPRESS_OPTIONS || '' + } --spec "cypress/e2e/${matchName}/spec.cy.ts"` ); } diff --git a/frontend/src/components/molecules/auth/AuthLogin.tsx b/frontend/src/components/molecules/auth/AuthLogin.tsx index a63fdbb9e7..80a2cea6fd 100644 --- a/frontend/src/components/molecules/auth/AuthLogin.tsx +++ b/frontend/src/components/molecules/auth/AuthLogin.tsx @@ -80,12 +80,6 @@ const AuthLogin = ({ setErrorState(error); }, [error]); - useEffect(() => { - if (!onPasswordSignIn && onOAuthSignIn && providers.length === 1) { - onOAuthSignIn(providers[0], callbackUrl); - } - }, [onPasswordSignIn, onOAuthSignIn, providers]); - const formik = useFormik({ initialValues: { email: '', diff --git a/frontend/src/components/organisms/sidebar/index.tsx b/frontend/src/components/organisms/sidebar/index.tsx index dd5e7d5cba..95342fa22c 100644 --- a/frontend/src/components/organisms/sidebar/index.tsx +++ b/frontend/src/components/organisms/sidebar/index.tsx @@ -26,7 +26,7 @@ const SideBar = () => { const [settings, setSettings] = useRecoilState(settingsState); const { config } = useConfig(); - const enableHistory = !!user.accessToken && !!config?.dataPersistence; + const enableHistory = !!user && !!config?.dataPersistence; useEffect(() => { if (isMobile) { diff --git a/frontend/src/pages/AuthCallback.tsx b/frontend/src/pages/AuthCallback.tsx index 59b953c52a..67700b558d 100644 --- a/frontend/src/pages/AuthCallback.tsx +++ b/frontend/src/pages/AuthCallback.tsx @@ -7,14 +7,23 @@ import { useQuery } from 'hooks/query'; export default function AuthCallback() { const query = useQuery(); - const { user, setAccessToken } = useAuth(); + const { user, setAccessToken, cookieAuth, setUserFromAPI } = useAuth(); const navigate = useNavigate(); + // Get access token from query in cookieless oauth. useEffect(() => { - const token = query.get('access_token'); - setAccessToken(token); + if (!cookieAuth) { + // Get token from query parameters for oauth login. + const token = query.get('access_token'); + if (token) setAccessToken(token); + } }, [query]); + // Fetch user in cookie-based oauth. + useEffect(() => { + if (!user && cookieAuth) setUserFromAPI(); + }, [cookieAuth]); + useEffect(() => { if (user) { navigate('/'); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index b0b2fba706..49671d8b40 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -9,24 +9,61 @@ import { useQuery } from 'hooks/query'; import { ChainlitContext, useAuth } from 'client-types/*'; +export const LoginError = new Error( + 'Error logging in. Please try again later.' +); + export default function Login() { const query = useQuery(); - const { data: config, setAccessToken, user } = useAuth(); + const { + data: config, + setAccessToken, + user, + cookieAuth, + setUserFromAPI + } = useAuth(); const [error, setError] = useState(''); const apiClient = useContext(ChainlitContext); const navigate = useNavigate(); - const handleHeaderAuth = async () => { + const handleTokenAuth = (json: any): void => { + // Handle case where access_token is in JSON reply. + const access_token = json.access_token; + if (access_token) return setAccessToken(access_token); + throw LoginError; + }; + + const handleCookieAuth = (json: any): void => { + if (json?.success != true) throw LoginError; + + // Validate login cookie and get user data. + setUserFromAPI(); + }; + + const handleAuth = async (jsonPromise: Promise, redirectURL: string) => { try { - const json = await apiClient.headerAuth(); - setAccessToken(json.access_token); - navigate('/'); + const json = await jsonPromise; + + if (!cookieAuth) { + handleTokenAuth(json); + } else { + handleCookieAuth(json); + } + + navigate(redirectURL); } catch (error: any) { setError(error.message); } }; + const handleHeaderAuth = async () => { + const jsonPromise = apiClient.headerAuth(); + + // Why does apiClient redirect to '/' but handlePasswordLogin to callbackUrl? + handleAuth(jsonPromise, '/'); + }; + const handlePasswordLogin = async ( email: string, password: string, @@ -36,13 +73,8 @@ export default function Login() { formData.append('username', email); formData.append('password', password); - try { - const json = await apiClient.passwordAuth(formData); - setAccessToken(json.access_token); - navigate(callbackUrl); - } catch (error: any) { - setError(error.message); - } + const jsonPromise = apiClient.passwordAuth(formData); + handleAuth(jsonPromise, callbackUrl); }; useEffect(() => { diff --git a/frontend/src/pages/Page.tsx b/frontend/src/pages/Page.tsx index 6b95766304..5fbc4f8a6a 100644 --- a/frontend/src/pages/Page.tsx +++ b/frontend/src/pages/Page.tsx @@ -18,7 +18,7 @@ type Props = { }; const Page = ({ children }: Props) => { - const { isAuthenticated } = useAuth(); + const { isAuthenticated, isReady } = useAuth(); const { config } = useConfig(); const userEnv = useRecoilValue(userEnvState); const sideViewElement = useRecoilValue(sideViewState); @@ -29,10 +29,11 @@ const Page = ({ children }: Props) => { } } - if (!isAuthenticated) { + if (isReady && !isAuthenticated) { return ; } + // Question: isn't isAuthenticated unreachable here? return ( { + // Shallow clone API client. + // TODO: Move me to core API. + + // Create new client + const newClient = new ChainlitAPI('', 'webapp'); + + // Assign old properties to new client + Object.assign(newClient, client); + + return newClient; +}; + +/** + * React hook for cached API data fetching using SWR (stale-while-revalidate). + * Optimized for GET requests with automatic caching and revalidation. + * + * Key features: + * - Automatic data caching and revalidation + * - Integration with React component lifecycle + * - Loading state management + * - Recoil state integration for global state + * - Memoized fetcher function to prevent unnecessary rerenders + * + * @param path - API endpoint path or null to disable the request + * @param config - Optional SWR configuration and token override + * @returns SWR response object containing: + * - data: The fetched data + * - error: Any error that occurred + * - isValidating: Whether a request is in progress + * - mutate: Function to mutate the cached data + * + * @example + * const { data, error, isValidating } = useApi('/user', { + * token: accessToken + * }); + */ function useApi( path?: string | null, { token, ...swrConfig }: SWRConfiguration & { token?: string } = {} @@ -25,8 +62,24 @@ function useApi( // Memoize the fetcher function to avoid recreating it on every render const memoizedFetcher = useMemo( () => - ([url, token]: [url: string, token: string]) => - fetcher(client, url, token), + ([url, token]: [url: string, token: string]) => { + if (!swrConfig.onErrorRetry) { + swrConfig.onErrorRetry = (...args) => { + const [err] = args; + + // Don't do automatic retry for 401 - it just means we're not logged in (yet). + // TODO: Consider setUser(null) if (user) + if (err.status === 401) return; + + // Fall back to default behavior. + return SWRConfig.defaultValue.onErrorRetry(...args); + }; + } + + const useApiClient = cloneClient(client); + useApiClient.on401 = useApiClient.onError = undefined; + return fetcher(useApiClient, url, token); + }, [client] ); diff --git a/libs/react-client/src/api/hooks/auth.ts b/libs/react-client/src/api/hooks/auth.ts deleted file mode 100644 index e4523df824..0000000000 --- a/libs/react-client/src/api/hooks/auth.ts +++ /dev/null @@ -1,95 +0,0 @@ -import jwt_decode from 'jwt-decode'; -import { useContext, useEffect } from 'react'; -import { useRecoilState, useSetRecoilState } from 'recoil'; -import { ChainlitContext } from 'src/context'; -import { - accessTokenState, - authState, - threadHistoryState, - userState -} from 'src/state'; -import { IAuthConfig, IUser } from 'src/types'; -import { getToken, removeToken, setToken } from 'src/utils/token'; - -import { useApi } from './api'; - -export const useAuth = () => { - const apiClient = useContext(ChainlitContext); - const [authConfig, setAuthConfig] = useRecoilState(authState); - const [user, setUser] = useRecoilState(userState); - const { data, isLoading } = useApi( - authConfig ? null : '/auth/config' - ); - const [accessToken, setAccessToken] = useRecoilState(accessTokenState); - const setThreadHistory = useSetRecoilState(threadHistoryState); - - useEffect(() => { - if (!data) return; - setAuthConfig(data); - }, [data, setAuthConfig]); - - const isReady = !!(!isLoading && authConfig); - - const logout = async (reload = false) => { - await apiClient.logout(); - setUser(null); - removeToken(); - setAccessToken(''); - setThreadHistory(undefined); - if (reload) { - window.location.reload(); - } - }; - - const saveAndSetToken = (token: string | null | undefined) => { - if (!token) { - logout(); - return; - } - try { - const { exp, ...User } = jwt_decode(token) as any; - setToken(token); - setAccessToken(`Bearer ${token}`); - setUser(User as IUser); - } catch (e) { - console.error( - 'Invalid token, clearing token from local storage', - 'error:', - e - ); - logout(); - } - }; - - useEffect(() => { - if (!user && getToken()) { - // Initialize the token from local storage - saveAndSetToken(getToken()); - return; - } - }, []); - - const isAuthenticated = !!accessToken; - - if (authConfig && !authConfig.requireLogin) { - return { - authConfig, - user: null, - isReady, - isAuthenticated: true, - accessToken: '', - logout: () => {}, - setAccessToken: () => {} - }; - } - - return { - data: authConfig, - user: user, - isAuthenticated, - isReady, - accessToken: accessToken, - logout: logout, - setAccessToken: saveAndSetToken - }; -}; diff --git a/libs/react-client/src/api/hooks/auth/index.ts b/libs/react-client/src/api/hooks/auth/index.ts new file mode 100644 index 0000000000..dff5d72e67 --- /dev/null +++ b/libs/react-client/src/api/hooks/auth/index.ts @@ -0,0 +1,47 @@ +import { useRecoilState } from 'recoil'; +import { useAuthConfig } from 'src/auth/config'; +import { useSessionManagement } from 'src/auth/session'; +import { useTokenManagement } from 'src/auth/token'; +import { IUseAuth } from 'src/auth/types'; +import { useUser } from 'src/auth/user'; +import { accessTokenState } from 'src/state'; + +export const useAuth = (): IUseAuth => { + const { authConfig, isLoading, cookieAuth } = useAuthConfig(); + const { logout } = useSessionManagement(); + const { user, setUserFromAPI } = useUser(); + const [accessToken] = useRecoilState(accessTokenState); + + const { handleSetAccessToken } = useTokenManagement(); + + const isReady = !!(!isLoading && authConfig); + + if (authConfig && !authConfig.requireLogin) { + return { + data: authConfig, + user: null, + isReady, + isAuthenticated: true, + accessToken: '', + logout: () => Promise.resolve(), + setAccessToken: () => {}, + setUserFromAPI: () => Promise.resolve(), + cookieAuth + }; + } + + return { + data: authConfig, + user, + isReady, + isAuthenticated: !!user, + accessToken, + logout, + setAccessToken: handleSetAccessToken, + setUserFromAPI, + cookieAuth + }; +}; + +// Re-export types and main hook +export type { IUseAuth }; diff --git a/libs/react-client/src/api/index.tsx b/libs/react-client/src/api/index.tsx index fb42000c58..a11f50ae35 100644 --- a/libs/react-client/src/api/index.tsx +++ b/libs/react-client/src/api/index.tsx @@ -1,5 +1,5 @@ -import { IThread } from 'src/types'; -import { removeToken } from 'src/utils/token'; +import { ensureTokenPrefix, removeToken } from 'src/auth/token'; +import { IThread, IUser } from 'src/types'; import { IFeedback } from 'src/types/feedback'; @@ -22,10 +22,12 @@ export interface IPagination { } export class ClientError extends Error { + status: number; detail?: string; - constructor(message: string, detail?: string) { + constructor(message: string, status: number, detail?: string) { super(message); + this.status = status; this.detail = detail; } @@ -57,15 +59,51 @@ export class APIBase { } } - checkToken(token: string) { - const prefix = 'Bearer '; - if (token.startsWith(prefix)) { - return token; - } else { - return prefix + token; + private async getDetailFromErrorResponse( + res: Response + ): Promise { + try { + const body = await res.json(); + return body?.detail; + } catch (error: any) { + console.error('Unable to parse error response', error); + } + return undefined; + } + + private handleRequestError(error: any) { + if (error instanceof ClientError) { + if (error.status === 401 && this.on401) { + // TODO: Consider whether we should logout() here instead. + removeToken(); + this.on401(); + } + if (this.onError) { + this.onError(error); + } } + console.error(error); } + /** + * Low-level HTTP request handler for direct API interactions. + * Provides full control over HTTP methods, request configuration, and error handling. + * + * Key features: + * - Supports all HTTP methods (GET, POST, PUT, PATCH, DELETE) + * - Handles both FormData and JSON payloads + * - Manages authentication headers and token formatting + * - Custom error handling with ClientError class + * - Support for request cancellation via AbortSignal + * + * @param method - HTTP method to use (GET, POST, etc.) + * @param path - API endpoint path + * @param token - Optional authentication token + * @param data - Optional request payload (FormData or JSON-serializable data) + * @param signal - Optional AbortSignal for request cancellation + * @returns Promise + * @throws ClientError for HTTP errors, including 401 unauthorized + */ async fetch( method: string, path: string, @@ -75,7 +113,7 @@ export class APIBase { ): Promise { try { const headers: { Authorization?: string; 'Content-Type'?: string } = {}; - if (token) headers['Authorization'] = this.checkToken(token); // Assuming token is a bearer token + if (token) headers['Authorization'] = ensureTokenPrefix(token); // Assuming token is a bearer token let body; @@ -94,20 +132,14 @@ export class APIBase { }); if (!res.ok) { - const body = await res.json(); - if (res.status === 401 && this.on401) { - removeToken(); - this.on401(); - } - throw new ClientError(res.statusText, body.detail); + const detail = await this.getDetailFromErrorResponse(res); + + throw new ClientError(res.statusText, res.status, detail); } return res; } catch (error: any) { - if (error instanceof ClientError && this.onError) { - this.onError(error); - } - console.error(error); + this.handleRequestError(error); throw error; } } @@ -149,6 +181,11 @@ export class ChainlitAPI extends APIBase { return res.json(); } + async getUser(accessToken?: string): Promise { + const res = await this.get(`/user`, accessToken); + return res.json(); + } + async logout() { const res = await this.post(`/logout`, {}); return res.json(); @@ -212,7 +249,7 @@ export class ChainlitAPI extends APIBase { ); if (token) { - xhr.setRequestHeader('Authorization', this.checkToken(token)); + xhr.setRequestHeader('Authorization', ensureTokenPrefix(token)); } // Track the progress of the upload diff --git a/libs/react-client/src/auth/config.ts b/libs/react-client/src/auth/config.ts new file mode 100644 index 0000000000..8ccc47e565 --- /dev/null +++ b/libs/react-client/src/auth/config.ts @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import { useRecoilState } from 'recoil'; +import { authState } from 'src/state'; +import { IAuthConfig } from 'src/types'; + +import { useApi } from '../api'; + +export const useAuthConfig = () => { + const [authConfig, setAuthConfig] = useRecoilState(authState); + const { data: authConfigData, isLoading } = useApi( + authConfig ? null : '/auth/config' + ); + + useEffect(() => { + if (authConfigData) { + setAuthConfig(authConfigData); + } + }, [authConfigData, setAuthConfig]); + + // Secure default: only set false if explicitly defined. + const cookieAuth: boolean = authConfig?.cookieAuth !== false; + + return { + authConfig, + isLoading, + setAuthConfig, + cookieAuth + }; +}; diff --git a/libs/react-client/src/auth/session.ts b/libs/react-client/src/auth/session.ts new file mode 100644 index 0000000000..d6e89a961c --- /dev/null +++ b/libs/react-client/src/auth/session.ts @@ -0,0 +1,35 @@ +import { useContext } from 'react'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { ChainlitContext } from 'src/context'; +import { accessTokenState, threadHistoryState, userState } from 'src/state'; + +import { useAuthConfig } from './config'; +import { removeToken } from './token'; + +export const useSessionManagement = () => { + const apiClient = useContext(ChainlitContext); + const [, setUser] = useRecoilState(userState); + const [, setAccessToken] = useRecoilState(accessTokenState); + + const { authConfig } = useAuthConfig(); + const setThreadHistory = useSetRecoilState(threadHistoryState); + + const logout = async (reload = false): Promise => { + await apiClient.logout(); + setUser(null); + setThreadHistory(undefined); + + if (!authConfig?.cookieAuth) { + removeToken(); + setAccessToken(''); + } + + if (reload) { + window.location.reload(); + } + }; + + return { + logout + }; +}; diff --git a/libs/react-client/src/auth/token.ts b/libs/react-client/src/auth/token.ts new file mode 100644 index 0000000000..f1b404ad98 --- /dev/null +++ b/libs/react-client/src/auth/token.ts @@ -0,0 +1,77 @@ +import jwt_decode from 'jwt-decode'; +import { useRecoilState } from 'recoil'; +import { accessTokenState, userState } from 'src/state'; + +import { useSessionManagement } from './session'; +import { JWTPayload } from './types'; + +const tokenKey = 'token'; + +export function getToken(): string | null | undefined { + try { + return localStorage.getItem(tokenKey); + } catch (_) { + return; + } +} + +export function setToken(token: string): void { + try { + return localStorage.setItem(tokenKey, token); + } catch (_) { + return; + } +} + +export function removeToken(): void { + try { + return localStorage.removeItem(tokenKey); + } catch (_) { + return; + } +} + +export function ensureTokenPrefix(token: string): string { + const prefix = 'Bearer '; + if (token.startsWith(prefix)) { + return token; + } else { + return prefix + token; + } +} + +export const useTokenManagement = () => { + const [, setUser] = useRecoilState(userState); + const [, setAccessToken] = useRecoilState(accessTokenState); + + const { logout } = useSessionManagement(); + + const processToken = (token: string): void => { + try { + const { exp, ...userInfo } = jwt_decode(token); + setToken(token); + setAccessToken(`Bearer ${token}`); + setUser(userInfo); + } catch (error) { + console.error('Invalid token:', error); + throw new Error('Invalid token format'); + } + }; + + const handleSetAccessToken = (token: string | null | undefined): void => { + if (!token) { + logout(); + return; + } + + try { + processToken(token); + } catch { + logout(); + } + }; + + return { + handleSetAccessToken + }; +}; diff --git a/libs/react-client/src/auth/types.ts b/libs/react-client/src/auth/types.ts new file mode 100644 index 0000000000..ef7ad38c5a --- /dev/null +++ b/libs/react-client/src/auth/types.ts @@ -0,0 +1,22 @@ +import { IAuthConfig, IUser } from 'src/types'; + +export interface JWTPayload extends IUser { + exp: number; +} + +export interface AuthState { + data: IAuthConfig | undefined; + user: IUser | null; + isAuthenticated: boolean; + isReady: boolean; + accessToken: string | undefined; + cookieAuth: boolean; +} + +export interface AuthActions { + logout: (reload?: boolean) => Promise; + setAccessToken: (token: string | null | undefined) => void; + setUserFromAPI: () => Promise; +} + +export type IUseAuth = AuthState & AuthActions; diff --git a/libs/react-client/src/auth/user.ts b/libs/react-client/src/auth/user.ts new file mode 100644 index 0000000000..4f3a9398ed --- /dev/null +++ b/libs/react-client/src/auth/user.ts @@ -0,0 +1,46 @@ +import { useEffect, useRef } from 'react'; +import { useRecoilState } from 'recoil'; +import { userState } from 'src/state'; + +import { IUser, useApi } from '..'; +import { useAuthConfig } from './config'; +import { getToken, useTokenManagement } from './token'; + +export const useUser = () => { + const [user, setUser] = useRecoilState(userState); + const { cookieAuth, isLoading: authConfigLoading } = useAuthConfig(); + const { handleSetAccessToken } = useTokenManagement(); + + const { data: userData, mutate: mutateUserData } = useApi( + cookieAuth ? '/user' : null + ); + + // setUser, only once (prevents callback loops). + const userDataEffectRun = useRef(false); + useEffect(() => { + if (!userDataEffectRun.current && userData) { + setUser(userData); + userDataEffectRun.current = true; + } + }, [userData]); + + // Not using cookie auth, attempt to get access token from local storage. + const tokenAuthEffectRun = useRef(false); + useEffect(() => { + if ( + !tokenAuthEffectRun.current && + !(user && authConfigLoading && cookieAuth) + ) { + const token = getToken(); + if (token) { + handleSetAccessToken(token); + tokenAuthEffectRun.current = true; + } + } + }, [user, cookieAuth, authConfigLoading]); + + return { + user, + setUserFromAPI: mutateUserData + }; +}; diff --git a/libs/react-client/src/types/config.ts b/libs/react-client/src/types/config.ts index 0184e35c4e..bbb6369612 100644 --- a/libs/react-client/src/types/config.ts +++ b/libs/react-client/src/types/config.ts @@ -21,6 +21,7 @@ export interface IAuthConfig { requireLogin: boolean; passwordAuth: boolean; headerAuth: boolean; + cookieAuth: boolean; oauthProviders: string[]; } diff --git a/libs/react-client/src/utils/token.ts b/libs/react-client/src/utils/token.ts index 6470a846a9..5473dbda72 100644 --- a/libs/react-client/src/utils/token.ts +++ b/libs/react-client/src/utils/token.ts @@ -1,25 +1,4 @@ -const tokenKey = 'token'; +// Backwards compatibility +import { getToken, removeToken, setToken } from 'src/auth/token'; -export function getToken() { - try { - return localStorage.getItem(tokenKey); - } catch (_) { - return; - } -} - -export function setToken(token: string) { - try { - return localStorage.setItem(tokenKey, token); - } catch (_) { - return; - } -} - -export function removeToken() { - try { - return localStorage.removeItem(tokenKey); - } catch (_) { - return; - } -} +export { getToken, setToken, removeToken }; diff --git a/package.json b/package.json index a01cd27396..6a6648d4bc 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test": "pnpm exec ts-node ./cypress/support/e2e.ts", "test:ui": "cd frontend && pnpm test", "prepare": "husky", + "lint": "pnpm run lintUi && pnpm run lintPython", "lintUi": "pnpm run --parallel lint", "formatUi": "cd frontend && pnpm run format", "lintPython": "cd backend && poetry run dmypy run --timeout 600 -- chainlit/ tests/",