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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ AUTH_ENCRYPTION_SECRET=my-test-salt
OAUTH_REQUEST_TIMEOUT=30
OAUTH_MAX_RETRIES=3

# OAuth Security Settings
# When MCP servers require OAuth authorization code flow,
# tokens are stored per-user to prevent cross-user token access.
# Users must individually authorize each OAuth-protected gateway.

# ==============================================================================
# SSO (Single Sign-On) Configuration
# ==============================================================================
Expand Down Expand Up @@ -505,6 +510,10 @@ DEBUG=false
ENABLE_HEADER_PASSTHROUGH=false
DEFAULT_PASSTHROUGH_HEADERS=["X-Tenant-Id", "X-Trace-Id"]

# Authorization Header Conflict Resolution:
# When gateway uses auth, use X-Upstream-Authorization header to pass
# authorization to upstream servers (automatically renamed to Authorization)

# Enable auto-completion for plugins CLI
PLUGINS_CLI_COMPLETION=false

Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ It currently supports:
* Virtualization of legacy APIs as MCP-compliant tools and servers
* Transport over HTTP, JSON-RPC, WebSocket, SSE (with configurable keepalive), stdio and streamable-HTTP
* An Admin UI for real-time management, configuration, and log monitoring
* Built-in auth, retries, and rate-limiting
* Built-in auth, retries, and rate-limiting with user-scoped OAuth tokens and unconditional X-Upstream-Authorization header support
* **OpenTelemetry observability** with Phoenix, Jaeger, Zipkin, and other OTLP backends
* Scalable deployments via Docker or PyPI, Redis-backed caching, and multi-cluster federation

