diff --git a/infra/compose/nginx/openrag-admin.conf b/infra/compose/nginx/openrag-admin.conf index 7053eb792..cc12478ef 100644 --- a/infra/compose/nginx/openrag-admin.conf +++ b/infra/compose/nginx/openrag-admin.conf @@ -14,6 +14,12 @@ map $http_x_forwarded_proto $forwarded_scheme { '' $scheme; } +# Preserve WebSocket upgrades for proxied apps such as Chainlit. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { # 8080 (unprivileged): the image runs nginx as a non-root user so it works # under a hardened container security context. The published host port is @@ -64,6 +70,9 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; # Stream SSE responses (e.g. /v1 chat) without buffering. proxy_buffering off; diff --git a/openrag/api/middleware/auth.py b/openrag/api/middleware/auth.py index dab7900f8..5fa80aa6d 100644 --- a/openrag/api/middleware/auth.py +++ b/openrag/api/middleware/auth.py @@ -35,6 +35,7 @@ from typing import Any from urllib.parse import quote +from core.auth.chainlit import CHAINLIT_TOKEN_COOKIE_NAME from core.config.auth import AuthBypassConfig from core.utils.logging import get_logger from fastapi import Request @@ -261,6 +262,18 @@ async def dispatch(self, request: Request, call_next): "OIDC session check failed on chainlit bypass; redirecting to login" ) session_valid = False + if not session_valid: + chainlit_token = request.cookies.get(CHAINLIT_TOKEN_COOKIE_NAME) + if chainlit_token: + try: + auth_service = self._get_auth_service(request) + user = await auth_service.get_user_by_token_for_request(chainlit_token) + session_valid = user is not None + except Exception as e: + logger.bind(error=str(e)).warning( + "Chainlit token handoff check failed; redirecting to login" + ) + session_valid = False if not session_valid: next_path = path if request.url.query: diff --git a/openrag/api/routers/auth/oidc.py b/openrag/api/routers/auth/oidc.py index 770829241..ce5b4cc02 100644 --- a/openrag/api/routers/auth/oidc.py +++ b/openrag/api/routers/auth/oidc.py @@ -8,9 +8,11 @@ One more route sits *behind* the middleware: - ``GET /auth/me`` — debug endpoint returning the current user. + - ``POST /auth/chainlit-session`` — short-lived Chainlit handoff for token users. -All routes return ``400`` when ``AUTH_MODE != "oidc"`` — the feature is dormant -in ``token`` mode. +The OIDC flow routes return ``400`` when ``AUTH_MODE != "oidc"``. The +middleware-protected utility routes stay available to authenticated users in +both modes. Phase 8A.1: every business decision (PKCE/state generation, code exchange, user lookup / provisioning, session creation, logout-URL construction) now @@ -22,7 +24,17 @@ from __future__ import annotations import os - +from urllib.parse import urlparse + +from api.dependencies.auth import current_user +from core.auth.chainlit import ( + CHAINLIT_AUTH_COOKIE_NAME, + CHAINLIT_LOGOUT_COOKIE_NAME, + CHAINLIT_LOGOUT_SIGNAL_HEADER, + CHAINLIT_TOKEN_COOKIE_MAX_AGE_SECONDS, + CHAINLIT_TOKEN_COOKIE_NAME, + CHAINLIT_TOKEN_COOKIE_PATH, +) from core.auth.state_cookie import StateCookieSerializer from core.utils.exceptions import OpenRAGError from core.utils.logging import get_logger @@ -66,10 +78,36 @@ def _is_request_secure(request: Request) -> bool: return request.url.scheme == "https" +def _is_cross_origin_request(request: Request) -> bool: + origin = request.headers.get("origin") + if not origin: + return False + origin_host = urlparse(origin).hostname + request_host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc + request_host = urlparse(f"//{request_host}").hostname + return bool(origin_host and request_host and origin_host != request_host) + + +def _chainlit_handoff_cookie_samesite(request: Request) -> str: + if _is_request_secure(request) and _is_cross_origin_request(request): + return "none" + return "lax" + + def _delete_state_cookie(response: Response) -> None: response.delete_cookie(key=StateCookieSerializer.COOKIE_NAME, path="/") +def _clear_chainlit_auth_cookie(request: Request, response: Response) -> None: + cookie_names = { + CHAINLIT_AUTH_COOKIE_NAME, + *(name for name in request.cookies if name.startswith(f"{CHAINLIT_AUTH_COOKIE_NAME}_")), + } + for cookie_name in cookie_names: + response.delete_cookie(key=cookie_name, path="/") + response.delete_cookie(key=cookie_name, path=CHAINLIT_TOKEN_COOKIE_PATH) + + def _json_error(status_code: int, detail: str, *, delete_state_cookie: bool = False) -> JSONResponse: r = JSONResponse(status_code=status_code, content={"detail": detail}) if delete_state_cookie: @@ -77,6 +115,13 @@ def _json_error(status_code: int, detail: str, *, delete_state_cookie: bool = Fa return r +def _bearer_token(request: Request) -> str | None: + auth = request.headers.get("authorization", "") + if not auth.lower().startswith("bearer "): + return None + return auth.split(" ", 1)[1].strip() or None + + # --------------------------------------------------------------------------- # GET /auth/login # --------------------------------------------------------------------------- @@ -222,6 +267,58 @@ async def logout( # logout in-place. The cookie deletion below still takes effect. response = JSONResponse(status_code=200, content={"detail": "Logged out"}) response.delete_cookie(key=SESSION_COOKIE_NAME, path="/") + response.delete_cookie(key=CHAINLIT_TOKEN_COOKIE_NAME, path=CHAINLIT_TOKEN_COOKIE_PATH) + return response + + +@router.get("/auth/chainlit-logout-signal", include_in_schema=False) +async def chainlit_logout_signal(request: Request): + should_consume = request.headers.get(CHAINLIT_LOGOUT_SIGNAL_HEADER) == "1" + response = JSONResponse(content={"logged_out": should_consume and CHAINLIT_LOGOUT_COOKIE_NAME in request.cookies}) + if should_consume: + response.delete_cookie(key=CHAINLIT_LOGOUT_COOKIE_NAME, path="/") + return response + + +# --------------------------------------------------------------------------- +# POST /auth/chainlit-session — standard AuthMiddleware applies +# --------------------------------------------------------------------------- + + +@router.post("/auth/chainlit-session", include_in_schema=False) +async def chainlit_session(request: Request, _user=Depends(current_user)): + """Prepare a short-lived Chainlit handoff for bearer-token users. + + OIDC browser users already carry the ``openrag_session`` cookie to + ``/chainlit/``. Bearer-token users do not, so the Admin UI calls this route + before opening Chat. The route is protected by AuthMiddleware and stores the + already-validated bearer only in a short-lived, HTTP-only cookie scoped to + the Chainlit path. + """ + + response = Response(status_code=status.HTTP_204_NO_CONTENT) + _clear_chainlit_auth_cookie(request, response) + token = _bearer_token(request) + if not token or getattr(request.state, "oidc_session", None) is not None: + return response + + response.set_cookie( + key=CHAINLIT_TOKEN_COOKIE_NAME, + value=token, + max_age=CHAINLIT_TOKEN_COOKIE_MAX_AGE_SECONDS, + httponly=True, + secure=_is_request_secure(request), + samesite=_chainlit_handoff_cookie_samesite(request), + path=CHAINLIT_TOKEN_COOKIE_PATH, + ) + return response + + +@router.delete("/auth/chainlit-session", include_in_schema=False) +async def clear_chainlit_session(request: Request, _user=Depends(current_user)): + response = Response(status_code=status.HTTP_204_NO_CONTENT) + response.delete_cookie(key=CHAINLIT_TOKEN_COOKIE_NAME, path=CHAINLIT_TOKEN_COOKIE_PATH) + _clear_chainlit_auth_cookie(request, response) return response diff --git a/openrag/app_front.py b/openrag/app_front.py index 462e573ac..fd5be7434 100644 --- a/openrag/app_front.py +++ b/openrag/app_front.py @@ -1,5 +1,7 @@ import json import os +import secrets +import time from functools import lru_cache from pathlib import Path from urllib.parse import urlparse @@ -9,6 +11,13 @@ from chainlit.config import config as cl_config from chainlit.context import get_context from consts import PARTITION_PREFIX +from core.auth.chainlit import ( + CHAINLIT_AUTH_COOKIE_NAME, + CHAINLIT_LOGOUT_COOKIE_MAX_AGE_SECONDS, + CHAINLIT_LOGOUT_COOKIE_NAME, + CHAINLIT_TOKEN_COOKIE_NAME, + CHAINLIT_TOKEN_COOKIE_PATH, +) from core.utils.logging import get_logger, mask_email from dotenv import load_dotenv from openai import AsyncOpenAI @@ -28,6 +37,15 @@ INTERNAL_BASE_URL = f"http://localhost:{port}" # Default fallback URL DEFAULT_LANGUAGE = os.environ.get("DEFAULT_LANGUAGE") +OPENRAG_API_KEY_SESSION_KEY = "openrag_api_key" +OPENRAG_AUTH_HANDLE_METADATA_KEY = "openrag_auth_handle" +OPENRAG_CHAT_PROFILES_METADATA_KEY = "openrag_chat_profiles" +OPENRAG_SESSION_COOKIE_NAME = "openrag_session" +_OPENRAG_TOKEN_STORE: dict[str, tuple[str, float]] = {} + + +class MissingOpenRAGCredentialError(RuntimeError): + pass def get_user_language() -> str: @@ -93,6 +111,245 @@ def _extract_cookie(cookie_header: str, name: str) -> str | None: return None +def _delete_cookie_and_chunks(request, response, name: str, *, paths: tuple[str, ...]) -> None: + cookie_names = {name, *(cookie_name for cookie_name in request.cookies if cookie_name.startswith(f"{name}_"))} + for cookie_name in cookie_names: + for path in paths: + response.delete_cookie(key=cookie_name, path=path) + + +def _is_request_secure(request) -> bool: + if os.environ.get("PREFERRED_URL_SCHEME", "").lower() == "https": + return True + headers = getattr(request, "headers", {}) or {} + xfp = headers.get("x-forwarded-proto", "") + if xfp.split(",", 1)[0].strip().lower() == "https": + return True + url = getattr(request, "url", None) + return getattr(url, "scheme", "") == "https" + + +def _clear_chat_logout_cookies(request, response) -> None: + _delete_cookie_and_chunks(request, response, CHAINLIT_AUTH_COOKIE_NAME, paths=("/", CHAINLIT_TOKEN_COOKIE_PATH)) + _delete_cookie_and_chunks(request, response, CHAINLIT_TOKEN_COOKIE_NAME, paths=(CHAINLIT_TOKEN_COOKIE_PATH,)) + response.delete_cookie(key=OPENRAG_SESSION_COOKIE_NAME, path="/") + secure_logout_signal = _is_request_secure(request) + response.set_cookie( + key=CHAINLIT_LOGOUT_COOKIE_NAME, + value="1", + max_age=CHAINLIT_LOGOUT_COOKIE_MAX_AGE_SECONDS, + path="/", + secure=secure_logout_signal, + samesite="none" if secure_logout_signal else "lax", + ) + + +def _token_store_ttl_seconds() -> int: + return int(getattr(cl_config.project, "user_session_timeout", 3600) or 3600) + + +def _cleanup_token_store(now: float | None = None) -> None: + now = now if now is not None else time.monotonic() + expired = [handle for handle, (_, expires_at) in _OPENRAG_TOKEN_STORE.items() if expires_at <= now] + for handle in expired: + _OPENRAG_TOKEN_STORE.pop(handle, None) + + +def _remember_openrag_api_key(api_key: str | None) -> str | None: + if not api_key: + return None + _cleanup_token_store() + handle = secrets.token_urlsafe(32) + _OPENRAG_TOKEN_STORE[handle] = (api_key, time.monotonic() + _token_store_ttl_seconds()) + return handle + + +def _openrag_api_key_from_user(user) -> str | None: + metadata = getattr(user, "metadata", {}) or {} + handle = metadata.get(OPENRAG_AUTH_HANDLE_METADATA_KEY) + if not handle: + return None + item = _OPENRAG_TOKEN_STORE.get(handle) + if not item: + return None + api_key, expires_at = item + now = time.monotonic() + if expires_at <= now: + _OPENRAG_TOKEN_STORE.pop(handle, None) + return None + _OPENRAG_TOKEN_STORE[handle] = (api_key, now + _token_store_ttl_seconds()) + return api_key + + +def _openrag_auth_provider_from_user(user) -> str | None: + metadata = getattr(user, "metadata", {}) or {} + provider = metadata.get("provider") + return provider if isinstance(provider, str) else None + + +def _openrag_api_key_from_context_cookie(*, prefer_handoff: bool = False) -> str | None: + try: + context = get_context() + cookie_header = context.session.environ.get("HTTP_COOKIE", "") + except Exception: + return None + session_token = _extract_cookie(cookie_header, "openrag_session") + handoff_token = _extract_cookie(cookie_header, CHAINLIT_TOKEN_COOKIE_NAME) + if prefer_handoff: + return handoff_token + return session_token or handoff_token + + +def _current_openrag_api_key(default: str = "sk-1234") -> str: + api_key = cl.user_session.get(OPENRAG_API_KEY_SESSION_KEY) + if api_key: + return api_key + + user = cl.user_session.get("user") + return _openrag_api_key_from_user_or_context(user, default=default) + + +def _openrag_api_key_from_user_or_context(user, default: str = "sk-1234") -> str: + api_key = _openrag_api_key_from_user(user) + if api_key: + cl.user_session.set(OPENRAG_API_KEY_SESSION_KEY, api_key) + return api_key + api_key = _openrag_api_key_from_context_cookie( + prefer_handoff=_openrag_auth_provider_from_user(user) == "credentials" + ) + if api_key: + cl.user_session.set(OPENRAG_API_KEY_SESSION_KEY, api_key) + return api_key + if user is not None: + raise MissingOpenRAGCredentialError( + "Your OpenRAG Chat session expired. Please sign out of Chat and sign in again." + ) + return default + + +async def _handle_missing_openrag_credential(error: MissingOpenRAGCredentialError) -> None: + logger.warning("OpenRAG Chat session expired", error=str(error)) + await cl.Message(content=str(error)).send() + + +def _current_openrag_auth_provider() -> str | None: + user = cl.user_session.get("user") + return _openrag_auth_provider_from_user(user) + + +def _chat_profile_from_model_id(model_id: str) -> cl.ChatProfile | None: + if not model_id.startswith(PARTITION_PREFIX): + return None + partition = model_id.removeprefix(PARTITION_PREFIX) + description_key = "profile_description_all" if partition == "all" else "profile_description_partition" + description_template = t(description_key) + return cl.ChatProfile( + name=model_id, + markdown_description=description_template.format(name=model_id, partition=partition), + icon="/public/favicon.svg", + default=model_id == f"{PARTITION_PREFIX}all", + ) + + +def _chat_profiles_from_model_ids(model_ids: list[str]) -> list[cl.ChatProfile]: + profiles = [] + for model_id in model_ids: + profile = _chat_profile_from_model_id(model_id) + if profile: + profiles.append(profile) + return profiles + + +def _cached_chat_profiles_from_user(user) -> list[cl.ChatProfile]: + metadata = getattr(user, "metadata", {}) or {} + model_ids = metadata.get(OPENRAG_CHAT_PROFILES_METADATA_KEY) + if not isinstance(model_ids, list): + return [] + return _chat_profiles_from_model_ids([model_id for model_id in model_ids if isinstance(model_id, str)]) + + +async def _load_openrag_model_ids(client: httpx.AsyncClient, api_key: str) -> list[str]: + response = await client.get( + url=f"{INTERNAL_BASE_URL}/v1/models", + headers=get_headers(api_key), + ) + response.raise_for_status() + data = response.json() + models = data.get("data", []) + if not isinstance(models, list): + return [] + return [ + model["id"] + for model in models + if isinstance(model, dict) and isinstance(model.get("id"), str) and model["id"].startswith(PARTITION_PREFIX) + ] + + +async def _load_openrag_model_ids_for_metadata(client: httpx.AsyncClient, api_key: str) -> list[str]: + try: + return await _load_openrag_model_ids(client, api_key) + except Exception as e: + logger.warning("Could not preload OpenRAG chat profiles for Chainlit", error=str(e)) + return [] + + +def _chainlit_user_from_info(data: dict, *, provider: str, api_key: str, model_ids: list[str] | None = None) -> cl.User: + metadata = { + "role": "admin" if data.pop("is_admin", False) else "user", + "provider": provider, + "extra": data, + } + if model_ids: + metadata[OPENRAG_CHAT_PROFILES_METADATA_KEY] = model_ids + auth_handle = _remember_openrag_api_key(api_key) + if auth_handle: + metadata[OPENRAG_AUTH_HANDLE_METADATA_KEY] = auth_handle + + identifier = data.get("display_name") or data.get("email") or f"User #{data.get('id')}" + return cl.User(identifier=identifier, metadata=metadata) + + +async def _load_user_info(client: httpx.AsyncClient, api_key: str) -> dict: + response = await client.get( + url=f"{INTERNAL_BASE_URL}/users/info", + headers=get_headers(api_key), + ) + response.raise_for_status() + return response.json() + + +async def _chainlit_user_from_browser_cookies(headers: dict) -> cl.User | None: + """Authenticate Chainlit from the browser cookies OpenRAG owns.""" + cookie_header = headers.get("cookie") or headers.get("Cookie") or "" + session_token = _extract_cookie(cookie_header, "openrag_session") + chainlit_token = _extract_cookie(cookie_header, CHAINLIT_TOKEN_COOKIE_NAME) + api_key = session_token or chainlit_token + if not api_key: + logger.info("No OpenRAG auth cookie in Chainlit request") + return None + + provider = "oidc" if session_token else "credentials" + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client: + try: + data = await _load_user_info(client, api_key) + except httpx.HTTPStatusError as e: + if e.response.status_code not in (401, 403) or not session_token or not chainlit_token: + raise + api_key = chainlit_token + provider = "credentials" + data = await _load_user_info(client, api_key) + model_ids = await _load_openrag_model_ids_for_metadata(client, api_key) + except httpx.HTTPStatusError as e: + logger.info("Session cookie rejected by /users/info", status=e.response.status_code) + return None + except Exception as e: + logger.exception("Chainlit header_auth_callback failure", error=str(e)) + return None + + return _chainlit_user_from_info(data, provider=provider, api_key=api_key, model_ids=model_ids) + + if PERSISTENCY: @cl.on_chat_resume @@ -108,26 +365,19 @@ async def on_chat_resume(thread): "and set it in your environment." ) + @cl.header_auth_callback + async def header_auth_callback(headers: dict) -> cl.User | None: + """Authenticate Chat from the short-lived Admin UI handoff cookie.""" + return await _chainlit_user_from_browser_cookies(headers) + @cl.password_auth_callback async def auth_callback(username: str, password: str): try: async with httpx.AsyncClient(timeout=httpx.Timeout(4 * 60.0)) as client: - response = await client.get( - url=f"{INTERNAL_BASE_URL}/users/info", - headers=get_headers(password), - ) - response.raise_for_status() # raises exception for 4xx/5xx responses - data = response.json() - - return cl.User( - identifier=data.get("display_name", "user"), - metadata={ - "role": "admin" if data.pop("is_admin") else "user", - "provider": "credentials", - "api_key": password, - "extra": data, - }, - ) + data = await _load_user_info(client, password) + model_ids = await _load_openrag_model_ids_for_metadata(client, password) + + return _chainlit_user_from_info(data, provider="credentials", api_key=password, model_ids=model_ids) except httpx.HTTPStatusError: logger.info("Authentication failed", username=mask_email(username)) @@ -146,37 +396,14 @@ async def auth_callback(username: str, password: str): @cl.header_auth_callback async def header_auth_callback(headers: dict) -> cl.User | None: - """Authenticate Chainlit users via the openrag_session cookie posted by /auth/callback.""" - cookie_header = headers.get("cookie") or headers.get("Cookie") or "" - session_token = _extract_cookie(cookie_header, "openrag_session") - if not session_token: - logger.info("No openrag_session cookie in Chainlit request") - return None + """Authenticate Chainlit users via an OpenRAG browser auth cookie.""" + return await _chainlit_user_from_browser_cookies(headers) - try: - async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client: - response = await client.get( - url=f"{INTERNAL_BASE_URL}/users/info", - headers=get_headers(session_token), - ) - response.raise_for_status() - data = response.json() - except httpx.HTTPStatusError as e: - logger.info("Session cookie rejected by /users/info", status=e.response.status_code) - return None - except Exception as e: - logger.exception("Chainlit header_auth_callback failure", error=str(e)) - return None - return cl.User( - identifier=data.get("display_name", "user"), - metadata={ - "role": "admin" if data.pop("is_admin", False) else "user", - "provider": "oidc", - "api_key": session_token, # opaque cookie value — used as Bearer for internal calls - "extra": data, - }, - ) +@cl.on_logout +async def on_logout(request, response): + _clear_chat_logout_cookies(request, response) + return {"success": True} def get_external_url(): @@ -189,36 +416,31 @@ def get_external_url(): @cl.set_chat_profiles async def chat_profile(current_user: cl.User): - api_key = current_user.metadata.get("api_key", "sk-1234") if current_user else "sk-1234" - client = AsyncOpenAI(base_url=f"{INTERNAL_BASE_URL}/v1", api_key=api_key) try: - output = await client.models.list() - models = output.data - chat_profiles = [] - for i, m in enumerate(models, start=1): - partition = m.id.split(PARTITION_PREFIX)[1] - description_key = "profile_description_all" if partition == "all" else "profile_description_partition" - description_template = t(description_key) - chat_profiles.append( - cl.ChatProfile( - name=m.id, - markdown_description=description_template.format(name=m.id, partition=partition), - icon="/public/favicon.svg", - default=m.id == f"{PARTITION_PREFIX}all", - ) - ) - return chat_profiles + api_key = _openrag_api_key_from_user_or_context(current_user) + async with httpx.AsyncClient(timeout=httpx.Timeout(4 * 60.0)) as client: + model_ids = await _load_openrag_model_ids(client, api_key) + return _chat_profiles_from_model_ids(model_ids) + except MissingOpenRAGCredentialError as e: + cached_profiles = _cached_chat_profiles_from_user(current_user) + if cached_profiles: + return cached_profiles + await _handle_missing_openrag_credential(e) + return [] except Exception as e: + cached_profiles = _cached_chat_profiles_from_user(current_user) + if cached_profiles: + return cached_profiles await cl.Message(content=t("error_profiles").format(e)).send() + return [] @cl.on_chat_start async def on_chat_start(): cl.user_session.set("messages", []) - user = cl.user_session.get("user") - api_key = user.metadata.get("api_key", "sk-1234") if user else "sk-1234" logger.debug("New Chat Started", internal_base_url=INTERNAL_BASE_URL) try: + api_key = _current_openrag_api_key() async with httpx.AsyncClient(timeout=httpx.Timeout(4 * 60.0)) as client: response = await client.get( url=f"{INTERNAL_BASE_URL}/health_check", @@ -227,6 +449,8 @@ async def on_chat_start(): print(response.text) commands = t("commands") await cl.context.emitter.set_commands(commands if isinstance(commands, list) else []) + except MissingOpenRAGCredentialError as e: + await _handle_missing_openrag_credential(e) except Exception as e: logger.exception("An error occured while checking the API health", error=str(e)) await cl.Message(content=t("error_health").format(e)).send() @@ -266,9 +490,10 @@ async def _format_sources(metadata_sources, only_txt=False, api_key=None): # Referer headers). In OIDC mode the browser already sends the # openrag_session cookie on same-origin file fetches, which the auth # middleware accepts — so the token query param is unnecessary. In token - # mode there is no such cookie, so it remains the only way for the - # browser to authenticate the fetch. - if AUTH_MODE != "oidc": + # mode, or for a token handoff inside an OIDC deployment, there is no + # cookie on /static, so it remains the only way for the browser to + # authenticate the fetch. + if api_key and (AUTH_MODE != "oidc" or _current_openrag_auth_provider() == "credentials"): file_url = f"{file_url}?token={api_key}" page = s["page"] source_name = f"{filename}" + ( @@ -309,12 +534,6 @@ async def _format_sources(metadata_sources, only_txt=False, api_key=None): async def on_message(message: cl.Message): messages: list = cl.user_session.get("messages", []) model: str = cl.user_session.get("chat_profile") - user = cl.user_session.get("user") - api_key = user.metadata.get("api_key") if user else "sk-1234" - client = AsyncOpenAI( - base_url=f"{INTERNAL_BASE_URL}/v1", - api_key=api_key, - ) messages.append({"role": "user", "content": message.content}) data = { @@ -338,6 +557,11 @@ async def on_message(message: cl.Message): await msg.send() try: + api_key = _current_openrag_api_key() + client = AsyncOpenAI( + base_url=f"{INTERNAL_BASE_URL}/v1", + api_key=api_key, + ) # Stream the response using OpenAI client directly stream = await client.chat.completions.create(**data) async for chunk in stream: @@ -362,6 +586,9 @@ async def on_message(message: cl.Message): s = "\n\n" + "-" * 50 + f"\n\n{t('sources_label')}: \n" + "\n".join(source_names) await msg.stream_token(s) await msg.update() + except MissingOpenRAGCredentialError as e: + logger.warning("OpenRAG Chat session expired during chat completion", error=str(e)) + await cl.Message(content=str(e)).send() except Exception as e: logger.exception("Error during chat completion", error=str(e)) await cl.Message(content=t("error_chat").format(e)).send() diff --git a/openrag/core/auth/chainlit.py b/openrag/core/auth/chainlit.py new file mode 100644 index 000000000..2fd6b4145 --- /dev/null +++ b/openrag/core/auth/chainlit.py @@ -0,0 +1,11 @@ +"""Shared Chainlit auth handoff constants.""" + +import os + +CHAINLIT_TOKEN_COOKIE_NAME = "openrag_chainlit_token" +CHAINLIT_TOKEN_COOKIE_PATH = "/chainlit" +CHAINLIT_TOKEN_COOKIE_MAX_AGE_SECONDS = 120 +CHAINLIT_AUTH_COOKIE_NAME = os.environ.get("CHAINLIT_AUTH_COOKIE_NAME", "access_token") +CHAINLIT_LOGOUT_COOKIE_NAME = "openrag_chainlit_logout" +CHAINLIT_LOGOUT_COOKIE_MAX_AGE_SECONDS = 86400 +CHAINLIT_LOGOUT_SIGNAL_HEADER = "x-openrag-chainlit-logout-signal" diff --git a/openrag/core/config/auth.py b/openrag/core/config/auth.py index 6c53397cc..aaf13f995 100644 --- a/openrag/core/config/auth.py +++ b/openrag/core/config/auth.py @@ -249,6 +249,7 @@ def from_env(cls) -> OIDCConfig: "/auth/callback", "/auth/backchannel-logout", "/auth/logout", + "/auth/chainlit-logout-signal", ) # REST API prefixes. Unauthenticated requests here get JSON 401/403, diff --git a/tests/unit/api/middleware/test_bypass_config.py b/tests/unit/api/middleware/test_bypass_config.py index 657274246..eee8f8e54 100644 --- a/tests/unit/api/middleware/test_bypass_config.py +++ b/tests/unit/api/middleware/test_bypass_config.py @@ -16,6 +16,7 @@ is_bypass_path, is_ui_path, ) +from core.auth.chainlit import CHAINLIT_TOKEN_COOKIE_NAME from core.config.auth import ( DEFAULT_API_PREFIXES, DEFAULT_BYPASS_PATHS, @@ -45,6 +46,7 @@ def test_default_bypass_paths_match_legacy_set() -> None: "/auth/callback", "/auth/backchannel-logout", "/auth/logout", + "/auth/chainlit-logout-signal", } assert set(DEFAULT_BYPASS_PATHS) == expected assert set(AuthBypassConfig().bypass_paths) == expected @@ -166,9 +168,9 @@ def test_auth_middleware_accepts_custom_bypass_config() -> None: assert instance._bypass_config is custom -def _request(headers=None): +def _request(headers=None, path="/indexer/files"): raw = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()] - scope = {"type": "http", "method": "GET", "path": "/indexer/files", "headers": raw, "query_string": b""} + scope = {"type": "http", "method": "GET", "path": path, "headers": raw, "query_string": b""} return Request(scope) @@ -274,6 +276,36 @@ async def call_next(req): svc.get_user_for_request.assert_not_awaited() +@pytest.mark.asyncio +async def test_oidc_chainlit_html_allows_valid_token_handoff_cookie(monkeypatch) -> None: + from unittest.mock import AsyncMock + + monkeypatch.setenv("AUTH_MODE", "oidc") + monkeypatch.setenv("AUTH_TOKEN", "secret") + + svc = type("S", (), {})() + svc.get_oidc_session_by_token_for_request = AsyncMock(return_value=None) + svc.get_user_by_token_for_request = AsyncMock(return_value={"id": 7, "display_name": "Token User"}) + + async def call_next(req): + return Response("chainlit") + + middleware = AuthMiddleware(lambda scope, receive, send: None, get_auth_service=lambda _r: svc) + response = await middleware.dispatch( + _request( + path="/chainlit/", + headers={ + "accept": "text/html", + "cookie": f"{CHAINLIT_TOKEN_COOKIE_NAME}=or-user-token", + }, + ), + call_next, + ) + + assert response.status_code == 200 + svc.get_user_by_token_for_request.assert_awaited_once_with("or-user-token") + + @pytest.mark.asyncio async def test_failed_token_auth_is_rate_limited_by_ip(monkeypatch) -> None: monkeypatch.setenv("AUTH_MODE", "token") diff --git a/tests/unit/infra/test_admin_ui_compose.py b/tests/unit/infra/test_admin_ui_compose.py index 658f2c99f..7e8737b2d 100644 --- a/tests/unit/infra/test_admin_ui_compose.py +++ b/tests/unit/infra/test_admin_ui_compose.py @@ -29,3 +29,13 @@ def test_admin_ui_nginx_preserves_public_host_header_for_api_redirects(): assert re.search(r"proxy_set_header\s+Host\s+\$http_host;", config) assert re.search(r"proxy_set_header\s+X-Forwarded-Host\s+\$http_host;", config) + + +def test_admin_ui_nginx_preserves_websocket_upgrades_for_chainlit(): + nginx_conf = Path(__file__).resolve().parents[3] / "infra/compose/nginx/openrag-admin.conf" + config = nginx_conf.read_text(encoding="utf-8") + + assert re.search(r"map\s+\$http_upgrade\s+\$connection_upgrade\s+\{", config) + assert re.search(r"proxy_http_version\s+1\.1;", config) + assert re.search(r"proxy_set_header\s+Upgrade\s+\$http_upgrade;", config) + assert re.search(r"proxy_set_header\s+Connection\s+\$connection_upgrade;", config) diff --git a/tests/unit/test_app_front_secret.py b/tests/unit/test_app_front_secret.py index c75bf5c23..b0a9a5e37 100644 --- a/tests/unit/test_app_front_secret.py +++ b/tests/unit/test_app_front_secret.py @@ -3,12 +3,30 @@ default secret. """ +import importlib import sys from pathlib import Path +from types import SimpleNamespace +import httpx import pytest +from starlette.responses import Response _FIX_SOURCE = Path(__file__).resolve().parents[2] / "openrag" / "app_front.py" +_OPENRAG_RUNTIME_PATH = _FIX_SOURCE.parent + + +def _load_app_front(monkeypatch, *, auth_mode: str, module_name: str): + monkeypatch.setenv("AUTH_TOKEN", "test-token") + monkeypatch.setenv("AUTH_MODE", auth_mode) + monkeypatch.setenv("CHAINLIT_AUTH_SECRET", "x" * 32) + monkeypatch.setattr("dotenv.load_dotenv", lambda *a, **kw: None) + monkeypatch.syspath_prepend(str(_OPENRAG_RUNTIME_PATH)) + + spec = importlib.util.spec_from_file_location(module_name, _FIX_SOURCE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module def test_no_hardcoded_default_secret_assignment_in_source(): @@ -24,6 +42,432 @@ def test_no_hardcoded_default_secret_assignment_in_source(): ) +def test_openrag_bearer_token_is_not_stored_in_chainlit_user_metadata(): + with open(_FIX_SOURCE) as f: + content = f.read() + + assert '"api_key": api_key' not in content + assert '"api_key": password' not in content + + +def test_token_mode_keeps_chainlit_password_login(monkeypatch): + """Token mode keeps manual login while allowing Admin UI handoff.""" + from chainlit.config import config + + monkeypatch.setattr(config.code, "header_auth_callback", None) + monkeypatch.setattr(config.code, "password_auth_callback", None) + + _load_app_front(monkeypatch, auth_mode="token", module_name="app_front_token_mode_test") + + assert config.code.header_auth_callback is not None + assert config.code.password_auth_callback is not None + + +@pytest.mark.asyncio +async def test_token_mode_header_auth_accepts_chainlit_handoff_cookie(monkeypatch): + from chainlit.config import config + + monkeypatch.setattr(config.code, "header_auth_callback", None) + monkeypatch.setattr(config.code, "password_auth_callback", None) + module = _load_app_front(monkeypatch, auth_mode="token", module_name="app_front_token_handoff_test") + + async def fake_load_user_info(_client, api_key): + assert api_key == "or-user-token" + return { + "display_name": "Token User", + "email": "token@example.test", + "is_admin": True, + } + + async def fake_load_model_ids(_client, api_key): + assert api_key == "or-user-token" + return ["openrag-default", "openrag-all"] + + monkeypatch.setattr(module, "_load_user_info", fake_load_user_info) + monkeypatch.setattr(module, "_load_openrag_model_ids_for_metadata", fake_load_model_ids) + + user = await config.code.header_auth_callback({"cookie": f"{module.CHAINLIT_TOKEN_COOKIE_NAME}=or-user-token"}) + + assert user.identifier == "Token User" + assert user.metadata["provider"] == "credentials" + assert user.metadata["role"] == "admin" + assert user.metadata[module.OPENRAG_CHAT_PROFILES_METADATA_KEY] == ["openrag-default", "openrag-all"] + auth_handle = user.metadata[module.OPENRAG_AUTH_HANDLE_METADATA_KEY] + assert module._OPENRAG_TOKEN_STORE[auth_handle][0] == "or-user-token" + + +def test_api_key_falls_back_to_chainlit_cookie_when_auth_handle_is_missing(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="oidc", module_name="app_front_cookie_fallback_test") + monkeypatch.setattr(module, "_openrag_api_key_from_context_cookie", lambda **_kwargs: "or-cookie-token") + + class UserSession: + def __init__(self): + self.values = {} + + def get(self, key): + return self.values.get(key) + + def set(self, key, value): + self.values[key] = value + + user_session = UserSession() + module.cl = SimpleNamespace(user_session=user_session) + + api_key = module._openrag_api_key_from_user_or_context(SimpleNamespace(metadata={})) + + assert api_key == "or-cookie-token" + assert user_session.values[module.OPENRAG_API_KEY_SESSION_KEY] == "or-cookie-token" + + +def test_api_key_requires_reauth_when_user_token_cannot_be_recovered(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="token", module_name="app_front_missing_token_test") + monkeypatch.setattr(module, "_openrag_api_key_from_context_cookie", lambda **_kwargs: None) + + with pytest.raises(module.MissingOpenRAGCredentialError, match="sign in again"): + module._openrag_api_key_from_user_or_context( + SimpleNamespace(metadata={module.OPENRAG_AUTH_HANDLE_METADATA_KEY: "missing-handle"}) + ) + + +def test_credentials_user_prefers_handoff_cookie_when_session_cookie_is_stale(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="oidc", module_name="app_front_handoff_cookie_priority_test") + user_session = SimpleNamespace(values={}, get=lambda key: None) + user_session.set = lambda key, value: user_session.values.update({key: value}) + module.cl = SimpleNamespace(user_session=user_session) + monkeypatch.setattr( + module, + "get_context", + lambda: SimpleNamespace( + session=SimpleNamespace( + environ={ + "HTTP_COOKIE": ( + f"openrag_session=stale-session-token; {module.CHAINLIT_TOKEN_COOKIE_NAME}=fresh-handoff-token" + ) + } + ) + ), + ) + + api_key = module._openrag_api_key_from_user_or_context(SimpleNamespace(metadata={"provider": "credentials"})) + + assert api_key == "fresh-handoff-token" + + +def test_credentials_user_does_not_recover_from_oidc_session_cookie(monkeypatch): + module = _load_app_front( + monkeypatch, auth_mode="oidc", module_name="app_front_credentials_no_session_fallback_test" + ) + user_session = SimpleNamespace(values={}, get=lambda key: None) + user_session.set = lambda key, value: user_session.values.update({key: value}) + module.cl = SimpleNamespace(user_session=user_session) + monkeypatch.setattr( + module, + "get_context", + lambda: SimpleNamespace(session=SimpleNamespace(environ={"HTTP_COOKIE": "openrag_session=oidc-session-token"})), + ) + + with pytest.raises(module.MissingOpenRAGCredentialError, match="sign in again"): + module._openrag_api_key_from_user_or_context(SimpleNamespace(metadata={"provider": "credentials"})) + + assert module.OPENRAG_API_KEY_SESSION_KEY not in user_session.values + + +@pytest.mark.asyncio +async def test_chainlit_logout_clears_handoff_and_openrag_session_cookies(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="token", module_name="app_front_logout_cookie_cleanup_test") + request = SimpleNamespace( + cookies={ + module.CHAINLIT_AUTH_COOKIE_NAME: "stale-chainlit-jwt", + f"{module.CHAINLIT_AUTH_COOKIE_NAME}_0": "stale-chainlit-jwt-chunk", + module.CHAINLIT_TOKEN_COOKIE_NAME: "or-user-token", + module.OPENRAG_SESSION_COOKIE_NAME: "oidc-session-token", + } + ) + response = Response() + + returned = await module.on_logout(request, response) + + cookies = [value.decode() for key, value in response.raw_headers if key.lower() == b"set-cookie"] + assert returned == {"success": True} + assert any(f"{module.CHAINLIT_AUTH_COOKIE_NAME}=" in cookie and "Max-Age=0" in cookie for cookie in cookies) + assert any(f"{module.CHAINLIT_AUTH_COOKIE_NAME}_0=" in cookie and "Max-Age=0" in cookie for cookie in cookies) + assert any(f"{module.CHAINLIT_TOKEN_COOKIE_NAME}=" in cookie and "Max-Age=0" in cookie for cookie in cookies) + assert any(f"{module.OPENRAG_SESSION_COOKIE_NAME}=" in cookie and "Max-Age=0" in cookie for cookie in cookies) + assert any(f"{module.CHAINLIT_LOGOUT_COOKIE_NAME}=1" in cookie for cookie in cookies) + assert any(f"Max-Age={module.CHAINLIT_LOGOUT_COOKIE_MAX_AGE_SECONDS}" in cookie for cookie in cookies) + + +@pytest.mark.asyncio +async def test_chainlit_logout_signal_cookie_is_cross_site_compatible_on_https(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="token", module_name="app_front_logout_cookie_https_test") + request = SimpleNamespace(cookies={}, headers={"x-forwarded-proto": "https"}) + response = Response() + + returned = await module.on_logout(request, response) + + cookies = [value.decode() for key, value in response.raw_headers if key.lower() == b"set-cookie"] + assert returned == {"success": True} + logout_cookie = next(cookie for cookie in cookies if f"{module.CHAINLIT_LOGOUT_COOKIE_NAME}=1" in cookie) + assert "SameSite=none" in logout_cookie + assert "Secure" in logout_cookie + + +@pytest.mark.asyncio +async def test_chat_start_handles_expired_handoff_without_exception_log(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="oidc", module_name="app_front_chat_start_expired_handoff_test") + sent_messages = [] + log_calls = [] + + class UserSession: + def __init__(self): + self.values = {} + + def get(self, key): + return self.values.get(key) + + def set(self, key, value): + self.values[key] = value + + class Message: + def __init__(self, content): + self.content = content + + async def send(self): + sent_messages.append(self.content) + + logger = SimpleNamespace( + debug=lambda *args, **kwargs: None, + warning=lambda *args, **kwargs: log_calls.append(("warning", args, kwargs)), + exception=lambda *args, **kwargs: log_calls.append(("exception", args, kwargs)), + ) + module.logger = logger + module.cl = SimpleNamespace(user_session=UserSession(), Message=Message) + monkeypatch.setattr( + module, + "_current_openrag_api_key", + lambda: (_ for _ in ()).throw(module.MissingOpenRAGCredentialError("expired handoff")), + ) + + await module.on_chat_start() + + assert sent_messages == ["expired handoff"] + assert [call[0] for call in log_calls] == ["warning"] + + +def test_chainlit_user_metadata_keeps_chat_profiles_but_not_bearer(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="token", module_name="app_front_profile_metadata_test") + + user = module._chainlit_user_from_info( + {"display_name": "Token User", "is_admin": False}, + provider="credentials", + api_key="or-user-token", + model_ids=["openrag-default", "openrag-all"], + ) + + assert user.metadata[module.OPENRAG_CHAT_PROFILES_METADATA_KEY] == ["openrag-default", "openrag-all"] + assert user.metadata["provider"] == "credentials" + assert "api_key" not in user.metadata + assert "or-user-token" not in str(user.metadata) + + +@pytest.mark.asyncio +async def test_chat_profiles_use_cached_model_ids_when_handoff_token_is_unavailable(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="oidc", module_name="app_front_cached_profiles_test") + sent_messages = [] + + class Message: + def __init__(self, content): + self.content = content + + async def send(self): + sent_messages.append(self.content) + + monkeypatch.setattr(module, "t", lambda key: "{name} ({partition})" if key.startswith("profile_") else key) + module.cl = SimpleNamespace(Message=Message, ChatProfile=module.cl.ChatProfile) + monkeypatch.setattr( + module, + "_openrag_api_key_from_user_or_context", + lambda _user: (_ for _ in ()).throw(module.MissingOpenRAGCredentialError("expired handoff")), + ) + + profiles = await module.chat_profile( + SimpleNamespace( + metadata={ + "provider": "credentials", + module.OPENRAG_CHAT_PROFILES_METADATA_KEY: ["openrag-default", "openrag-all"], + } + ) + ) + + assert [profile.name for profile in profiles] == ["openrag-default", "openrag-all"] + assert profiles[-1].default is True + assert sent_messages == [] + + +@pytest.mark.asyncio +async def test_chat_profiles_handle_expired_handoff_without_exception_log(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="oidc", module_name="app_front_profiles_expired_handoff_test") + sent_messages = [] + log_calls = [] + + class Message: + def __init__(self, content): + self.content = content + + async def send(self): + sent_messages.append(self.content) + + logger = SimpleNamespace( + warning=lambda *args, **kwargs: log_calls.append(("warning", args, kwargs)), + exception=lambda *args, **kwargs: log_calls.append(("exception", args, kwargs)), + ) + module.logger = logger + module.cl = SimpleNamespace(Message=Message) + monkeypatch.setattr( + module, + "_openrag_api_key_from_user_or_context", + lambda _user: (_ for _ in ()).throw(module.MissingOpenRAGCredentialError("expired handoff")), + ) + + profiles = await module.chat_profile(SimpleNamespace(metadata={"provider": "credentials"})) + + assert profiles == [] + assert sent_messages == ["expired handoff"] + assert [call[0] for call in log_calls] == ["warning"] + + +def test_oidc_user_prefers_session_cookie_when_handoff_cookie_is_left_over(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="oidc", module_name="app_front_oidc_cookie_priority_test") + user_session = SimpleNamespace(values={}, get=lambda key: None) + user_session.set = lambda key, value: user_session.values.update({key: value}) + module.cl = SimpleNamespace(user_session=user_session) + monkeypatch.setattr( + module, + "get_context", + lambda: SimpleNamespace( + session=SimpleNamespace( + environ={ + "HTTP_COOKIE": ( + f"openrag_session=current-session-token; " + f"{module.CHAINLIT_TOKEN_COOKIE_NAME}=left-over-handoff-token" + ) + } + ) + ), + ) + + api_key = module._openrag_api_key_from_user_or_context(SimpleNamespace(metadata={"provider": "oidc"})) + + assert api_key == "current-session-token" + + +@pytest.mark.parametrize("stale_status", [401, 403]) +@pytest.mark.asyncio +async def test_chainlit_cookie_auth_retries_handoff_after_stale_oidc_session(monkeypatch, stale_status): + module = _load_app_front(monkeypatch, auth_mode="oidc", module_name="app_front_cookie_retry_test") + attempts = [] + + async def fake_load_user_info(_client, api_key): + attempts.append(api_key) + if api_key == "stale-session-token": + request = httpx.Request("GET", "http://internal/users/info") + response = httpx.Response(stale_status, request=request) + raise httpx.HTTPStatusError("Unauthorized", request=request, response=response) + return { + "display_name": "Handoff User", + "email": "handoff@example.test", + "is_admin": False, + } + + async def fake_load_model_ids(_client, api_key): + assert api_key == "handoff-token" + return ["openrag-handoff", "openrag-all"] + + monkeypatch.setattr(module, "_load_user_info", fake_load_user_info) + monkeypatch.setattr(module, "_load_openrag_model_ids_for_metadata", fake_load_model_ids) + + user = await module._chainlit_user_from_browser_cookies( + {"cookie": (f"openrag_session=stale-session-token; {module.CHAINLIT_TOKEN_COOKIE_NAME}=handoff-token")} + ) + + assert attempts == ["stale-session-token", "handoff-token"] + assert user.identifier == "Handoff User" + assert user.metadata["provider"] == "credentials" + assert user.metadata[module.OPENRAG_CHAT_PROFILES_METADATA_KEY] == ["openrag-handoff", "openrag-all"] + auth_handle = user.metadata[module.OPENRAG_AUTH_HANDLE_METADATA_KEY] + assert module._OPENRAG_TOKEN_STORE[auth_handle][0] == "handoff-token" + + +@pytest.mark.asyncio +async def test_oidc_token_handoff_keeps_bearer_on_static_source_urls(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="oidc", module_name="app_front_source_token_test") + module.INTERNAL_BASE_URL = "http://internal:8080" + monkeypatch.setattr(module, "get_external_url", lambda: "https://openrag.example") + + class UserSession: + def get(self, key): + if key == "user": + return SimpleNamespace(metadata={"provider": "credentials"}) + return None + + module.cl = SimpleNamespace( + user_session=UserSession(), + Pdf=lambda **kwargs: SimpleNamespace(**kwargs), + Text=lambda **kwargs: SimpleNamespace(**kwargs), + Image=lambda **kwargs: SimpleNamespace(**kwargs), + Video=lambda **kwargs: SimpleNamespace(**kwargs), + Audio=lambda **kwargs: SimpleNamespace(**kwargs), + ) + + elements, _ = await module._format_sources( + [ + { + "filename": "document.pdf", + "file_url": "http://internal:8080/static/source-id", + "page": "1", + } + ], + api_key="or-user-token", + ) + + assert elements[0].url == "https://openrag.example/static/source-id?token=or-user-token" + + +@pytest.mark.asyncio +async def test_oidc_session_does_not_put_bearer_on_static_source_urls(monkeypatch): + module = _load_app_front(monkeypatch, auth_mode="oidc", module_name="app_front_source_oidc_test") + module.INTERNAL_BASE_URL = "http://internal:8080" + monkeypatch.setattr(module, "get_external_url", lambda: "https://openrag.example") + + class UserSession: + def get(self, key): + if key == "user": + return SimpleNamespace(metadata={"provider": "oidc"}) + return None + + module.cl = SimpleNamespace( + user_session=UserSession(), + Pdf=lambda **kwargs: SimpleNamespace(**kwargs), + Text=lambda **kwargs: SimpleNamespace(**kwargs), + Image=lambda **kwargs: SimpleNamespace(**kwargs), + Video=lambda **kwargs: SimpleNamespace(**kwargs), + Audio=lambda **kwargs: SimpleNamespace(**kwargs), + ) + + elements, _ = await module._format_sources( + [ + { + "filename": "document.pdf", + "file_url": "http://internal:8080/static/source-id", + "page": "1", + } + ], + api_key="opaque-session-token", + ) + + assert elements[0].url == "https://openrag.example/static/source-id" + + def test_app_front_raises_when_secret_missing(monkeypatch): """Importing app_front.py without CHAINLIT_AUTH_SECRET must raise.""" monkeypatch.setenv("AUTH_TOKEN", "test-token") @@ -37,8 +481,6 @@ def test_app_front_raises_when_secret_missing(monkeypatch): for mod in [m for m in sys.modules if m == "app_front" or m.endswith(".app_front")]: sys.modules.pop(mod, None) - import importlib - spec = importlib.util.spec_from_file_location("app_front_test", _FIX_SOURCE) module = importlib.util.module_from_spec(spec) with pytest.raises(RuntimeError, match="CHAINLIT_AUTH_SECRET"): diff --git a/tests/unit/test_auth_router.py b/tests/unit/test_auth_router.py index bb9751e18..6addeee16 100644 --- a/tests/unit/test_auth_router.py +++ b/tests/unit/test_auth_router.py @@ -80,6 +80,14 @@ def _restore_dependencies_stub(previous_modules: dict[str, types.ModuleType | No _PREVIOUS_MODULES = _install_dependencies_stub() from api.routers.auth.oidc import router as auth_router # noqa: E402 +from core.auth.chainlit import ( # noqa: E402 + CHAINLIT_AUTH_COOKIE_NAME, + CHAINLIT_LOGOUT_COOKIE_NAME, + CHAINLIT_LOGOUT_SIGNAL_HEADER, + CHAINLIT_TOKEN_COOKIE_MAX_AGE_SECONDS, + CHAINLIT_TOKEN_COOKIE_NAME, + CHAINLIT_TOKEN_COOKIE_PATH, +) from di.providers import get_auth_service # noqa: E402 from services.orchestrators.auth_service import ( # noqa: E402 SESSION_COOKIE_NAME, @@ -156,6 +164,19 @@ def client(stub: StubAuthService) -> TestClient: return TestClient(app) +@pytest.fixture +def authenticated_client() -> TestClient: + app = FastAPI() + + @app.middleware("http") + async def bind_user(request, call_next): + request.state.user = {"id": 7, "display_name": "Token User"} + return await call_next(request) + + app.include_router(auth_router) + return TestClient(app) + + @pytest.fixture def oidc_env(monkeypatch): monkeypatch.setenv("AUTH_MODE", "oidc") @@ -309,3 +330,123 @@ def test_logout_allows_cross_site_top_level_navigation(oidc_env, client, stub): ) assert r.status_code == 302 assert stub.calls == [("logout", "sess")] + + +def test_chainlit_logout_signal_reports_and_clears_marker_cookie(client): + client.cookies.set(CHAINLIT_LOGOUT_COOKIE_NAME, "1", path="/") + + r = client.get("/auth/chainlit-logout-signal", headers={CHAINLIT_LOGOUT_SIGNAL_HEADER: "1"}) + + assert r.status_code == 200 + assert r.json() == {"logged_out": True} + cookie = next(c for c in _set_cookies(r) if CHAINLIT_LOGOUT_COOKIE_NAME in c) + assert "Max-Age=0" in cookie or "expires=" in cookie.lower() + + +def test_chainlit_logout_signal_is_false_without_marker_cookie(client): + r = client.get("/auth/chainlit-logout-signal", headers={CHAINLIT_LOGOUT_SIGNAL_HEADER: "1"}) + + assert r.status_code == 200 + assert r.json() == {"logged_out": False} + + +def test_chainlit_logout_signal_plain_get_does_not_clear_marker_cookie(client): + client.cookies.set(CHAINLIT_LOGOUT_COOKIE_NAME, "1", path="/") + + r = client.get("/auth/chainlit-logout-signal") + + assert r.status_code == 200 + assert r.json() == {"logged_out": False} + assert not any(CHAINLIT_LOGOUT_COOKIE_NAME in c and "Max-Age=0" in c for c in _set_cookies(r)) + + +# --------------------------------------------------------------------------- +# POST /auth/chainlit-session +# --------------------------------------------------------------------------- + + +def test_chainlit_session_sets_short_lived_cookie_for_bearer(authenticated_client): + authenticated_client.cookies.set(CHAINLIT_AUTH_COOKIE_NAME, "stale-chainlit-jwt", path="/") + + r = authenticated_client.post("/auth/chainlit-session", headers={"Authorization": "Bearer or-user-token"}) + + assert r.status_code == 204 + cookies = _set_cookies(r) + cookie = next(c for c in cookies if CHAINLIT_TOKEN_COOKIE_NAME in c) + assert "or-user-token" in cookie + assert "HttpOnly" in cookie + assert f"Max-Age={CHAINLIT_TOKEN_COOKIE_MAX_AGE_SECONDS}" in cookie + assert f"Path={CHAINLIT_TOKEN_COOKIE_PATH}" in cookie + assert "SameSite=lax" in cookie + stale_chainlit_cookie = next(c for c in cookies if CHAINLIT_AUTH_COOKIE_NAME in c and "Max-Age=0" in c) + assert "Path=/" in stale_chainlit_cookie + + +def test_chainlit_session_allows_secure_cross_origin_handoff_cookie(authenticated_client): + r = authenticated_client.post( + "/auth/chainlit-session", + headers={ + "Authorization": "Bearer or-user-token", + "Host": "api.example.test", + "Origin": "https://admin.example.test", + "X-Forwarded-Proto": "https", + }, + ) + + assert r.status_code == 204 + cookie = next(c for c in _set_cookies(r) if CHAINLIT_TOKEN_COOKIE_NAME in c) + assert "SameSite=none" in cookie + assert "Secure" in cookie + + +def test_chainlit_session_clears_chunked_stale_chainlit_auth_cookie(authenticated_client): + authenticated_client.cookies.set(f"{CHAINLIT_AUTH_COOKIE_NAME}_0", "stale-jwt-part-1", path="/") + authenticated_client.cookies.set(f"{CHAINLIT_AUTH_COOKIE_NAME}_1", "stale-jwt-part-2", path="/") + + r = authenticated_client.post("/auth/chainlit-session", headers={"Authorization": "Bearer or-user-token"}) + + assert r.status_code == 204 + cookies = _set_cookies(r) + assert any(f"{CHAINLIT_AUTH_COOKIE_NAME}_0=" in c and "Max-Age=0" in c for c in cookies) + assert any(f"{CHAINLIT_AUTH_COOKIE_NAME}_1=" in c and "Max-Age=0" in c for c in cookies) + + +def test_chainlit_session_is_noop_for_cookie_authenticated_user(authenticated_client): + authenticated_client.cookies.set(CHAINLIT_AUTH_COOKIE_NAME, "stale-chainlit-jwt", path="/") + + r = authenticated_client.post("/auth/chainlit-session") + + assert r.status_code == 204 + assert not any(CHAINLIT_TOKEN_COOKIE_NAME in c for c in _set_cookies(r)) + assert any(CHAINLIT_AUTH_COOKIE_NAME in c and "Max-Age=0" in c for c in _set_cookies(r)) + + +def test_chainlit_session_does_not_handoff_bearer_when_oidc_session_authenticated(): + app = FastAPI() + + @app.middleware("http") + async def bind_oidc_user(request, call_next): + request.state.user = {"id": 7, "display_name": "OIDC User"} + request.state.oidc_session = {"id": 42, "user_id": 7} + return await call_next(request) + + app.include_router(auth_router) + c = TestClient(app) + + r = c.post("/auth/chainlit-session", headers={"Authorization": "Bearer stale-or-different-token"}) + + assert r.status_code == 204 + assert not any(CHAINLIT_TOKEN_COOKIE_NAME in cookie for cookie in _set_cookies(r)) + + +def test_clear_chainlit_session_deletes_handoff_cookie(authenticated_client): + authenticated_client.cookies.set(CHAINLIT_AUTH_COOKIE_NAME, "stale-chainlit-jwt", path="/") + + r = authenticated_client.delete("/auth/chainlit-session") + + assert r.status_code == 204 + cookies = _set_cookies(r) + cookie = next(c for c in cookies if CHAINLIT_TOKEN_COOKIE_NAME in c) + assert f"Path={CHAINLIT_TOKEN_COOKIE_PATH}" in cookie + assert "Max-Age=0" in cookie or "expires=" in cookie.lower() + assert any(CHAINLIT_AUTH_COOKIE_NAME in c and "Max-Age=0" in c for c in cookies) diff --git a/ui/src/components/layout/header.test.tsx b/ui/src/components/layout/header.test.tsx index 6245c9843..a9192e9b9 100644 --- a/ui/src/components/layout/header.test.tsx +++ b/ui/src/components/layout/header.test.tsx @@ -1,10 +1,12 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Header } from "./header"; +import { TOKEN_KEY } from "@/lib/api/client"; const authState = vi.hoisted(() => ({ chainlitEnabled: true, + logout: vi.fn(() => false), })); vi.mock("@/lib/auth", () => ({ @@ -16,7 +18,7 @@ vi.mock("@/lib/auth", () => ({ is_admin: true, chainlit_enabled: authState.chainlitEnabled, }, - logout: vi.fn(), + logout: authState.logout, }), })); @@ -24,31 +26,94 @@ vi.mock("@/components/ui/sidebar", () => ({ SidebarTrigger: () => , })); +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + }, +})); + +function fakeResponse(status = 204): Response { + return { + status, + ok: status >= 200 && status < 300, + headers: { get: () => null }, + text: async () => "", + json: async () => ({}), + } as unknown as Response; +} + describe("Header", () => { + const fetchMock = vi.fn(); + const openMock = vi.fn(); + beforeEach(() => { authState.chainlitEnabled = true; vi.stubEnv("VITE_API_BASE_URL", ""); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("open", openMock); + localStorage.clear(); + fetchMock.mockReset(); + openMock.mockReset(); + authState.logout.mockImplementation(() => false); + fetchMock.mockResolvedValue(fakeResponse()); }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllEnvs(); + vi.unstubAllGlobals(); }); - it("links to the Chainlit chat", () => { + it("prepares a Chainlit session before opening chat", async () => { + const openedWindow = { opener: {}, location: { href: "" } }; + openMock.mockReturnValue(openedWindow); + localStorage.setItem(TOKEN_KEY, "or-user-token"); + render(
, ); - const chatLink = screen.getByRole("link", { name: /chat/i }); - expect(chatLink.getAttribute("href")).toBe("/chainlit/"); - expect(chatLink.getAttribute("target")).toBe("_blank"); - expect(chatLink.getAttribute("rel")).toBe("noopener noreferrer"); + fireEvent.click(screen.getByRole("button", { name: /open chat in a new tab/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/auth/chainlit-session", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ Authorization: "Bearer or-user-token" }), + }), + ); + expect(openedWindow.location.href).toBe("/chainlit/"); + }); + expect(openMock).toHaveBeenCalledWith("about:blank", "_blank"); + expect(openedWindow.opener).toBeNull(); }); - it("uses the configured API origin for Chainlit in browser-direct builds", () => { + it("uses the configured API origin for Chainlit in browser-direct builds", async () => { vi.stubEnv("VITE_API_BASE_URL", "https://api.example.test"); + const openedWindow = { opener: {}, location: { href: "" } }; + openMock.mockReturnValue(openedWindow); + + render( + +
+ , + ); + + fireEvent.click(screen.getByRole("button", { name: /open chat in a new tab/i })); + + await waitFor(() => { + expect(openedWindow.location.href).toBe("https://api.example.test/chainlit/"); + }); + }); + + it("opens chat after the handoff timeout if the session request stalls", async () => { + vi.useFakeTimers(); + const openedWindow = { opener: {}, location: { href: "" } }; + openMock.mockReturnValue(openedWindow); + fetchMock.mockReturnValue(new Promise(() => undefined)); render( @@ -56,8 +121,13 @@ describe("Header", () => { , ); - const chatLink = screen.getByRole("link", { name: /open chat in a new tab/i }); - expect(chatLink.getAttribute("href")).toBe("https://api.example.test/chainlit/"); + fireEvent.click(screen.getByRole("button", { name: /open chat in a new tab/i })); + + expect(openedWindow.location.href).toBe(""); + + await vi.advanceTimersByTimeAsync(3000); + + expect(openedWindow.location.href).toBe("/chainlit/"); }); it("hides the Chainlit chat link when Chainlit is disabled", () => { @@ -69,6 +139,53 @@ describe("Header", () => { , ); - expect(screen.queryByRole("link", { name: /chat/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /open chat in a new tab/i })).toBeNull(); + }); + + it("clears the Chainlit handoff cookie before token logout", async () => { + localStorage.setItem(TOKEN_KEY, "or-user-token"); + authState.logout.mockImplementation(() => { + localStorage.removeItem(TOKEN_KEY); + return false; + }); + + render( + +
+ , + ); + + fireEvent.click(screen.getByRole("button", { name: /log out/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/auth/chainlit-session", + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ Authorization: "Bearer or-user-token" }), + }), + ); + }); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); + }); + + it("does not wait for Chainlit cookie cleanup before local token logout", () => { + localStorage.setItem(TOKEN_KEY, "or-user-token"); + authState.logout.mockImplementation(() => { + localStorage.removeItem(TOKEN_KEY); + return false; + }); + fetchMock.mockReturnValue(new Promise(() => undefined)); + + render( + +
+ , + ); + + fireEvent.click(screen.getByRole("button", { name: /log out/i })); + + expect(authState.logout).toHaveBeenCalled(); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); }); }); diff --git a/ui/src/components/layout/header.tsx b/ui/src/components/layout/header.tsx index 0bc77b8d4..0218ccfa8 100644 --- a/ui/src/components/layout/header.tsx +++ b/ui/src/components/layout/header.tsx @@ -1,19 +1,66 @@ import { useAuth } from "@/lib/auth"; -import { apiUrl } from "@/lib/api/client"; +import { apiUrl, request, TOKEN_KEY } from "@/lib/api/client"; import { useNavigate } from "react-router-dom"; import { LogOut, MessageSquare } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { toast } from "sonner"; + +const CHAT_HANDOFF_TIMEOUT_MS = 3000; export function Header() { const { user, logout } = useAuth(); const navigate = useNavigate(); const chatHref = apiUrl("/chainlit/"); - const handleLogout = () => { + const handleOpenChat = async () => { + const chatWindow = window.open("about:blank", "_blank"); + if (chatWindow) { + chatWindow.opener = null; + } + + try { + const result = await Promise.race([ + request("/auth/chainlit-session", { method: "POST" }) + .then(() => "ready" as const) + .catch(() => "failed" as const), + new Promise<"timeout">((resolve) => { + window.setTimeout(() => resolve("timeout"), CHAT_HANDOFF_TIMEOUT_MS); + }), + ]); + if (result !== "ready") { + throw new Error("Chainlit handoff was not ready"); + } + } catch { + // Keep the previous fallback behavior: if the handoff cannot be prepared, + // still let Chainlit handle its own login flow. + toast.error("Could not prepare Chat session. Opening Chat login instead."); + } + + if (chatWindow) { + chatWindow.location.href = chatHref; + } else { + window.location.assign(chatHref); + } + }; + + const handleLogout = async () => { + const token = localStorage.getItem(TOKEN_KEY); + const clearChainlitSession = + token !== null + ? request("/auth/chainlit-session", { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }).catch(() => { + // Local token logout should still complete even if the optional + // chat cookie cleanup request fails. + }) + : undefined; + const wasOidcSession = logout(); + void clearChainlitSession; if (wasOidcSession) { // Full-page navigation to the backend's RP-initiated logout: it revokes // the server session, clears the cookie, and redirects on to the IdP. @@ -34,11 +81,9 @@ export function Header() { - Open Chat in a new tab @@ -52,7 +97,13 @@ export function Header() { {user.is_admin ? "Admin" : "User"} - diff --git a/ui/src/lib/auth.test.tsx b/ui/src/lib/auth.test.tsx index 7211c1964..1ed0036c4 100644 --- a/ui/src/lib/auth.test.tsx +++ b/ui/src/lib/auth.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from "vitest"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; import type { ReactNode } from "react"; import { AuthProvider, useAuth } from "./auth"; @@ -9,14 +9,25 @@ import { getMyInfo, type MyInfo } from "./api/account"; const mockInfo = getMyInfo as unknown as ReturnType; const wrapper = ({ children }: { children: ReactNode }) => {children}; +const fetchMock = vi.fn(); function user(is_admin: boolean): MyInfo { return { id: 1, display_name: "Test", is_admin, file_quota: null }; } +beforeEach(() => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ logged_out: false }), + }); + vi.stubGlobal("fetch", fetchMock); +}); + afterEach(() => { localStorage.clear(); + document.cookie = "openrag_chainlit_logout=; Max-Age=0; path=/"; vi.clearAllMocks(); + vi.unstubAllGlobals(); }); describe("useAuth (token model)", () => { @@ -99,4 +110,58 @@ describe("useAuth (token model)", () => { expect(wasOidc).toBe(true); expect(result.current.isAuthenticated).toBe(false); }); + + it("clears token auth when Chainlit logout signal is present on load", async () => { + localStorage.setItem("openrag_token", "or-abc123"); + document.cookie = "openrag_chainlit_logout=1; path=/"; + mockInfo.mockResolvedValue(user(true)); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(localStorage.getItem("openrag_token")).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + expect(document.cookie).not.toContain("openrag_chainlit_logout=1"); + expect(mockInfo).not.toHaveBeenCalled(); + }); + + it("clears an already-open Admin UI tab after Chainlit logout", async () => { + localStorage.setItem("openrag_token", "or-abc123"); + mockInfo.mockResolvedValue(user(true)); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + + document.cookie = "openrag_chainlit_logout=1; path=/"; + await act(async () => { + window.dispatchEvent(new Event("focus")); + }); + + await waitFor(() => expect(localStorage.getItem("openrag_token")).toBeNull()); + expect(localStorage.getItem("openrag_token")).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + expect(document.cookie).not.toContain("openrag_chainlit_logout=1"); + }); + + it("clears token auth when the API reports a Chainlit logout signal", async () => { + localStorage.setItem("openrag_token", "or-abc123"); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ logged_out: true }), + }); + mockInfo.mockResolvedValue(user(true)); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(fetchMock).toHaveBeenCalledWith( + "/auth/chainlit-logout-signal", + expect.objectContaining({ + credentials: "include", + headers: expect.objectContaining({ "x-openrag-chainlit-logout-signal": "1" }), + }), + ); + expect(localStorage.getItem("openrag_token")).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + expect(mockInfo).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/lib/auth.tsx b/ui/src/lib/auth.tsx index 03b06a8db..c1ed0168f 100644 --- a/ui/src/lib/auth.tsx +++ b/ui/src/lib/auth.tsx @@ -7,7 +7,51 @@ import { type ReactNode, } from "react"; import { getMyInfo, type MyInfo } from "./api/account"; -import { TOKEN_KEY } from "./api/client"; +import { apiUrl, TOKEN_KEY } from "./api/client"; + +const CHAINLIT_LOGOUT_COOKIE_NAME = "openrag_chainlit_logout"; +const CHAINLIT_LOGOUT_SIGNAL_HEADER = "x-openrag-chainlit-logout-signal"; + +type ChainlitLogoutSignalResponse = { + logged_out?: boolean; +}; + +function hasCookie(name: string): boolean { + if (typeof document === "undefined") return false; + return document.cookie.split(";").some((cookie) => cookie.trim().startsWith(`${name}=`)); +} + +function clearCookie(name: string) { + if (typeof document === "undefined") return; + document.cookie = `${name}=; Max-Age=0; path=/; SameSite=Lax`; +} + +function consumeLocalChainlitLogoutSignal(): boolean { + if (!hasCookie(CHAINLIT_LOGOUT_COOKIE_NAME)) return false; + clearCookie(CHAINLIT_LOGOUT_COOKIE_NAME); + return true; +} + +async function consumeRemoteChainlitLogoutSignal(): Promise { + const response = await fetch(apiUrl("/auth/chainlit-logout-signal"), { + credentials: "include", + headers: { + [CHAINLIT_LOGOUT_SIGNAL_HEADER]: "1", + }, + }); + if (!response.ok) return false; + const body = (await response.json()) as ChainlitLogoutSignalResponse; + return body.logged_out === true; +} + +async function consumeChainlitLogoutSignal(): Promise { + if (consumeLocalChainlitLogoutSignal()) return true; + try { + return await consumeRemoteChainlitLogoutSignal(); + } catch { + return false; + } +} // Identity comes from the backend, not a decoded token: /users/info resolves the // current principal from either a stored bearer token (AUTH_MODE=token) or the @@ -34,21 +78,44 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + const applyExternalLogout = useCallback(async () => { + if (!(await consumeChainlitLogoutSignal())) return false; + localStorage.removeItem(TOKEN_KEY); + setUser(null); + return true; + }, []); + const load = useCallback(async () => { setIsLoading(true); try { + if (await applyExternalLogout()) return; setUser(await getMyInfo()); } catch { setUser(null); } finally { setIsLoading(false); } - }, []); + }, [applyExternalLogout]); useEffect(() => { void load(); }, [load]); + useEffect(() => { + const handleFocus = () => { + void applyExternalLogout(); + }; + const handleVisibilityChange = () => { + if (!document.hidden) void applyExternalLogout(); + }; + window.addEventListener("focus", handleFocus); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.removeEventListener("focus", handleFocus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [applyExternalLogout]); + const loginWithToken = useCallback(async (token: string) => { localStorage.setItem(TOKEN_KEY, token.trim()); try {