diff --git a/OAUTH-ARCHITECTURE.md b/OAUTH-ARCHITECTURE.md new file mode 100644 index 0000000..dad0373 --- /dev/null +++ b/OAUTH-ARCHITECTURE.md @@ -0,0 +1,381 @@ +# 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.