Expand Down Expand Up @@ -1151,7 +1151,10 @@ You can get started by copying the provided [.env.example](https://github.com/IB
| `MCPGATEWAY_UI_ENABLED` | Enable the interactive Admin dashboard | `false` | bool |
| `MCPGATEWAY_ADMIN_API_ENABLED` | Enable API endpoints for admin ops | `false` | bool |
| `MCPGATEWAY_BULK_IMPORT_ENABLED` | Enable bulk import endpoint for tools | `true` | bool |
| `MCPGATEWAY_BULK_IMPORT_MAX_TOOLS` | Maximum number of tools per bulk import request | `200` | int |
| `MCPGATEWAY_BULK_IMPORT_RATE_LIMIT` | Rate limit for bulk import endpoint (requests per minute) | `10` | int |
| `MCPGATEWAY_UI_TOOL_TEST_TIMEOUT` | Tool test timeout in milliseconds for the admin UI | `60000` | int |
| `MCPCONTEXT_UI_ENABLED` | Enable ContextForge UI features | `true` | bool |

> 🖥️ Set both UI and Admin API to `false` to disable management UI and APIs in production.
> 📥 The bulk import endpoint allows importing up to 200 tools in a single request via `/admin/tools/import`.
Expand Down Expand Up @@ -1292,15 +1295,24 @@ Follow the tutorial at https://ibm.github.io/mcp-context-forge/tutorials/dcr-hyp
| `COOKIE_SAMESITE` | Cookie SameSite attribute | `lax` | `strict`/`lax`/`none` |
| `SECURITY_HEADERS_ENABLED` | Enable security headers middleware | `true` | bool |
| `X_FRAME_OPTIONS` | X-Frame-Options header value | `DENY` | `DENY`/`SAMEORIGIN` |
| `X_CONTENT_TYPE_OPTIONS_ENABLED` | Enable X-Content-Type-Options: nosniff header | `true` | bool |
| `X_XSS_PROTECTION_ENABLED` | Enable X-XSS-Protection header | `true` | bool |
| `X_DOWNLOAD_OPTIONS_ENABLED` | Enable X-Download-Options: noopen header | `true` | bool |
| `HSTS_ENABLED` | Enable HSTS header | `true` | bool |
| `HSTS_MAX_AGE` | HSTS max age in seconds | `31536000` | int |
| `HSTS_INCLUDE_SUBDOMAINS` | Include subdomains in HSTS header | `true` | bool |
| `REMOVE_SERVER_HEADERS` | Remove server identification | `true` | bool |
| `DOCS_ALLOW_BASIC_AUTH` | Allow Basic Auth for docs (in addition to JWT) | `false` | bool |
| `MIN_SECRET_LENGTH` | Minimum length for secret keys (JWT, encryption) | `32` | int |
| `MIN_PASSWORD_LENGTH` | Minimum length for passwords | `12` | int |
| `REQUIRE_STRONG_SECRETS` | Enforce strong secrets (fail startup on weak secrets) | `false` | bool |

> **CORS Configuration**: When `ENVIRONMENT=development`, CORS origins are automatically configured for common development ports (3000, 8080, gateway port). In production, origins are constructed from `APP_DOMAIN` (e.g., `https://yourdomain.com`, `https://app.yourdomain.com`). You can override this by explicitly setting `ALLOWED_ORIGINS`.
>
> **Security Headers**: The gateway automatically adds configurable security headers to all responses including CSP, X-Frame-Options, X-Content-Type-Options, X-Download-Options, and HSTS (on HTTPS). All headers can be individually enabled/disabled. Sensitive server headers are removed.
>
> **Security Validation**: Set `REQUIRE_STRONG_SECRETS=true` to enforce minimum lengths for JWT secrets and passwords at startup. This helps prevent weak credentials in production. Default is `false` for backward compatibility.
>
> **iframe Embedding**: By default, `X-Frame-Options: DENY` prevents iframe embedding for security. To allow embedding, set `X_FRAME_OPTIONS=SAMEORIGIN` (same domain) or disable with `X_FRAME_OPTIONS=""`. Also update CSP `frame-ancestors` directive if needed.
>
> **Cookie Security**: Authentication cookies are automatically configured with HttpOnly, Secure (in production), and SameSite attributes for CSRF protection.
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ services:
# MCP Gateway - the main API server for the MCP stack
# ──────────────────────────────────────────────────────────────────────
gateway:
#image: ghcr.io/ibm/mcp-context-forge:0.6.0 # Use the release MCP Context Forge image
image: ${IMAGE_LOCAL:-mcpgateway/mcpgateway:latest} # Use the local latest image. Run `make docker-prod` to build it.
#image: ghcr.io/ibm/mcp-context-forge:0.7.0 # Testing migration from 0.7.0
#image: ghcr.io/ibm/mcp-context-forge:0.6.0 # Use the release MCP Context Forge image
build:
context: .
dockerfile: Containerfile # Same one the Makefile builds
Expand Down
33 changes: 28 additions & 5 deletions docs/docs/architecture/oauth-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,27 @@ ADD COLUMN oauth_config JSON;
}
```

### OAuth Tokens Table

```sql
CREATE TABLE oauth_tokens (
id INTEGER PRIMARY KEY,
gateway_id VARCHAR(255) NOT NULL,
user_id VARCHAR(255) NOT NULL,
app_user_email VARCHAR(255) NOT NULL, -- MCP Gateway user (security isolation)
access_token TEXT NOT NULL,
refresh_token TEXT,
expires_at TIMESTAMP NOT NULL,
scopes JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

FOREIGN KEY (gateway_id) REFERENCES gateways (id) ON DELETE CASCADE,
FOREIGN KEY (app_user_email) REFERENCES email_users (email) ON DELETE CASCADE,
UNIQUE CONSTRAINT (gateway_id, app_user_email) -- One token per gateway per MCP user
);
```

## Core Components

### 1. OAuth Manager Service
Expand Down Expand Up @@ -315,11 +336,13 @@ sequenceDiagram

## Security Considerations

1. **Token Storage**: Access tokens are never stored - requested fresh for each operation
2. **Secret Encryption**: Client secrets encrypted using `AUTH_ENCRYPTION_SECRET`
3. **HTTPS Required**: All OAuth endpoints must use HTTPS
4. **Scope Validation**: Request minimum required scopes
5. **Error Handling**: Comprehensive error handling for OAuth failures
1. **User-Scoped Token Storage**: OAuth tokens are stored per gateway and MCP Gateway user (app_user_email) to prevent token sharing between users
2. **Token Isolation**: Each Authorization Code flow token is tied to the specific user who authorized it with unique constraints
3. **Secret Encryption**: Client secrets and stored tokens encrypted using `AUTH_ENCRYPTION_SECRET`
4. **HTTPS Required**: All OAuth endpoints must use HTTPS
5. **Scope Validation**: Request minimum required scopes
6. **Error Handling**: Comprehensive error handling for OAuth failures
7. **Data Integrity**: Foreign key relationships ensure token cleanup when users are deleted

## Configuration

Expand Down
18 changes: 18 additions & 0 deletions docs/docs/faq/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,24 @@
- Use `make podman-run-ssl` for self-signed certs or drop your own certificate under `certs`.
- Set `ALLOWED_ORIGINS` or `CORS_ENABLED` for CORS headers.

???+ example "🔐 How do I pass Authorization headers to upstream MCP servers when the gateway uses authentication?"
When MCP Gateway uses authentication (JWT/Bearer/Basic/OAuth), there's a conflict if you need to pass different Authorization headers to upstream MCP servers.

**Solution: Use X-Upstream-Authorization header**

```bash
# Send X-Upstream-Authorization header - gateway automatically renames it to Authorization for upstream
curl -H "Authorization: Bearer $GATEWAY_TOKEN" \
-H "X-Upstream-Authorization: Bearer $UPSTREAM_TOKEN" \
-X POST http://localhost:4444/tools/invoke/my_tool \
-d '{"arguments": {}}'
```

The gateway will:
1. Use the `Authorization` header for gateway authentication
2. Rename `X-Upstream-Authorization` to `Authorization` when forwarding to the upstream MCP server
3. This solves the header conflict and allows different auth tokens for gateway vs upstream

---

## 📡 Tools, Servers & Federation
Expand Down
11 changes: 7 additions & 4 deletions docs/docs/manage/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,18 @@ sequenceDiagram

---

## Token Storage and Refresh (Optional)
## Token Storage and Refresh

By default, access tokens are fetched on-demand and not persisted. The Authorization Code UI design introduces optional storage and refresh:
OAuth tokens are stored per gateway and user for the Authorization Code flow to ensure proper security isolation:

- Store tokens per gateway + user
- **User-Scoped Tokens**: OAuth tokens are scoped per MCP Gateway user (using app_user_email field) to prevent token sharing between users
- Store tokens per gateway + user combination with unique constraints
- Auto-refresh using refresh tokens when near expiry
- Encrypt tokens at rest using `AUTH_ENCRYPTION_SECRET`
- Foreign key relationships ensure token cleanup when users are deleted

If enabled in future releases, you will be able to toggle token storage and auto-refresh in the gateway's OAuth settings. See oauth-authorization-code-ui-design.md.
!!! important "Security Enhancement"
OAuth tokens are now user-scoped to prevent token sharing between users. Each Authorization Code flow token is tied to the specific MCP Gateway user who authorized it, providing better security isolation.

---

Expand Down
59 changes: 59 additions & 0 deletions docs/docs/manage/proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,65 @@ ENABLE_HEADER_PASSTHROUGH=true
DEFAULT_PASSTHROUGH_HEADERS='["X-Tenant-Id", "X-Request-Id", "X-Authenticated-User", "X-Groups"]'
```

