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
This commit is contained in:
parent
d0aa37e9c3
commit
f843a8a161
381
OAUTH-ARCHITECTURE.md
Normal file
381
OAUTH-ARCHITECTURE.md
Normal file
@ -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 <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
|
||||
|
||||
```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 <token>` 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=<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.
|
||||
Loading…
x
Reference in New Issue
Block a user