Skip to content

[Bug]: OAuth Token Multi-Tenancy Support: User-Specific Token Handling Required #1078

@crivetimihai

Description

@crivetimihai

The MCP Gateway's OAuth implementation isn't complete - where an authenticated user can access any other user's OAuth tokens. When User A invokes a tool from an OAuth-protected MCP server, the gateway may execute the request using User B's OAuth credentials, allowing unauthorized access to User B's resources.

Current Behavior

When a user invokes a tool from an OAuth-protected MCP server (registered as a "Gateway"):

  1. The gateway calls token_storage.get_any_valid_token(gateway.id)
  2. This returns the first available token for that gateway, regardless of which user owns it
  3. User A's request executes with User B's OAuth credentials
  4. User A gains unauthorized access to User B's resources (e.g., private GitHub repos)

Expected Behavior

Each user's OAuth tokens should be completely isolated:

  • User A's requests should only use User A's OAuth tokens
  • If User A hasn't authorized a gateway, they should receive an error prompting authorization
  • No cross-user token access should be possible

Root Causes

1. No User Context Propagation

  • The /rpc endpoint authenticates users but doesn't pass identity to service layer
  • Service methods (ToolService.invoke_tool()) don't accept user parameters
  • No way to filter tokens by requesting user

2. Missing User Mapping

  • OAuth tokens store only the OAuth provider's user ID (e.g., "github-user-123")
  • No app_user_email field linking tokens to MCP Gateway users
  • Database schema lacks user association

3. Insecure OAuth Flow

  • /oauth/authorize/{gateway_id} doesn't require authentication
  • State parameter is just gateway_id_random with no user info or integrity check
  • OAuth callback has no way to determine which MCP user initiated the flow

4. Authorization Header Collision

  • Gateway consumes Authorization header for its own JWT validation
  • Prevents forwarding of bearer tokens to upstream MCP servers
  • Blocks non-OAuth authentication methods to external servers

Affected Code

Token Retrieval (Multiple Locations)

# mcpgateway/services/token_storage_service.py:167
async def get_any_valid_token(self, gateway_id: str) -> Optional[str]:
    token_record = self.db.execute(
        select(OAuthToken).where(OAuthToken.gateway_id == gateway_id)
    ).scalar_one_or_none()  # Returns ANY user's token!

# mcpgateway/services/tool_service.py:954-956
# Try to get a valid token for any user (for now, we'll use a placeholder)
# In a real implementation, you might want to specify which user's tokens to use
access_token = await token_storage.get_any_valid_token(gateway.id)

# mcpgateway/services/gateway_service.py
# Lines 753, 1568, 1655, 1834 all use get_any_valid_token()

OAuth Flow

# mcpgateway/routers/oauth_router.py
@oauth_router.get("/authorize/{gateway_id}")
async def initiate_oauth_flow(..., db: Session = Depends(get_db)):
    # No authentication required!
    # No user context in state parameter!

Service Layer

# mcpgateway/main.py
@utility_router.post("/rpc")
async def handle_rpc(..., user=Depends(require_auth)):
    # Has user but doesn't pass it to services
    result = await tool_service.invoke_tool(
        db=db, name=name, arguments=arguments, request_headers=headers
        # Missing: app_user_email=user.email
    )

Proposed Solution

1. Add User Association to OAuth Tokens

  • Add app_user_email column to oauth_tokens table with foreign key to email_users
  • Create unique constraint on (gateway_id, app_user_email)
  • Delete existing tokens (they lack user context)

2. Replace Token Retrieval Logic

  • Delete get_any_valid_token() entirely
  • Implement get_user_token(gateway_id, app_user_email) with strict user filtering
  • Return None if user hasn't authorized the gateway

3. Propagate User Context

  • Add app_user_email parameter to all service methods
  • Pass authenticated user from API layer to service layer
  • Ensure all entry points (/rpc, /mcp, SSE, WebSocket) provide user context

4. Secure OAuth Flow

  • Require authentication on /oauth/authorize/{gateway_id}
  • Encode user email in state parameter with HMAC signature
  • Validate state integrity in callback
  • Store user association when saving tokens

5. Resolve Authorization Header Conflict

  • Support alternate header (e.g., X-MCP-Gateway-Auth) for gateway authentication
  • Allow Authorization header passthrough when gateway uses alternate auth
  • Document cookie-only authentication option

Impact

Security

  • Critical: Prevents unauthorized access to other users' OAuth-protected resources
  • Ensures proper multi-tenant isolation
  • Provides audit trail of which user authorized which gateway

Breaking Changes

  • All existing OAuth tokens must be deleted (they lack user context)
  • Users must re-authorize OAuth gateways after the fix
  • API behavior changes to require user context

Files Affected

  1. Database migration (new Alembic version)
  2. mcpgateway/services/token_storage_service.py
  3. mcpgateway/services/tool_service.py
  4. mcpgateway/services/gateway_service.py
  5. mcpgateway/services/oauth_manager.py
  6. mcpgateway/routers/oauth_router.py
  7. mcpgateway/main.py
  8. mcpgateway/db.py
  9. Transport implementations (SSE, WebSocket, Streamable HTTP)
  10. Authentication utilities and header handling

Testing Requirements

def test_oauth_user_isolation():
    """Verify tokens are isolated per user."""

    # Alice authorizes GitHub
    await token_storage.store_tokens(
        gateway_id="github-gateway",
        app_user_email="[email protected]",
        access_token="alice_token"
    )

    # Bob cannot use Alice's token
    token = await token_storage.get_user_token(
        gateway_id="github-gateway",
        app_user_email="[email protected]"
    )
    assert token is None

    # Alice gets her own token
    token = await token_storage.get_user_token(
        gateway_id="github-gateway",
        app_user_email="[email protected]"
    )
    assert token == "alice_token"

Related Issues

Acceptance Criteria

  • get_any_valid_token() is completely removed
  • All OAuth tokens are user-scoped via app_user_email
  • OAuth authorization requires authenticated user
  • State parameter is signed and includes user identity
  • User context propagates from API to service layer
  • Tests verify complete user isolation
  • Documentation updated with security implications

Additional Context

The comment in the code acknowledges this is a placeholder:

"Try to get a valid token for any user (for now, we'll use a placeholder)

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingtriageIssues / Features awaiting triage

Type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions