mcvsphere/OAUTH-ARCHITECTURE.md
Ryan Malloy f843a8a161 add OAuth architecture design document
- Service Account + OAuth Audit model for vCenter integration
- Authentik as OIDC provider with JWT validation
- Permission escalation based on OAuth groups
- Credential broker pattern for user mapping
- Implementation checklist and environment variables
2025-12-27 00:53:24 -07:00

15 KiB

OAuth Architecture for vSphere MCP Server

The Problem

We need to add authentication to the MCP server so that:

  1. Users authenticate via OAuth 2.1 / OIDC (using Authentik as IdP)
  2. The MCP server knows WHO is making requests (for audit logging)
  3. vCenter permissions are respected per-user

Challenge: vCenter 7.0.3 doesn't support OAuth token exchange (RFC 8693), so we can't pass OAuth tokens directly to vCenter.


Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                      MCP Client (Claude Code)                    │
└────────────────────────────┬────────────────────────────────────┘
                             │
                             │ 1. OAuth 2.1 + PKCE flow
                             │    (browser opens for login)
                             │
┌────────────────────────────▼────────────────────────────────────┐
│                         Authentik                                │
│                    (Self-hosted OIDC IdP)                        │
│                                                                  │
│   - Issues JWT access tokens                                     │
│   - Validates user credentials                                   │
│   - Includes user identity in token (sub, email, groups)         │
└────────────────────────────┬────────────────────────────────────┘
                             │
                             │ 2. JWT Bearer token
                             │    Authorization: Bearer <jwt>
                             │
┌────────────────────────────▼────────────────────────────────────┐
│                     vSphere MCP Server                           │
│                   (FastMCP + pyvmomi)                            │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                   OIDCProxy (FastMCP)                    │    │
│  │  - Validates JWT signature via Authentik JWKS            │    │
│  │  - Extracts user identity (preferred_username)           │    │
│  │  - Makes user available via ctx.request_context.user     │    │
│  └─────────────────────────────────────────────────────────┘    │
│                              │                                   │
│                              ▼                                   │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                 Credential Broker                        │    │
│  │  - Maps OAuth user → vCenter credentials                 │    │
│  │  - Caches pyvmomi connections per-user                   │    │
│  │  - Retrieves passwords from Vault / env vars             │    │
│  └─────────────────────────────────────────────────────────┘    │
│                              │                                   │
│                              ▼                                   │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                    Audit Logger                          │    │
│  │  - Logs all tool invocations with OAuth identity         │    │
│  │  - "User ryan@example.com powered on VM web-server"      │    │
│  └─────────────────────────────────────────────────────────┘    │
└────────────────────────────┬────────────────────────────────────┘
                             │
                             │ 3. pyvmomi (as mapped user)
                             │
┌────────────────────────────▼────────────────────────────────────┐
│                        vCenter 7.0.3                             │
│  - Receives API calls as the actual user                        │
│  - Native audit logs show real user identity                    │
│  - vCenter permissions apply naturally                          │
└─────────────────────────────────────────────────────────────────┘

User Mapping Strategies

Since we can't exchange OAuth tokens for vCenter tokens, we need a "credential broker":

