Skip to content

fix: forward end-user JWT to MCP/API callers via gateway-placed X-OpenRAG-API-JWT header#1874

Merged
edwinjosechittilappilly merged 3 commits into
mainfrom
fix/mcp-api-jwt-header
Jun 15, 2026
Merged

fix: forward end-user JWT to MCP/API callers via gateway-placed X-OpenRAG-API-JWT header#1874
edwinjosechittilappilly merged 3 commits into
mainfrom
fix/mcp-api-jwt-header

Conversation

@edwinjosechittilappilly

@edwinjosechittilappilly edwinjosechittilappilly commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

MCP tool calls against the /v1/* surface fail with 403/401:

  • openrag_get_settings / update_settings / list_models → 403 (providers:read, config:write)
  • openrag_search, knowledge-filter reads/deletes → "403 (401 Unauthorized)" / 404

Root cause: every /v1 request arriving via /mcp logs jwt_present: false. FastMCP's from_fastapi proxy rebuilds the request to the underlying /v1 handler using get_http_headers(), whose hardcoded exclude set contains authorization — so the gateway-forwarded user JWT is stripped before it reaches the auth dependency. The caller then falls back to IBM lakehouse Basic credentials, which (a) carry no openrag_roles → RBAC 403, and (b) are rejected by OpenSearch → AuthenticationException(401).

This is a proxy-layer issue, not a Traefik misconfiguration: the same JWT arrives fine (jwt_present: true) on UI/session routes where it isn't proxied through FastMCP.

Fix

Read the JWT from a secondary, gateway-placed header that FastMCP does not strip. In SaaS, the MCP/API caller still authenticates with X-Username + X-Api-Key; Traefik exchanges those for the user JWT and injects it into the add-on header. The /v1 API-key auth path reads it as a fallback to Authorization.

  • config/settings.py: add get_api_jwt_header()OPENRAG_API_JWT_HEADER (default X-OpenRAG-API-JWT).
  • dependencies.py (get_api_key_user_async): use Authorization, then fall back to the add-on header; log/report whichever header actually supplied the token.
  • mcp_http/server.py: correct the auth docstring (Authorization is stripped by the proxy; document the gateway-placed JWT header).

Trust model

The add-on header is gateway-managed, not client-supplied — same trust boundary as today's Authorization. Because claims are decode-only unless OPENRAG_JWT_VERIFY_SIGNATURE=true, the header is trusted only because Traefik mints it. Gateway requirement: Traefik must inject X-OpenRAG-API-JWT on the /mcp route and strip any client-supplied value at the edge so it cannot be forged.

Config

  • OPENRAG_API_JWT_HEADER (default X-OpenRAG-API-JWT) — name of the add-on JWT header.

Testing

  • Existing auth-header unit tests pass (tests/unit/dependencies/test_jwt_header_auth.py, tests/unit/config/test_jwt_auth_header.py, tests/unit/dependencies/test_ibm_header_auth.py).
  • Manual: with Traefik placing X-OpenRAG-API-JWT, call openrag_get_settings via MCP and confirm the [AUTH] API-key path JWT header lookup log flips to jwt_present: true (header_name X-OpenRAG-API-JWT), and openrag_search returns results with no OpenSearch 401.

Follow-ups

  • Add unit coverage for the add-on-header fallback in get_api_key_user_async.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added configurable JWT authentication header support for the /v1 API surface, including gateway-forwarded header fallback.
  • Bug Fixes

    • Improved JWT-in-header authentication behavior, including clearer 401 responses when the user JWT is missing and preventing unintended downstream auth/DB actions in SaaS+RBAC scenarios.
  • Documentation

    • Clarified which authentication headers are supported for MCP→FastAPI and what headers the gateway injects for /v1 handling.
  • Tests

    • Strengthened coverage for “no roles” and missing-JWT RBAC failure paths.

@github-actions github-actions Bot added the backend 🔷 Issues related to backend services (OpenSearch, Langflow, APIs) label Jun 15, 2026
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2cd228a4-68ee-412f-a1f9-f86532e8e5d0

📥 Commits

Reviewing files that changed from the base of the PR and between 9a7b36d and 5c4f651.

📒 Files selected for processing (2)
  • src/dependencies.py
  • tests/unit/dependencies/test_jwt_header_auth.py

Walkthrough

Adds get_api_jwt_header() to settings, reading OPENRAG_API_JWT_HEADER (default X-OpenRAG-API-JWT). In get_api_key_user_async, the JWT header is now selected by falling back to this secondary header when the primary is absent. For SaaS+RBAC deployments, the dependency now fails fast with a missing_user_jwt 401 when no gateway JWT is forwarded, preventing fallthrough to API-key auth. Error messages include the selected header name. Tests validate the fail-fast behavior, and MCP server docs are updated to describe the revised auth header behavior.

Changes

Secondary JWT Header and SaaS+RBAC Fail-Fast Authentication

Layer / File(s) Summary
New get_api_jwt_header() settings accessor
src/config/settings.py
Adds get_api_jwt_header() which reads OPENRAG_API_JWT_HEADER from the environment, defaulting to X-OpenRAG-API-JWT.
JWT header fallback selection and SaaS+RBAC fail-fast
src/dependencies.py
Selects jwt_header by falling back from the primary JWT header to the secondary when the primary value is empty; propagates the chosen header name into debug logs and HTTP 401 error detail strings; adds SaaS+RBAC fail-fast that returns missing_user_jwt 401 when no forwarded JWT is present, blocking fallthrough to API-key auth; includes a clarifying comment for non-saas-rbac paths.
Test updates for SaaS+RBAC fail-fast behavior
tests/unit/dependencies/test_jwt_header_auth.py
Adds assertion that _attach_request_user is never reached when JWT+RBAC role staging fails; replaces the prior no-JWT test with two new tests: one validates that SaaS+RBAC with no gateway JWT fails fast with missing_user_jwt 401 (no DB upserts), and another ensures that even with X-API-Key present, SaaS+RBAC without JWT fails fast with missing_user_jwt without validating the key.
MCP server auth documentation
src/mcp_http/server.py
Rewrites the module docstring to remove Authorization: Bearer as a supported method, explains that FastMCP's proxy strips Authorization headers, and documents X-API-Key for OpenRAG and the gateway-injected X-OpenRAG-API-JWT header for IBM auth.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • langflow-ai/openrag#1697: Both PRs change src/dependencies.py to support gateway-forwarded JWT-in-header authentication for the /v1 path and tie the selected JWT header to a new config accessor in src/config/settings.py (this PR adds get_api_jwt_header() and a fallback, while the retrieved PR adds get_jwt_auth_header() and the JWT verification/role-staging flow).

Suggested labels

tests

Suggested reviewers

  • mfortman11
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: forwarding end-user JWT via a new gateway-placed header to enable MCP/API callers to authenticate properly.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/mcp-api-jwt-header

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added bug 🔴 Something isn't working. and removed bug 🔴 Something isn't working. labels Jun 15, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/config/settings.py`:
- Around line 273-290: The get_api_jwt_header() function does not normalize
empty or whitespace values of the OPENRAG_API_JWT_HEADER environment variable to
the default fallback. When the environment variable is set but contains an empty
string or only whitespace, the function returns that blank value instead of
falling back to the default "X-OpenRAG-API-JWT". Fix this by retrieving the
environment variable value, checking if it is empty or contains only whitespace
using appropriate string methods, and returning the default value in those
cases; otherwise return the retrieved environment variable value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 41667a0c-e678-4a84-bd8e-428b77a93fc9

📥 Commits

Reviewing files that changed from the base of the PR and between 14b1646 and 9a7b36d.

📒 Files selected for processing (3)
  • src/config/settings.py
  • src/dependencies.py
  • src/mcp_http/server.py

Comment thread src/config/settings.py
Comment on lines +273 to +290
def get_api_jwt_header() -> str:
"""Secondary, gateway-placed JWT header for the /v1 API/MCP surface only.

The primary JWT header (``get_jwt_auth_header()``, default ``Authorization``)
is stripped by FastMCP's get_http_headers() before an MCP tool call is
proxied to the underlying /v1 route, so it never reaches the /v1 auth
dependency. The gateway (Traefik) therefore authenticates the MCP/API caller
(who supplies X-Username + X-Api-Key) and injects the minted user JWT into
this add-on header instead, which FastMCP forwards verbatim. Read per-call so
tests can override via monkeypatch.setenv.

TRUST NOTE: this header is read in addition to Authorization on the /v1
surface and is trusted as an identity source. It is gateway-managed: Traefik
mints and injects it, and MUST strip any client-supplied value at the edge
(standard internal-trust-header hygiene) so callers cannot forge it —
important because claims are decode-only unless ``OPENRAG_JWT_VERIFY_SIGNATURE``
is enabled."""
return os.getenv("OPENRAG_API_JWT_HEADER", "X-OpenRAG-API-JWT")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize blank OPENRAG_API_JWT_HEADER values to the default.

If OPENRAG_API_JWT_HEADER is set but empty/whitespace, this returns a blank header name and the fallback JWT lookup will always miss.

Proposed fix
 def get_api_jwt_header() -> str:
@@
-    return os.getenv("OPENRAG_API_JWT_HEADER", "X-OpenRAG-API-JWT")
+    configured = os.getenv("OPENRAG_API_JWT_HEADER", "").strip()
+    return configured or "X-OpenRAG-API-JWT"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config/settings.py` around lines 273 - 290, The get_api_jwt_header()
function does not normalize empty or whitespace values of the
OPENRAG_API_JWT_HEADER environment variable to the default fallback. When the
environment variable is set but contains an empty string or only whitespace, the
function returns that blank value instead of falling back to the default
"X-OpenRAG-API-JWT". Fix this by retrieving the environment variable value,
checking if it is empty or contains only whitespace using appropriate string
methods, and returning the default value in those cases; otherwise return the
retrieved environment variable value.

@github-actions github-actions Bot added the lgtm label Jun 15, 2026
@github-actions github-actions Bot added tests bug 🔴 Something isn't working. and removed bug 🔴 Something isn't working. labels Jun 15, 2026
@edwinjosechittilappilly edwinjosechittilappilly merged commit 7d39bf5 into main Jun 15, 2026
21 of 22 checks passed
@github-actions github-actions Bot deleted the fix/mcp-api-jwt-header branch June 15, 2026 17:50
ricofurtado pushed a commit that referenced this pull request Jun 15, 2026
…nRAG-API-JWT header (#1874)

* use fallback header

* Notes on new header

* raises http exception for missing jwt
ricofurtado pushed a commit that referenced this pull request Jun 25, 2026
…nRAG-API-JWT header (#1874)

* use fallback header

* Notes on new header

* raises http exception for missing jwt
ricofurtado added a commit that referenced this pull request Jul 1, 2026
* feat:  add connectors permissions for Saas (#1782)

* feat: add SaaS workspace connector permissions with RBAC gates
Introduce admin Connectors Permission settings (cloud-only) backed by
workspace_config, enforce availability on connector APIs and OAuth init,
and sync the new connectors:manage:access permission at startup without
an Alembic migration. OSS/dev OSS brand bypasses policy; add dev role
toggle and brand-aware proxy headers for local RBAC testing.

* style: ruff autofix (auto)

* address CodeRabbit comments and fix backend lint unused imports

* fix backend lint

* feat: workspace connector permissions with aligned SaaS policy guards
Add admin Connectors Permission (workspace_config-backed) with RBAC
connectors:manage:access, enforce policy on connector APIs and OAuth init,
and keep the permission list independent of the live connectors tab.
Consolidate frontend connector-access hooks into useGetConnectorsQuery and
settings tab helpers into brand.ts. Fix Connectors Permission tab redirects
(RSC dev-brand default, wait for permissionsResolved). Let explicitly
enabled connectors override deployment visibility filters; add
OPENRAG_DEV_CONNECTOR_POLICY for local OSS dev backend enforcement Snapshot and restore all cached connector query keys in connect/disconnect
mutations so optimistic updates stay correct when policy context changes
mid-mutation (brand toggle, permissions resolving).

* style: ruff autofix (auto)

* addressed coderabbit comments

* remove mentions of dev only envs in the env.example

---------

Co-authored-by: Olfa Maslah <[email protected]>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Edwin Jose <[email protected]>

* fix: google to support WEBHOOK_BASE_URL (#1849)

* Update connector.py

* update env in settings

* fix webhook url of sharepoint

* fix: make connector webhooks work end-to-end (Drive routing, Graph subscriptions, change discovery) (#1867)

* fix: alias legacy /connectors/google/webhook to google_drive and reload connections store on webhook channel lookup miss

* Fix Sharepoint and Google Drive

* fix: propagate deleted items from SharePoint/OneDrive webhook delta query

Main's delete-event coverage (#1852) expects handle_webhook to return
deleted file ids so sync_specific_files can run its deleted-at-source
cleanup (get_file_content -> 404 -> delete indexed chunks). The delta
implementation skipped deleted items; now deleted files propagate and
deleted folders stay excluded.

* use logging _config

* Update test_webhook_type_alias.py

* fix: forward end-user JWT to MCP/API callers via gateway-placed X-OpenRAG-API-JWT header (#1874)

* use fallback header

* Notes on new header

* raises http exception for missing jwt

* feat: Add shared flag for COS ingestion to allow documents to be indexed without an owner

* style: ruff autofix (auto)

* Update src/api/connectors.py

Improving error return

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* feat: Implement shared flag for document replacement in OpenSearch queries

* style: ruff autofix (auto)

* refactor: Remove shared flag from document ingestion options in SDK

* feat: Update resolve_shared_owner_fields to return anonymous user details for shared documents

* style: ruff autofix (auto)

* feat: Add anonymous delete permission for shared documents and update related functionality

* feat: Update delete permission to include anonymous access for shared documents

* style: ruff autofix (auto)

* style: apply ruff autofix

* feat: Restrict shared flag usage to ibm_cos connector in sync_connector_files method

* fix: clarify behavior of documents with owner=null in DLS tests

* fix button types (#1875)

* refactor: no derived state (#1886)

* no derivied state

* coderabbit comments

* OPENSEARCH_NODE_COUNT_CHECK_ENABLED as deafult flag (#1975)

What changed:

  - Renamed the env var read by the backend to OPENSEARCH_NODE_COUNT_CHECK_ENABLED in src/config/settings.py.
  - Updated Docker Compose to pass OPENSEARCH_NODE_COUNT_CHECK_ENABLED in docker-compose.yml.
  - Added the canonical flag to the example env file in .env.example.
  - Documented the flag in the OpenSearch config table in docs/docs/reference/configuration.mdx.
  - Updated the existing readiness test wording in tests/unit/test_opensearch_wait_node_count.py.
  - Added a regression test that loads config.settings with OPENSEARCH_NODE_COUNT_CHECK_ENABLED=false and verifies the canonical flag is what the code reads in tests/unit/config/
    test_opensearch_node_count_check_enabled.py.

* chore: Segment improvements (#1971)

* chore: Segment improvements (#1963)

* Update static properties

* and knowledge/setting actions

* avoid duplicate events

* coderabbit comments

* fix: onboarding timeout cleanup issue (#1977)

* fix the cleanup timeout issue for the embedding step

* final step fix

* feat: add shared parameter to connector_sync function

* feat: add shared parameter to TaskProcessor and update connector_sync for permission checks

* fix: improve response handling in delete_document_endpoint for better status reporting

* fix: clear completeTimeoutRef after completion to prevent memory leaks

* fix: update OPENSEARCH_NODE_COUNT_CHECK_ENABLED to use fallback from OPENSEARCH_NODE_COUNT_CHECK

* fix: onboarding timeout cleanup issue (#1977)

* fix the cleanup timeout issue for the embedding step

* final step fix

* fix: support big csv files on langflow-less ingestion (#1968)

* fix max tokens when splitting for langflow-less ingestion

* fix coderabbit picks

* style: ruff autofix (auto)

* added non lf ingestion test

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: upgrade OpenSearch to 3.6.0 (#1972)

* upgrade OpenSearch to 3.6.0

* fixed PR review comments

* fix nitpicks

* Added ibm watsonx ai and langchain ibm to Langflow image (#1989)

* OPENSEARCH_NODE_COUNT_CHECK_ENABLED as deafult flag (#1975)

What changed:

  - Renamed the env var read by the backend to OPENSEARCH_NODE_COUNT_CHECK_ENABLED in src/config/settings.py.
  - Updated Docker Compose to pass OPENSEARCH_NODE_COUNT_CHECK_ENABLED in docker-compose.yml.
  - Added the canonical flag to the example env file in .env.example.
  - Documented the flag in the OpenSearch config table in docs/docs/reference/configuration.mdx.
  - Updated the existing readiness test wording in tests/unit/test_opensearch_wait_node_count.py.
  - Added a regression test that loads config.settings with OPENSEARCH_NODE_COUNT_CHECK_ENABLED=false and verifies the canonical flag is what the code reads in tests/unit/config/
    test_opensearch_node_count_check_enabled.py.

* fix: onboarding timeout cleanup issue (#1977)

* fix the cleanup timeout issue for the embedding step

* final step fix

* fix: universal file names (#1982)

* ascii support for ingest files

* update the flow json

* style: ruff autofix (auto)

* Update langflow_headers.py

* Update test_langflow_ingest_callback.py

* Update test-ci.yml

* Update test_onboarding_sample_docs.py

* style: ruff autofix (auto)

* Update test_ascii_safe_header_value.py

* Update langflow_file_service.py

* re trigger commit

* fix: purge config_manager singleton in non-langflow ingestion test

The _purge_modules() list was missing "config.config_manager", so the
ConfigManager singleton retained its cached _config from a previous test.
Subsequent calls to get_openrag_config() returned the old config where
disable_ingest_with_langflow=False, causing the router to use the Langflow
path even though DISABLE_INGEST_WITH_LANGFLOW=true was set in the environment.
Adding config.config_manager to the purge list forces a fresh load on the
next import, picking up the correct env-var values.

* fix: use hash_id to derive expected document_id in CSV ingestion test

DocumentFileProcessor ignores Docling's binary_hash field and computes
document_id = hash_id(file_path) from the actual file content. The test
was asserting against the literal mock value "sha-csv-integration-123"
which never appears in the indexed documents, giving 0 hits and a false
failure. Now the test derives expected_document_id via hash_id(csv_path)
to match what the production code actually indexes.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

---------

Co-authored-by: Wallgau <[email protected]>
Co-authored-by: Olfa Maslah <[email protected]>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Edwin Jose <[email protected]>
Co-authored-by: Edwin Jose <[email protected]>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Mike Fortman <[email protected]>
Co-authored-by: Sebastián Estévez <[email protected]>
Co-authored-by: Lucas Oliveira <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend 🔷 Issues related to backend services (OpenSearch, Langflow, APIs) bug 🔴 Something isn't working. lgtm tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants