Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
903f8f3
feat: add exponential-backoff retries to loader worker pools
EnjoyBacon7 Apr 14, 2026
c565cfa
style: ruff format ray_utils
EnjoyBacon7 Apr 14, 2026
a3f5070
Merge pull request #304 from linagora/MarkerRetries
Ahmath-Gadji Apr 15, 2026
7b22ca6
feat(auth): add OpenID Connect authentication mode
guimard Apr 17, 2026
fb91921
refactor(auth): simplify OIDC matching to sub-only + add claim mapping
guimard Apr 17, 2026
78e6a42
fix(tests): authlib JsonWebToken() needs algorithms + respx MockRouter
guimard Apr 17, 2026
30060db
feat(auth): add GET / root redirect to avoid 404 after OIDC login
guimard Apr 17, 2026
ee60761
fix(auth): address CI + Copilot review
guimard Apr 17, 2026
f8f8e97
Add docs/sso-quickstart.md
guimard Apr 17, 2026
738cef4
fix(tests): force kid on JWKS key + add SSO quick-start doc
guimard Apr 17, 2026
6fa0c13
refactor(auth): no default for OIDC_POST_LOGOUT_REDIRECT_URI
guimard Apr 17, 2026
1919736
Add AUTH_MODE=oidc
guimard Apr 17, 2026
d70048f
style(tests): ruff format test_oidc_lifecycle.py + gitignore compose …
guimard Apr 17, 2026
d6fd8e7
Add warnings to SSO doc
guimard Apr 17, 2026
cac96f0
feat(deploy): INDEXERUI_BASE_PATH build-arg + submodule bump
guimard Apr 17, 2026
5b90fd6
fix(migrations): make schema migrations idempotent
Ahmath-Gadji Apr 20, 2026
3caa316
refactor(migrations): extract shared schema-inspection helpers
Ahmath-Gadji Apr 20, 2026
912737f
docs(oidc): clarify OIDC_REDIRECT_URI for proxy vs direct access
Ahmath-Gadji Apr 20, 2026
c4b2f8f
fix(migrations): rewrite workspace_files file_fk update without forwa…
Ahmath-Gadji Apr 20, 2026
0bdf8ee
Merge pull request #316 from linagora/docs/oidc-redirect-uri-guidance
Ahmath-Gadji Apr 20, 2026
c20ade3
docs(indexerui): document INDEXERUI_BASE_PATH
Ahmath-Gadji Apr 20, 2026
5ac78aa
Merge pull request #310 from linagora/feat/indexerui-base-path
Ahmath-Gadji Apr 20, 2026
65c5a71
Merge pull request #315 from linagora/fix/sql_migration
Ahmath-Gadji Apr 20, 2026
dc85736
feat: translate Chainlit chat profile descriptions using i18n system
Ahmath-Gadji Apr 2, 2026
aa64a0e
feat: unify language env var to DEFAULT_LANGUAGE for Chainlit and Ind…
Ahmath-Gadji Apr 19, 2026
fb0ce12
Merge pull request #314 from linagora/front/language_translation
Ahmath-Gadji Apr 20, 2026
b980157
feat(users): allow users to regenerate their own API token
guimard Apr 19, 2026
fec1aa0
Merge pull request #318 from linagora/feat/regenerate-own-api-token
EnjoyBacon7 Apr 20, 2026
21ae76c
fix(pdf): close PdfDocument via try/finally (no ctx mgr in 4.30)
paultranvan Apr 20, 2026
da655cf
fix: only enforce token limit in direct LLM mode
Ahmath-Gadji Apr 16, 2026
19c5208
chore(submodule): bump indexer-ui to e4bf5d5 (6 commits from main)
paultranvan Apr 20, 2026
6d7cc4b
chore(submodule): bump indexer-ui to 239eb04
paultranvan Apr 20, 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
50 changes: 48 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ VLM_MODEL=
# Set to true, it will mount chainlit chat ui to the fastapi app (Default: true)
## WITH_CHAINLIT_UI=true

# UI language for Chainlit and Indexer UI (e.g. en-US, fr).
# When unset, the browser language is used, with en-US as the final fallback.
## DEFAULT_LANGUAGE=fr

# EMBEDDER
EMBEDDER_MODEL_NAME=jinaai/jina-embeddings-v3 # or other embedder from huggingface compatible with vllm
# EMBEDDER_BASE_URL=http://vllm:8000/v1
Expand Down Expand Up @@ -51,8 +55,13 @@ RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 # critical with the newest version of UV