Strategy How it Works Security Use Case
Service Account All requests use one vCenter admin account Medium Simple/dev
Per-User Mapping Map OAuth username → vCenter credentials from Vault High Production
LDAP Sync Same username/password in Authentik and vCenter SSO Medium AD environments
class CredentialBroker:
    """Maps OAuth users to vCenter credentials."""

    def __init__(self, vcenter_host: str, fallback_user: str = None, fallback_password: str = None):
        self.vcenter_host = vcenter_host
        self.fallback_user = fallback_user  # Service account fallback
        self.fallback_password = fallback_password
        self._connections: dict[str, ServiceInstance] = {}

    def get_connection_for_user(self, oauth_user: dict) -> ServiceInstance:
        """Get pyvmomi connection for this OAuth user."""
        username = oauth_user.get("preferred_username")

        # Try per-user credentials first
        try:
            vcenter_creds = self._lookup_credentials(username)
            return self._get_or_create_connection(
                vcenter_creds["user"],
                vcenter_creds["password"]
            )
        except KeyError:
            # Fall back to service account
            if self.fallback_user:
                return self._get_or_create_connection(
                    self.fallback_user,
                    self.fallback_password
                )
            raise ValueError(f"No vCenter credentials for user: {username}")

    def _lookup_credentials(self, username: str) -> dict:
        """Look up vCenter credentials for OAuth user."""
        # Option 1: Environment variable
        env_key = f"VCENTER_PASSWORD_{username.upper().replace('@', '_').replace('.', '_')}"
        if password := os.environ.get(env_key):
            return {"user": f"{username}@vsphere.local", "password": password}

        # Option 2: HashiCorp Vault (production)
        # return vault_client.read(f"secret/vcenter/users/{username}")

        raise KeyError(f"No credentials found for {username}")

FastMCP OAuth Integration

1. Add OIDCProxy to server.py

import os
from fastmcp import FastMCP
from fastmcp.server.auth import OIDCProxy

# Configure OAuth with Authentik
auth = OIDCProxy(
    # Authentik OIDC Discovery URL
    config_url=os.environ["AUTHENTIK_OIDC_URL"],
    # e.g., "https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration"

    # Application credentials from Authentik
    client_id=os.environ["AUTHENTIK_CLIENT_ID"],
    client_secret=os.environ["AUTHENTIK_CLIENT_SECRET"],

    # MCP Server base URL (for redirects)
    base_url=os.environ.get("MCP_BASE_URL", "http://localhost:8000"),

    # Token validation
    required_scopes=["openid", "profile", "email"],

    # Allow Claude Code localhost redirects
    allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
)

# Create MCP server with OAuth
mcp = FastMCP(
    "vSphere MCP Server",
    auth=auth,
    # Use Streamable HTTP transport for OAuth
)

2. Access User Identity in Tools

from fastmcp import Context

@mcp.tool()
async def power_on_vm(ctx: Context, vm_name: str) -> str:
    """Power on a virtual machine."""
    # Get authenticated user from OAuth token
    user = ctx.request_context.user
    username = user.get("preferred_username", user.get("sub"))

    # Get vCenter connection for this user
    broker = get_credential_broker()
    connection = broker.get_connection_for_user(user)

    # Execute operation
    content = connection.RetrieveContent()
    vm = find_vm(content, vm_name)
    vm.PowerOnVM_Task()

    # Audit log
    logger.info(f"User {username} powered on VM {vm_name}")

    return f"VM '{vm_name}' is powering on"

MCP Transport: Streamable HTTP

OAuth requires HTTP transport (not stdio). Use Streamable HTTP:

# In server.py or via environment
mcp = FastMCP(
    "vSphere MCP Server",
    auth=auth,
)

def main():
    # Run with HTTP transport for OAuth support
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)

Transport characteristics:

  • Single HTTP endpoint (/mcp)
  • POST requests with JSON-RPC body
  • Server-Sent Events (SSE) for streaming responses
  • Authorization: Bearer <token> header on every request
  • Mcp-Session-Id header for session continuity

Environment Variables

# Authentik OIDC
AUTHENTIK_OIDC_URL=https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration
AUTHENTIK_CLIENT_ID=<from-authentik-application>
AUTHENTIK_CLIENT_SECRET=<from-authentik-application>

# MCP Server
MCP_BASE_URL=https://mcp.example.com  # Public URL for OAuth redirects
MCP_TRANSPORT=streamable-http

# vCenter Connection (service account fallback)
VCENTER_HOST=vcenter.example.com
VCENTER_USER=svc-mcp@vsphere.local
VCENTER_PASSWORD=<service-account-password>
VCENTER_INSECURE=true

# Per-user credentials (optional, for testing)
VCENTER_PASSWORD_RYAN=<ryan's-vcenter-password>
VCENTER_PASSWORD_ALICE=<alice's-vcenter-password>

