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 {