INCLUDE_CREDENTIALS=false # set true if fastapi authentification is enabled, i.e AUTH_TOKEN is set
INDEXERUI_PORT=8060 # Port to expose the Indexer UI (default is 3042)
INDEXERUI_URL='http://X.X.X.X:INDEXERUI_PORT'
API_BASE_URL='http://X.X.X.X:APP_PORT' # Base URL of your FastAPI backend.
INDEXERUI_URL='http://X.X.X.X:INDEXERUI_PORT'
API_BASE_URL='http://X.X.X.X:APP_PORT' # Base URL of your FastAPI backend.
# Mount the indexer-ui under a subpath (e.g. `/indexerui`) on a shared vhost
# that also hosts the backend. Leave EMPTY for root-level deployment (default).
# Consumed as a Docker build ARG — run `docker compose build indexer-ui`
# after changing this value; it is NOT read at runtime.
# INDEXERUI_BASE_PATH=/indexerui

# Web Search
# WEBSEARCH_API_TOKEN= # Web search provider API token. If unset, web search is silently disabled.
Expand All @@ -66,3 +75,40 @@ LOG_LEVEL=DEBUG # See possible values https://loguru.readthedocs.io/en/stable/ap
# SERVER
# Set the preferred URL scheme for generated URLs (e.g., task_status_url).
PREFERRED_URL_SCHEME=https

# ============================================================================
# Authentication
# ============================================================================
# AUTH_MODE controls how users authenticate.
# - token (default): Bearer AUTH_TOKEN-based auth (legacy).
# - oidc: OpenID Connect flow (auth code + PKCE).
# AUTH_MODE=token

# --- OIDC (only required when AUTH_MODE=oidc) ---
# Issuer URL — MUST match EXACTLY the "issuer" field returned by the IdP's
# /.well-known/openid-configuration (trailing slash matters, per OIDC spec).
# Keycloak typically returns no trailing slash; LemonLDAP::NG and Auth0 return one.
# Check with: curl -s <endpoint>/.well-known/openid-configuration | jq -r .issuer
# OIDC_ENDPOINT=https://idp.example.com/realms/openrag
# OIDC_CLIENT_ID=openrag
# OIDC_CLIENT_SECRET=change-me
# Callback URL on OpenRag's BACKEND. Must match the IdP client config byte-for-byte.
# Behind a reverse proxy: use the public URL (e.g. https://openrag.example.com/auth/callback).
# Direct access (no proxy): use host + APP_PORT (e.g. http://<ip_addr>:<APP_PORT>/auth/callback).
# OIDC_REDIRECT_URI=https://openrag.example.com/auth/callback
# OIDC_SCOPES=openid email profile offline_access # include offline_access for refresh tokens (persistent sessions)
# Generate a Fernet key once: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# OIDC_TOKEN_ENCRYPTION_KEY=
# Where the IdP sends the user after RP-initiated logout. No default —
# pointing this to an OpenRag URL causes an instant re-auth loop. Prefer
# a URL OUTSIDE OpenRag (corporate intranet, static 'bye' page, IdP home).
# OIDC_POST_LOGOUT_REDIRECT_URI=https://intranet.example.com/
# --- Optional claim mapping ---
# Where to read claims from when OIDC_CLAIM_MAPPING is set.
# 'id_token' (default) — read from the verified ID-token claims (no extra HTTP).
# 'userinfo' — fetch the IdP /userinfo endpoint (more complete, non-standard claims).
# OIDC_CLAIM_SOURCE=id_token
# CSV of `db_field:claim` pairs to sync into the users row on every login.
# Writable fields whitelist: display_name, email (never is_admin / external_user_id / file_quota / token).
# Leave unset to keep user rows untouched after login.
# OIDC_CLAIM_MAPPING=display_name:name,email:email
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,7 @@ docs/plans/

# Build artifacts
*.egg-info/

# Local developer overrides (not committed)
docker-compose.override.yaml
docker-compose.override.yml
96 changes: 96 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ Per-user file quota enforcement tracked via the `file_count` and `file_quota` co

**Migration:** `openrag/scripts/migrations/alembic/versions/c224d4befe71_add_file_count_and_file_quota.py`

### Alembic Migration Idempotency

`Base.metadata.create_all()` runs at app startup (`PartitionFileManager.__init__` in `openrag/components/indexer/vectordb/utils.py`), so a freshly bootstrapped database already contains the full current-model schema before alembic ever touches it. Migrations must therefore be **idempotent** — re-applying an `ADD COLUMN` / `CREATE TABLE` / `CREATE INDEX` against an already-existing object would raise `DuplicateColumn` / `DuplicateTable`.