### X-Upstream-Authorization Header

When MCP Gateway uses authentication (JWT/Bearer/Basic/OAuth), clients face an Authorization header conflict when trying to pass different auth to upstream MCP servers.

**Problem**: You need one `Authorization` header for gateway auth and a different one for upstream MCP servers.

**Solution**: Use the `X-Upstream-Authorization` header, which the gateway automatically renames to `Authorization` when forwarding to upstream servers.

```mermaid
sequenceDiagram
participant Client
participant Gateway as MCP Gateway
participant MCP as MCP Server

Client->>Gateway: Authorization: Bearer gateway_token<br/>X-Upstream-Authorization: Bearer upstream_token
Gateway->>Gateway: Validate gateway_token
Gateway->>MCP: Authorization: Bearer upstream_token<br/>(X-Upstream-Authorization renamed)
MCP-->>Gateway: Response
Gateway-->>Client: Response
```

#### Example Usage

```bash
# Client authenticates to gateway with one token
# and passes different auth to upstream MCP server
curl -H "Authorization: Bearer $GATEWAY_JWT" \
-H "X-Upstream-Authorization: Bearer $MCP_SERVER_TOKEN" \
-X POST http://localhost:4444/tools/invoke/github_create_issue \
-d '{"arguments": {"title": "New Issue"}}'
```

#### Configuration

This feature is automatically enabled when the gateway uses authentication:

```bash
# Any of these auth methods enable X-Upstream-Authorization handling
AUTH_REQUIRED=true
BASIC_AUTH_USER=admin
JWT_SECRET_KEY=your-secret

# Or OAuth-enabled gateways
# oauth_config in gateway configuration
```

The gateway will always process `X-Upstream-Authorization` headers when:
1. The gateway itself uses authentication (`auth_type` in ["basic", "bearer", "oauth"])
2. The header value passes security validation

**Note**: `X-Upstream-Authorization` processing is independent of the `ENABLE_HEADER_PASSTHROUGH` flag and always works when the gateway uses authentication.

#### Security Notes

- Headers are sanitized before forwarding
- Only processed when gateway authentication is enabled
- Failed sanitization logs warnings but doesn't block requests
- Provides clean separation between gateway and upstream authentication

## Security Considerations

### Network Isolation
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
"""add_user_context_to_oauth_tokens

Revision ID: 14ac971cee42
Revises: e182847d89e6
Create Date: 2025-09-19 23:18:00.710347

"""

