- 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
15 KiB
15 KiB
OAuth Architecture for vSphere MCP Server
The Problem
We need to add authentication to the MCP server so that:
- Users authenticate via OAuth 2.1 / OIDC (using Authentik as IdP)
- The MCP server knows WHO is making requests (for audit logging)
- 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 |
Recommended: Per-User Mapping with Fallback
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 requestMcp-Session-Idheader 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)
-
Create OAuth2/OIDC Provider:
- Name:
vsphere-mcp - Client Type: Confidential
- Redirect URIs:
http://localhost:*/callbackhttps://mcp.example.com/auth/callback
- Scopes:
openid,profile,email - Signing Key: Select or create RS256 key
- Name:
-
Create Application:
- Name:
vSphere MCP Server - Slug:
vsphere-mcp - Provider: Select the provider above
- Note the Client ID and Client Secret
- Name:
-
Configure Groups (optional):
vsphere-admins- Full accessvsphere-operators- Limited access- Groups are included in JWT
groupsclaim
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.pywith OIDCProxy configuration - Create
credential_broker.pyfor 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:
- Authenticates users via OAuth (trusts Authentik)
- Authorizes by mapping OAuth identity to vCenter credentials
- Audits by logging all actions with the OAuth user identity
- Executes vCenter API calls using mapped credentials
This gives you SSO-like experience while working within vCenter 7.0.3's authentication limitations.