Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
173125a
fix(auth): bridge token users into chainlit
hedhoud Jul 15, 2026
201bccf
fix(auth): harden chainlit token handoff
hedhoud Jul 15, 2026
e356623
fix(auth): enable chainlit handoff in token mode
hedhoud Jul 15, 2026
5340d5d
fix(auth): cover chainlit handoff edge cases
hedhoud Jul 15, 2026
de3f8e5
fix(auth): handle chainlit handoff fallbacks
hedhoud Jul 15, 2026
6cba8ba
fix(auth): preserve chainlit token login form
hedhoud Jul 15, 2026
15c9f81
test(auth): cover chainlit cookie fallback
hedhoud Jul 15, 2026
d39cbab
fix(auth): fail closed on missing chainlit token
hedhoud Jul 15, 2026
63d1839
fix(auth): avoid stale chainlit bearer handoff
hedhoud Jul 15, 2026
5d70c4e
fix(auth): prefer chainlit handoff for token sessions
hedhoud Jul 15, 2026
31826b0
fix(auth): avoid session fallback for chainlit handoff
hedhoud Jul 15, 2026
0f8911f
fix(admin-ui): proxy chainlit websocket upgrades
hedhoud Jul 15, 2026
d2ccc60
fix(auth): handle chainlit handoff expiry safely
hedhoud Jul 15, 2026
9afd88c
fix(auth): handle expired chainlit profile sessions
hedhoud Jul 15, 2026
6e0c304
fix(auth): enable chainlit handoff in token mode
hedhoud Jul 15, 2026
b57bb93
fix(auth): preserve chainlit partition profiles
hedhoud Jul 15, 2026
cb04b22
fix(auth): refresh stale chainlit sessions
hedhoud Jul 15, 2026
5fc309c
fix(auth): clear openrag cookies on chainlit logout
hedhoud Jul 15, 2026
88648b5
fix(auth): return valid chainlit logout response
hedhoud Jul 15, 2026
fe3969d
fix(auth): sync chainlit logout with admin ui
hedhoud Jul 15, 2026
3aadecc
fix(auth): share chainlit logout signal with admin ui
hedhoud Jul 15, 2026
3503774
fix(auth): guard chainlit logout signal consumption
hedhoud Jul 15, 2026
0dda10c
Merge remote-tracking branch 'origin/develop' into tmp/pr656-update-2…
hedhoud Jul 15, 2026
80dd9cd
fix(auth): fall back to email/id when chainlit display_name is null
Ahmath-Gadji Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions infra/compose/nginx/openrag-admin.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions openrag/api/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
103 changes: 100 additions & 3 deletions openrag/api/routers/auth/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -66,17 +78,50 @@ 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:
_delete_state_cookie(r)
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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Comment thread
hedhoud marked this conversation as resolved.
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
Comment thread
hedhoud marked this conversation as resolved.

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


Expand Down
Loading
Loading