Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,37 @@ 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
# 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
90 changes: 90 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix typo in auth behavior note.

Line 413 has a typo: ProgrammticProgrammatic.

Suggested doc fix
-- Programmtic access: Bearer `users.token` accepted in both modes
+- Programmatic access: Bearer `users.token` accepted in both modes
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Programmtic access: Bearer `users.token` accepted in both modes
- Programmatic access: Bearer `users.token` accepted in both modes
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` at line 413, Fix the typo in CLAUDE.md by replacing the string
"Programmtic access: Bearer `users.token` accepted in both modes" with
"Programmatic access: Bearer `users.token` accepted in both modes" so the auth
behavior note reads correctly.


**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
10 changes: 10 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ 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
- ${MODEL_WEIGHTS_VOLUME:-~/.cache/huggingface}:/app/model_weights # Model weights for RAG
Expand Down Expand Up @@ -69,6 +78,7 @@ services:
environment:
- API_BASE_URL=${API_BASE_URL:-http://localhost:${APP_PORT:-8080}}
- INCLUDE_CREDENTIALS=${INCLUDE_CREDENTIALS:-false}
- AUTH_MODE=${AUTH_MODE:-token}
ports:
- "${INDEXERUI_PORT:-3042}:3000"
restart: unless-stopped
Expand Down
Loading
Loading