# 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 │ ┌────────────────────────────▼────────────────────────────────────┐ │ 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 | ### Recommended: Per-User Mapping with Fallback ```python 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 ```python 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 ```python 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: ```python # 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 ` header on every request - `Mcp-Session-Id` header for session continuity --- ## Environment Variables ```bash # Authentik OIDC AUTHENTIK_OIDC_URL=https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= # 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= VCENTER_INSECURE=true # Per-user credentials (optional, for testing) VCENTER_PASSWORD_RYAN= VCENTER_PASSWORD_ALICE= # 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 → 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.