Guard every schema-mutating op with an inspector-based existence check (`table_exists`, `column_exists`, `index_exists`, `fk_exists`), in both `upgrade()` and `downgrade()`. For migrations that convert a column type, also short-circuit if the column is already the target type.

### Configuration

Configuration uses Hydra with YAML files in `.hydra_config/`:
Expand Down Expand Up @@ -323,3 +329,93 @@ from config import load_config
# Avoid relative imports across packages
# from .ray_utils import ... # Only within same package
```

### OIDC Authentication (OpenID Connect)

OpenRag supports two authentication modes, controlled by the `AUTH_MODE` environment variable:

**Token Mode** (`AUTH_MODE=token`, default):
- Bearer token authentication via `Authorization: Bearer <AUTH_TOKEN>` header
- Existing behavior unchanged
- Suitable for programmatic access, CI/CD, and testing
- Admin user (id=1) created with `AUTH_TOKEN` env var or random token on bootstrap

**OIDC Mode** (`AUTH_MODE=oidc`):
- OpenID Connect Authorization Code + PKCE flow
- Users authenticate via an external IdP (Keycloak, LemonLDAP::NG, etc.)
- Browser UI (Chainlit, Indexer) redirects to IdP login
- Opaque session tokens stored in `openrag_session` httpOnly cookie
- Bearer `users.token` still accepted for programmatic access

**Env Variables** (required when `AUTH_MODE=oidc`):

| Variable | Purpose | Example |
|----------|---------|---------|
| `OIDC_ENDPOINT` | Issuer URL for auto-discovery | `https://idp.example.com/realms/openrag` |
| `OIDC_CLIENT_ID` | Client registered at IdP | `openrag` |
| `OIDC_CLIENT_SECRET` | Client secret | (provided by IdP) |
| `OIDC_REDIRECT_URI` | Callback URL (must match IdP config) | `https://openrag.example.com/auth/callback` |
| `OIDC_TOKEN_ENCRYPTION_KEY` | Fernet key for token encryption | (generate via: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`) |

**Optional Env Variables**:

| Variable | Default | Purpose |
|----------|---------|---------|
| `OIDC_CLAIM_SOURCE` | `id_token` | Where to read claims for claim mapping: `id_token` (verified JWT) or `userinfo` (`/userinfo` endpoint) |
| `OIDC_CLAIM_MAPPING` | (none) | CSV of `db_field:claim` pairs to sync IdP claims into the users row on every login (whitelist: `display_name`, `email`). Unset = no post-login update. |
| `OIDC_SCOPES` | `openid email profile offline_access` | Space-separated scope list (include `offline_access` for refresh tokens) |
| `OIDC_POST_LOGOUT_REDIRECT_URI` | — | URL the IdP sends the user to after RP-initiated logout. No default (an OpenRag URL would re-trigger OIDC login) |

**User Matching & Provisioning**:

When a user logs in via OIDC, matching is **exclusively** by `users.external_user_id == sub` (the stable OIDC claim). There is no email fallback and no auto-provisioning: if the `sub` is unknown, the callback returns `403 "User not registered"`. Admins MUST pre-create every user with the expected `external_user_id`.

Optionally, if `OIDC_CLAIM_MAPPING` is set, after a successful match the callback reads the configured claims (from the ID token or `/userinfo`, per `OIDC_CLAIM_SOURCE`) and updates the user row. The writable whitelist is strict — only `display_name` and `email` are allowed; `is_admin`, `external_user_id`, `file_quota`, `token` are never writable via claim mapping.

**Admin Pre-provisioning**: Admins create users with the `external_user_id` matching the IdP's `sub` claim for that user. Example:
```bash
curl -X POST http://localhost:8080/users/ \
-H "Authorization: Bearer <AUTH_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"display_name": "Alice", "external_user_id": "kc-alice-uuid", "is_admin": false}'
```

**Database Schema**:

Columns on `users` table relevant to OIDC:
- `external_user_id` (String, unique, nullable): Must equal the IdP's `sub` for OIDC matching
- `email` (String, unique, nullable): Pure metadata; populated manually or via claim mapping. Not used for matching.

New table `oidc_sessions`:
- `session_token_hash` (unique): SHA-256 of the opaque session token
- `user_id` (FK): User this session belongs to
- `sid` (nullable): OIDC session identifier (used for back-channel logout)
- `sub` (required): OIDC `sub` claim (stable user identifier)
- `id_token_encrypted`, `access_token_encrypted`, `refresh_token_encrypted`: Fernet-encrypted IdP tokens
- `access_token_expires_at`, `session_expires_at`: Token expiry times
- `revoked_at` (nullable): Set on back-channel logout or manual revocation

**Auth Endpoints** (all bypass the normal middleware):

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/auth/login` | Start Authorization Code + PKCE flow; redirects to IdP |
| GET | `/auth/callback` | IdP callback; creates session, sets cookie; redirects to `next_url` |
| POST | `/auth/backchannel-logout` | IdP-driven logout (OIDC spec); revokes sessions by `sid` |
| GET | `/auth/logout` | RP-initiated logout; invalidates session + redirects to IdP |
| GET | `/auth/me` | (debug) Returns current user and session expiry |

**Session Management**:

- Session token: URL-safe opaque token (`secrets.token_urlsafe(32)` — ~43 chars from 32 bytes of randomness), hashed (SHA-256) before storage
- Cookie: `openrag_session` (httpOnly, Secure if HTTPS, SameSite=Lax, Path=/, no Domain=)
- TTL: Aligned with `access_token_expires_at`; auto-refresh if `refresh_token` available (<60s before expiry)
- Revocation: Via back-channel logout or manual invalidation

**Middleware Behavior**:

- UI paths (`/`, `/chainlit`, `/static`) without auth → 302 redirect to `/auth/login?next=...`
- API paths (`/v1`, `/indexer`, `/search`, etc.) without auth → 401 JSON response
- Programmtic access: Bearer `users.token` accepted in both modes

**See Also**: Full configuration and troubleshooting guide at `docs/oidc.md`.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,17 @@ Once the app is up and running, visit `http://localhost:APP_PORT` or `http:X.X.X
>[!NOTE]
> Chainlit UI has no authentication by default. To enable it, follow the [dedicated guide](./docs/setup_chainlit_ui_auth.md). The same goes for chat data persistancy, enable it with this [guide](docs/chainlit_data_persistency.md)

#### Authentication Modes

OpenRag supports two authentication modes:

- **Token Mode** (`AUTH_MODE=token`, default): Bearer token authentication via `Authorization: Bearer <AUTH_TOKEN>` header. Suitable for development and programmatic access.
- **OIDC Mode** (`AUTH_MODE=oidc`): OpenID Connect flow with an external identity provider (Keycloak, LemonLDAP::NG, etc.). Users authenticate via browser redirect to the IdP.

To enable OIDC, set `AUTH_MODE=oidc` and configure the required OIDC variables (see [`.env.example`](./.env.example) for the full list).

For comprehensive OIDC setup and configuration, see the [OIDC Authentication Guide](./docs/oidc.md).

3. `http://localhost:INDEXERUI_PORT` to access the indexer ui for easy document ingestion, indexing, and management

#### 5. Distributed deployment in a Ray cluster
Expand Down
12 changes: 12 additions & 0 deletions conf/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ loader:
whisper_n_workers: 3
whisper_num_gpus: 0.01
whisper_concurrency_per_worker: 2
# Whisper failures are often deterministic (corrupt audio); 1 retry
# covers transient GPU OOM without wasting time on bad inputs.
whisper_timeout: 1800
whisper_max_task_retry: 1
whisper_retry_base_delay: 2.0

# Env: PDFLoader, AUDIOLOADER (per-extension overrides)
file_loaders:
Expand Down Expand Up @@ -188,11 +193,18 @@ loader:
# Split large PDFs into chunks of this many pages for parallel processing across workers.
# 0 = no chunking (process entire PDF in one worker).
marker_chunk_size: 10
# Retry a failed chunk up to marker_max_task_retry times with exponential backoff
# (delay = marker_retry_base_delay * 2**attempt).
marker_max_task_retry: 3
marker_retry_base_delay: 2.0

# Env: DOCLING_NUM_GPUS, DOCLING_POOL_SIZE, DOCLING_MAX_TASKS_PER_WORKER
docling_num_gpus: 0.01
docling_pool_size: 1
docling_max_tasks_per_worker: 2
docling_timeout: 3600
docling_max_task_retry: 3
docling_retry_base_delay: 2.0

# Env: TRANSCRIBER_BASE_URL, TRANSCRIBER_API_KEY, TRANSCRIBER_MODEL,
# TRANSCRIBER_TIMEOUT, TRANSCRIBER_MAX_CONCURRENT_CHUNKS, USE_WHISPER_LANG_DETECTOR
Expand Down
18 changes: 18 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ x-openrag: &openrag_template
build:
context: .
dockerfile: Dockerfile
extra_hosts:
# Let containers reach services running on the Docker host via
# ``host.docker.internal`` (Linux Docker >= 20.10). Useful when an OIDC
# provider runs on the host in AUTH_MODE=oidc. If the IdP is behind a
# custom hostname resolved only in the host's /etc/hosts, add your own
# mapping via a ``docker-compose.override.yaml`` rather than editing this
# list — otherwise real external domains with the same name would be
# shadowed for other users.
- "host.docker.internal:host-gateway"
volumes:
- ${DATA_VOLUME:-./data}:/app/data
- ./i8n:/app/openrag/.chainlit/translations
- ${MODEL_WEIGHTS_VOLUME:-~/.cache/huggingface}:/app/model_weights # Model weights for RAG
- ./openrag:/app/openrag # For dev mode
- /$SHARED_ENV:/ray_mount/.env # Shared environment variables
Expand Down Expand Up @@ -66,9 +76,17 @@ services:
build:
context: ./extern/indexer-ui
dockerfile: Dockerfile
args:
# Mount indexer-ui under a subpath (e.g. /indexerui/) on a shared
# vhost with the backend. Empty = root-level (default). See
# docs/oidc.md §"Single vhost deployment" for the matching nginx
# rules.
BASE_PATH: ${INDEXERUI_BASE_PATH:-}
environment:
- API_BASE_URL=${API_BASE_URL:-http://localhost:${APP_PORT:-8080}}
- INCLUDE_CREDENTIALS=${INCLUDE_CREDENTIALS:-false}
- AUTH_MODE=${AUTH_MODE:-token}
- DEFAULT_LANGUAGE=${DEFAULT_LANGUAGE:-}
ports:
- "${INDEXERUI_PORT:-3042}:3000"
restart: unless-stopped
Expand Down
7 changes: 6 additions & 1 deletion docs/content/docs/documentation/env_vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,4 +502,9 @@ Always set a strong **`AUTH_TOKEN`** in production environments. Never leave it
### Chainlit
[See this](/openrag/documentation/setup_chainlit_ui_auth/) for chainlit authentification

[See this](/openrag/documentation/chainlit_data_persistency/) for chainlit data persistency
[See this](/openrag/documentation/chainlit_data_persistency/) for chainlit data persistency


| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `DEFAULT_LANGUAGE` | `str` | `` | UI language for Chainlit and Indexer UI (e.g. `en-US`, `fr`). When unset, the browser language is used, with `en-US` as the final fallback. |
53 changes: 52 additions & 1 deletion docs/content/docs/documentation/setup_indexerui.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,55 @@ INCLUDE_CREDENTIALS=false # Set to true if FastAPI authentication is enabled
INDEXERUI_PORT=8060 # Port for the Indexer UI (default: 3042)
INDEXERUI_URL='http://X.X.X.X:INDEXERUI_PORT'
API_BASE_URL='http://X.X.X.X:APP_PORT'
```
```

### 3. Mount the Indexer UI under a Subpath (optional)

By default the Indexer UI is served at the root of its own origin (`http://host:INDEXERUI_PORT/`). If you want to expose it **on the same vhost as the backend** (behind a reverse proxy), you can mount it under a subpath via `INDEXERUI_BASE_PATH`. The typical reason is to keep the frontend and the backend same-origin.
With this setup, the backend's session cookie is first-party to the UI (this matters when `AUTH_MODE=oidc`, where a cross-origin `openrag_session` cookie is dropped by the browser).

```bash
# Empty (default) → root-level deployment.
# Set to mount under a subpath — leading slash required, no trailing slash.
INDEXERUI_BASE_PATH=/indexerui
```

:::caution[Rebuild required]
`INDEXERUI_BASE_PATH` is consumed as a **Docker build ARG** (passed to the `indexer-ui` Dockerfile via `docker-compose.yaml`), not a runtime variable. The SvelteKit app bakes the value into every asset URL and every API call at build time. After changing it you must rebuild the image:

```bash
docker compose build indexer-ui
docker compose up -d indexer-ui
```
:::

#### Example nginx rules (single vhost)

```nginx
server {
listen 443 ssl http2;
server_name rag.example.com;

# ... TLS config ...

# Indexer UI mounted under /indexerui/
location /indexerui/ {
proxy_pass http://indexer-ui:3000/;
proxy_set_header Host $host;
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 $scheme;
}

# Everything else → backend (API, /auth/*, /chainlit, /static, ...)
location / {
proxy_pass http://openrag:8080;
proxy_set_header Host $host;
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 $scheme;
}
}
```

The trailing slash on `proxy_pass http://indexer-ui:3000/;` is load-bearing — it strips the `/indexerui` prefix before forwarding, leaving the SvelteKit app to re-add its `base` via `INDEXERUI_BASE_PATH`.
Loading
Loading