# Standard
from typing import Sequence, Union

# Third-Party
from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "14ac971cee42"
down_revision: Union[str, Sequence[str], None] = "e182847d89e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Add app_user_email to oauth_tokens for user-specific token handling."""

# Check if oauth_tokens table exists
conn = op.get_bind()
inspector = sa.inspect(conn)

if "oauth_tokens" not in inspector.get_table_names():
# Table doesn't exist, nothing to upgrade
print("oauth_tokens table not found. Skipping migration.")
return

# First, delete all existing OAuth tokens as they lack user context
# This is a security fix - existing tokens are vulnerable
try:
# Check if table has any rows
result = conn.execute(sa.text("SELECT COUNT(*) FROM oauth_tokens")).scalar()
if result > 0:
op.execute("DELETE FROM oauth_tokens")
print(f"Deleted {result} existing OAuth tokens (security fix)")
except Exception as e:
print(f"Warning: Could not delete existing tokens: {e}")

# Get database dialect for engine-specific handling
dialect_name = conn.dialect.name.lower()

# Add app_user_email column - handle nullable constraint differently per database
if dialect_name == "sqlite":
# SQLite doesn't support adding NOT NULL columns to existing tables with data
# even though we deleted all rows, we need to handle this carefully
with op.batch_alter_table("oauth_tokens") as batch_op:
batch_op.add_column(sa.Column("app_user_email", sa.String(255), nullable=False, server_default=""))
# Remove the server default after adding the column
batch_op.alter_column("app_user_email", server_default=None)
else:
# PostgreSQL and MySQL can handle adding NOT NULL columns to empty tables
op.add_column("oauth_tokens", sa.Column("app_user_email", sa.String(255), nullable=False))

# Add foreign key constraint to ensure referential integrity
# SQLite with batch mode will handle foreign keys properly
if dialect_name == "sqlite":
with op.batch_alter_table("oauth_tokens") as batch_op:
batch_op.create_foreign_key("fk_oauth_app_user", "email_users", ["app_user_email"], ["email"], ondelete="CASCADE")
else:
op.create_foreign_key("fk_oauth_app_user", "oauth_tokens", "email_users", ["app_user_email"], ["email"], ondelete="CASCADE")

# Create unique index to ensure one token per user per gateway
op.create_index("idx_oauth_gateway_user", "oauth_tokens", ["gateway_id", "app_user_email"], unique=True)

# Drop the old index if it exists (gateway_id only)
try:
op.drop_index("idx_oauth_tokens_gateway_user", "oauth_tokens")
except Exception: # nosec B110
# Index might not exist, which is fine - we're just cleaning up old indexes
print("Old index idx_oauth_tokens_gateway_user not found (expected for new installations)")

# Create oauth_states table for CSRF protection in multi-worker deployments
op.create_table(
"oauth_states",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("gateway_id", sa.String(36), sa.ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False),
sa.Column("state", sa.String(500), nullable=False, unique=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used", sa.Boolean, nullable=False, default=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, default=sa.func.now()),
)

# Create index for efficient lookups
op.create_index("idx_oauth_state_lookup", "oauth_states", ["gateway_id", "state"])


def downgrade() -> None:
"""Remove user context from oauth_tokens and oauth_states table."""

# Drop oauth_states table first
op.drop_index("idx_oauth_state_lookup", "oauth_states")
op.drop_table("oauth_states")

# Check if oauth_tokens table exists
conn = op.get_bind()
inspector = sa.inspect(conn)

if "oauth_tokens" not in inspector.get_table_names():
# Table doesn't exist, nothing to downgrade
print("oauth_tokens table not found. Skipping downgrade.")
return

# Get database dialect for engine-specific handling
dialect_name = conn.dialect.name.lower()

# Drop the unique index if it exists
try:
op.drop_index("idx_oauth_gateway_user", "oauth_tokens")
except Exception: # nosec B110
# Index might not exist, which is fine - this could be a partial rollback
print("Index idx_oauth_gateway_user not found (expected if upgrade was incomplete)")

if dialect_name == "sqlite":
# SQLite requires batch mode for dropping foreign keys and columns
with op.batch_alter_table("oauth_tokens") as batch_op:
# SQLite doesn't have explicit foreign key constraints to drop in batch mode
# The foreign key will be removed when we drop the column
batch_op.drop_column("app_user_email")
else:
# Drop the foreign key constraint for PostgreSQL and MySQL
op.drop_constraint("fk_oauth_app_user", "oauth_tokens", type_="foreignkey")

# Drop the column
op.drop_column("oauth_tokens", "app_user_email")

# Note: We don't restore deleted tokens as they were insecure
Loading
Loading