# User Mapping Mode
USER_MAPPING_MODE=service_account  # or 'per_user', 'ldap_sync'

Authentik Setup (Quick Reference)

  1. Create OAuth2/OIDC Provider:

    • Name: vsphere-mcp
    • Client Type: Confidential
    • Redirect URIs:
      • http://localhost:*/callback
      • https://mcp.example.com/auth/callback
    • Scopes: openid, profile, email
    • Signing Key: Select or create RS256 key
  2. Create Application:

    • Name: vSphere MCP Server
    • Slug: vsphere-mcp
    • Provider: Select the provider above
    • Note the Client ID and Client Secret
  3. Configure Groups (optional):

    • vsphere-admins - Full access
    • vsphere-operators - Limited access
    • Groups are included in JWT groups claim

OAuth Flow (End-to-End)

1. Claude Code connects to MCP Server
   → GET /mcp
   → Server returns 401 Unauthorized
   → WWW-Authenticate header includes OAuth metadata URL

2. Claude Code fetches OAuth metadata
   → Discovers Authentik authorization URL
   → Discovers required scopes

3. Claude Code initiates OAuth flow
   → Opens browser to Authentik login page
   → User enters credentials
   → Authentik redirects back with authorization code

4. Claude Code exchanges code for tokens
   → POST to Authentik token endpoint
   → Receives JWT access token + refresh token

5. Claude Code reconnects with Bearer token
   → POST /mcp with Authorization: Bearer <jwt>
   → Server validates JWT via Authentik JWKS
   → Server extracts user identity
   → User can now invoke tools

6. Tool invocation
   → Client: "power on web-server VM"
   → Server: Validates token, maps user to vCenter creds
   → Server: Executes pyvmomi call
   → Server: Logs "User ryan@example.com powered on web-server"
   → Client: Receives success response

Implementation Checklist

Phase 1: Prepare Server

  • Add fastmcp[auth] to dependencies
  • Create auth.py with OIDCProxy configuration
  • Create credential_broker.py for user mapping
  • Add audit logging to all tools
  • Update server.py to use HTTP transport

Phase 2: Deploy Authentik

  • Docker Compose for Authentik
  • Create OIDC provider and application
  • Configure redirect URIs
  • Note client credentials
  • Test OIDC flow manually with curl

Phase 3: Integration

  • Configure environment variables
  • Test with fastmcp dev (OAuth mode)
  • Test with Claude Code (claude mcp add --auth oauth)
  • Verify audit logs show correct user identity

Phase 4: Production

  • HTTPS via Caddy reverse proxy
  • Secrets in Docker secrets / Vault
  • Service account with minimal vCenter permissions
  • Log aggregation and monitoring

Files to Create/Modify

esxi-mcp-server/
├── src/esxi_mcp_server/
│   ├── auth.py                 # NEW: OIDCProxy configuration
│   ├── credential_broker.py    # NEW: OAuth → vCenter credential mapping
│   ├── server.py               # MODIFY: Add auth, HTTP transport
│   └── mixins/
│       └── *.py                # MODIFY: Add ctx.request_context.user logging
├── .env.example                # MODIFY: Add OAuth variables
└── docker-compose.yml          # MODIFY: Add Authentik services

Key Insight

The "middleman" role of the MCP server is critical:

OAuth Token (Authentik) ──┐
                          │
                          ▼
                    ┌─────────────┐
                    │  MCP Server │  ← Validates OAuth, maps to vCenter creds
                    │ (Middleman) │  ← Logs audit trail with OAuth identity
                    └─────────────┘
                          │
                          ▼
              vCenter API (pyvmomi)

The MCP server doesn't pass OAuth tokens to vCenter. Instead, it:

  1. Authenticates users via OAuth (trusts Authentik)
  2. Authorizes by mapping OAuth identity to vCenter credentials
  3. Audits by logging all actions with the OAuth user identity
  4. Executes vCenter API calls using mapped credentials

This gives you SSO-like experience while working within vCenter 7.0.3's authentication limitations.