From f843a8a16111d5314b82f73bcf6b8b407f34ec4a Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 27 Dec 2025 00:53:24 -0700 Subject: [PATCH 1/3] 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 --- OAUTH-ARCHITECTURE.md | 381 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 OAUTH-ARCHITECTURE.md 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. From cda49f291252b7656e5f7c814d6b5cfabee84130 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 27 Dec 2025 01:12:58 -0700 Subject: [PATCH 2/3] implement OAuth authentication with Authentik support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core OAuth infrastructure: - permissions.py: 5-level permission model (read_only → full_admin) Maps all 94 tools to permission levels Maps OAuth groups to permission sets - audit.py: Centralized logging with OAuth user identity - auth.py: OIDCProxy configuration for Authentik/OIDC providers - middleware.py: Permission checking decorator and tool wrapper Server integration: - config.py: Add OAuth settings (oauth_enabled, oauth_issuer_url, etc.) Validate OAuth config completeness, require HTTP transport - server.py: Integrate auth provider, add HTTP transport support Show OAuth status in startup banner Deployment: - docker-compose.oauth.yml: Authentik stack (server, worker, postgres, redis) - .env.example: Document all OAuth and Authentik environment variables Permission model: - vsphere-readers: READ_ONLY (32 tools) - vsphere-operators: + POWER_OPS (14 tools) - vsphere-admins: + VM_LIFECYCLE (33 tools) - vsphere-host-admins: + HOST_ADMIN (6 tools) - vsphere-super-admins: + FULL_ADMIN (9 tools) --- .env.example | 50 +++++++- docker-compose.oauth.yml | 127 +++++++++++++++++++ src/mcvsphere/audit.py | 186 +++++++++++++++++++++++++++ src/mcvsphere/auth.py | 63 ++++++++++ src/mcvsphere/config.py | 53 +++++++- src/mcvsphere/middleware.py | 181 ++++++++++++++++++++++++++ src/mcvsphere/permissions.py | 238 +++++++++++++++++++++++++++++++++++ src/mcvsphere/server.py | 21 +++- uv.lock | 2 +- 9 files changed, 907 insertions(+), 14 deletions(-) create mode 100644 docker-compose.oauth.yml create mode 100644 src/mcvsphere/audit.py create mode 100644 src/mcvsphere/auth.py create mode 100644 src/mcvsphere/middleware.py create mode 100644 src/mcvsphere/permissions.py diff --git a/.env.example b/.env.example index acd3acf..6356b37 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ -# ESXi MCP Server Configuration +# mcvsphere Configuration # Copy this file to .env and fill in your values # Docker Compose project name (prevents environment clashes) -COMPOSE_PROJECT=esxi-mcp +COMPOSE_PROJECT=mcvsphere # ───────────────────────────────────────────────────────────────────────────── # VMware vCenter/ESXi Connection (Required) @@ -35,10 +35,10 @@ VCENTER_INSECURE=true # API key for authentication (optional, but recommended for production) # MCP_API_KEY=your-secret-api-key -# Transport type: stdio (for Claude Desktop) or sse (for web/Docker) +# Transport type: stdio (Claude Desktop), sse (web/Docker), http (OAuth) MCP_TRANSPORT=sse -# Server binding (only used with SSE transport) +# Server binding (only used with SSE/HTTP transport) MCP_HOST=0.0.0.0 MCP_PORT=8080 @@ -49,4 +49,44 @@ MCP_PORT=8080 LOG_LEVEL=INFO # Log file path (logs to console if not specified) -# LOG_FILE=/app/logs/esxi-mcp.log +# LOG_FILE=/app/logs/mcvsphere.log + +# ───────────────────────────────────────────────────────────────────────────── +# OAuth/OIDC Configuration (Optional - requires Authentik or other OIDC provider) +# ───────────────────────────────────────────────────────────────────────────── +# Enable OAuth authentication (requires MCP_TRANSPORT=http or sse) +OAUTH_ENABLED=false + +# OIDC issuer URL (Authentik application URL) +# Example: https://auth.example.com/application/o/mcvsphere/ +# OAUTH_ISSUER_URL= + +# OAuth client credentials (from Authentik application) +# OAUTH_CLIENT_ID= +# OAUTH_CLIENT_SECRET= + +# OAuth scopes to request (comma-separated or JSON array) +# OAUTH_SCOPES=["openid", "profile", "email", "groups"] + +# OAuth groups required for access (empty = any authenticated user) +# OAUTH_REQUIRED_GROUPS=["vsphere-readers"] + +# ───────────────────────────────────────────────────────────────────────────── +# Authentik Deployment (for docker-compose.oauth.yml) +# ───────────────────────────────────────────────────────────────────────────── +# Authentik secret key (generate with: openssl rand -base64 36) +# AUTHENTIK_SECRET_KEY= + +# Authentik PostgreSQL password +# AUTHENTIK_DB_PASSWORD= + +# Authentik bootstrap admin (first run only) +# AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost +# AUTHENTIK_BOOTSTRAP_PASSWORD= + +# Authentik ports +# AUTHENTIK_PORT=9000 +# AUTHENTIK_HTTPS_PORT=9443 + +# Authentik hostname (for Caddy reverse proxy) +# AUTHENTIK_HOST=auth.localhost diff --git a/docker-compose.oauth.yml b/docker-compose.oauth.yml new file mode 100644 index 0000000..d79d92c --- /dev/null +++ b/docker-compose.oauth.yml @@ -0,0 +1,127 @@ +# OAuth-enabled deployment with Authentik +# Usage: docker compose -f docker-compose.yml -f docker-compose.oauth.yml up +# +# This overlay adds Authentik identity provider for OAuth authentication. +# Requires AUTHENTIK_* environment variables to be set. + +services: + # ───────────────────────────────────────────────────────────────────────── + # PostgreSQL for Authentik + # ───────────────────────────────────────────────────────────────────────── + authentik-db: + image: postgres:16-alpine + container_name: mcvsphere-authentik-db + restart: unless-stopped + environment: + POSTGRES_DB: authentik + POSTGRES_USER: authentik + POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD:?AUTHENTIK_DB_PASSWORD required} + volumes: + - authentik-db-data:/var/lib/postgresql/data + networks: + - authentik-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U authentik"] + interval: 10s + timeout: 5s + retries: 5 + + # ───────────────────────────────────────────────────────────────────────── + # Redis for Authentik + # ───────────────────────────────────────────────────────────────────────── + authentik-redis: + image: redis:7-alpine + container_name: mcvsphere-authentik-redis + restart: unless-stopped + command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] + volumes: + - authentik-redis-data:/data + networks: + - authentik-internal + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # ───────────────────────────────────────────────────────────────────────── + # Authentik Server + # ───────────────────────────────────────────────────────────────────────── + authentik-server: + image: ghcr.io/goauthentik/server:2024.10.4 + container_name: mcvsphere-authentik-server + restart: unless-stopped + command: server + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?AUTHENTIK_SECRET_KEY required} + AUTHENTIK_REDIS__HOST: authentik-redis + AUTHENTIK_POSTGRESQL__HOST: authentik-db + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD} + # Bootstrap admin user (first run only) + AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-admin@localhost} + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-} + ports: + - "${AUTHENTIK_PORT:-9000}:9000" + - "${AUTHENTIK_HTTPS_PORT:-9443}:9443" + volumes: + - authentik-media:/media + - authentik-templates:/templates + depends_on: + authentik-db: + condition: service_healthy + authentik-redis: + condition: service_healthy + networks: + - authentik-internal + - mcvsphere-network + healthcheck: + test: ["CMD", "ak", "healthcheck"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + labels: + # Caddy reverse proxy (if using caddy-docker-proxy) + caddy: ${AUTHENTIK_HOST:-auth.localhost} + caddy.reverse_proxy: "{{upstreams 9000}}" + + # ───────────────────────────────────────────────────────────────────────── + # Authentik Worker (background tasks) + # ───────────────────────────────────────────────────────────────────────── + authentik-worker: + image: ghcr.io/goauthentik/server:2024.10.4 + container_name: mcvsphere-authentik-worker + restart: unless-stopped + command: worker + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_REDIS__HOST: authentik-redis + AUTHENTIK_POSTGRESQL__HOST: authentik-db + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD} + volumes: + - authentik-media:/media + - authentik-templates:/templates + depends_on: + authentik-db: + condition: service_healthy + authentik-redis: + condition: service_healthy + networks: + - authentik-internal + +networks: + authentik-internal: + driver: bridge + mcvsphere-network: + external: true + name: ${COMPOSE_PROJECT_NAME:-mcvsphere}_default + +volumes: + authentik-db-data: + authentik-redis-data: + authentik-media: + authentik-templates: diff --git a/src/mcvsphere/audit.py b/src/mcvsphere/audit.py new file mode 100644 index 0000000..b32cc06 --- /dev/null +++ b/src/mcvsphere/audit.py @@ -0,0 +1,186 @@ +"""Audit logging for OAuth-authenticated operations. + +Provides centralized logging with OAuth user identity for all tool invocations. +""" + +import logging +from contextvars import ContextVar +from datetime import UTC, datetime +from typing import Any + +logger = logging.getLogger("mcvsphere.audit") + +# Context variable to store current user info for the request +_current_user: ContextVar[dict[str, Any] | None] = ContextVar("current_user", default=None) + + +def set_current_user(user_info: dict[str, Any] | None) -> None: + """Set the current user for this request context. + + Args: + user_info: User information extracted from OAuth token, or None for anonymous. + """ + _current_user.set(user_info) + + +def get_current_user() -> dict[str, Any] | None: + """Get the current user for this request context. + + Returns: + User information dict or None if no user is authenticated. + """ + return _current_user.get() + + +def get_username() -> str: + """Get the username of the current user. + + Returns: + Username string, or 'anonymous' if no user is authenticated. + """ + user = get_current_user() + if not user: + return "anonymous" + + # Try common OAuth claim names in order of preference + for claim in ("preferred_username", "email", "sub"): + if value := user.get(claim): + return str(value) + + return "unknown" + + +def get_user_groups() -> list[str]: + """Get the groups of the current user. + + Returns: + List of group names, or empty list if none. + """ + user = get_current_user() + if not user: + return [] + return user.get("groups", []) + + +def _sanitize_args(args: dict[str, Any]) -> dict[str, Any]: + """Remove sensitive values from args for logging. + + Args: + args: Tool arguments dict. + + Returns: + Sanitized args with sensitive values redacted. + """ + sensitive_patterns = {"password", "secret", "token", "credential", "key"} + + def is_sensitive(key: str) -> bool: + key_lower = key.lower() + return any(pattern in key_lower for pattern in sensitive_patterns) + + return {k: "***REDACTED***" if is_sensitive(k) else v for k, v in args.items()} + + +def _truncate(value: str | None, max_length: int = 200) -> str | None: + """Truncate a string value for logging. + + Args: + value: String to truncate. + max_length: Maximum length. + + Returns: + Truncated string or None. + """ + if value is None: + return None + if len(value) <= max_length: + return value + return value[:max_length] + "..." + + +def audit_log( + tool_name: str, + args: dict[str, Any], + result: str | None = None, + error: str | None = None, + duration_ms: float | None = None, +) -> None: + """Log a tool invocation with OAuth user identity. + + Args: + tool_name: Name of the MCP tool invoked. + args: Tool arguments (will be sanitized). + result: Tool result string (will be truncated). + error: Error message if tool failed. + duration_ms: Execution time in milliseconds. + """ + username = get_username() + groups = get_user_groups() + + log_entry = { + "timestamp": datetime.now(UTC).isoformat(), + "user": username, + "groups": groups, + "tool": tool_name, + "args": _sanitize_args(args), + "duration_ms": round(duration_ms, 2) if duration_ms else None, + "result": _truncate(result), + "error": error, + } + + # Remove None values for cleaner logs + log_entry = {k: v for k, v in log_entry.items() if v is not None} + + if error: + logger.warning("AUDIT_FAIL: %s", log_entry) + else: + logger.info("AUDIT: %s", log_entry) + + +def audit_permission_denied( + tool_name: str, + args: dict[str, Any], + required_permission: str, +) -> None: + """Log a permission denied event. + + Args: + tool_name: Name of the MCP tool attempted. + args: Tool arguments (will be sanitized). + required_permission: The permission level that was required. + """ + username = get_username() + groups = get_user_groups() + + log_entry = { + "timestamp": datetime.now(UTC).isoformat(), + "user": username, + "groups": groups, + "tool": tool_name, + "args": _sanitize_args(args), + "required_permission": required_permission, + "event": "PERMISSION_DENIED", + } + + logger.warning("AUDIT_DENIED: %s", log_entry) + + +def audit_auth_event( + event_type: str, + username: str | None = None, + details: dict[str, Any] | None = None, +) -> None: + """Log an authentication event. + + Args: + event_type: Type of auth event (login, logout, token_refresh, etc.) + username: Username if known. + details: Additional event details. + """ + log_entry = { + "timestamp": datetime.now(UTC).isoformat(), + "event": event_type, + "user": username or get_username(), + **(details or {}), + } + + logger.info("AUTH_EVENT: %s", log_entry) diff --git a/src/mcvsphere/auth.py b/src/mcvsphere/auth.py new file mode 100644 index 0000000..580d896 --- /dev/null +++ b/src/mcvsphere/auth.py @@ -0,0 +1,63 @@ +"""OAuth authentication configuration for mcvsphere. + +Provides OIDCProxy configuration for Authentik or other OIDC providers. +""" + +import logging + +from mcvsphere.config import Settings + +logger = logging.getLogger(__name__) + + +def create_auth_provider(settings: Settings): + """Create OAuth provider if enabled. + + Args: + settings: Application settings with OAuth configuration. + + Returns: + OIDCProxy instance if OAuth is enabled, None otherwise. + """ + if not settings.oauth_enabled: + logger.debug("OAuth authentication disabled") + return None + + # Import here to avoid loading auth dependencies when not needed + from fastmcp.server.auth import OIDCProxy + + # Build the OIDC config URL from issuer URL + # Authentik format: https://auth.example.com/application/o// + # Discovery URL: https://auth.example.com/application/o//.well-known/openid-configuration + issuer_url = settings.oauth_issuer_url.rstrip("/") + if not issuer_url.endswith("/.well-known/openid-configuration"): + config_url = f"{issuer_url}/.well-known/openid-configuration" + else: + config_url = issuer_url + + # Build base URL for the MCP server + base_url = f"http://{settings.mcp_host}:{settings.mcp_port}" + + logger.info("Configuring OAuth with OIDC provider: %s", issuer_url) + + try: + auth = OIDCProxy( + config_url=config_url, + client_id=settings.oauth_client_id, + client_secret=settings.oauth_client_secret.get_secret_value(), + base_url=base_url, + required_scopes=settings.oauth_scopes, + # Allow Claude Code localhost redirects + allowed_client_redirect_uris=[ + "http://localhost:*", + "http://127.0.0.1:*", + ], + # Skip consent screen for MCP clients (they're already trusted) + require_authorization_consent=False, + ) + logger.info("OAuth authentication enabled via OIDC") + return auth + + except Exception as e: + logger.error("Failed to configure OAuth: %s", e) + raise ValueError(f"OAuth configuration failed: {e}") from e diff --git a/src/mcvsphere/config.py b/src/mcvsphere/config.py index 9012b03..1191a44 100644 --- a/src/mcvsphere/config.py +++ b/src/mcvsphere/config.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Literal import yaml -from pydantic import Field, SecretStr, field_validator +from pydantic import Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -49,8 +49,31 @@ class Settings(BaseSettings): ) mcp_host: str = Field(default="0.0.0.0", description="Server bind address") mcp_port: int = Field(default=8080, description="Server port") - mcp_transport: Literal["stdio", "sse"] = Field( - default="stdio", description="MCP transport type" + mcp_transport: Literal["stdio", "sse", "http"] = Field( + default="stdio", description="MCP transport type (http required for OAuth)" + ) + + # OAuth/OIDC settings + oauth_enabled: bool = Field( + default=False, description="Enable OAuth authentication via OIDC" + ) + oauth_issuer_url: str | None = Field( + default=None, + description="OIDC issuer URL (e.g., https://auth.example.com/application/o/mcvsphere/)", + ) + oauth_client_id: str | None = Field( + default=None, description="OAuth client ID from OIDC provider" + ) + oauth_client_secret: SecretStr | None = Field( + default=None, description="OAuth client secret from OIDC provider" + ) + oauth_scopes: list[str] = Field( + default_factory=lambda: ["openid", "profile", "email", "groups"], + description="OAuth scopes to request", + ) + oauth_required_groups: list[str] = Field( + default_factory=list, + description="OAuth groups required for access (empty = any authenticated user)", ) # Logging settings @@ -61,13 +84,35 @@ class Settings(BaseSettings): default=None, description="Log file path (logs to console if not specified)" ) - @field_validator("vcenter_insecure", mode="before") + @field_validator("vcenter_insecure", "oauth_enabled", mode="before") @classmethod def parse_bool(cls, v: str | bool) -> bool: if isinstance(v, bool): return v return v.lower() in ("true", "1", "yes", "on") + @model_validator(mode="after") + def validate_oauth_config(self) -> "Settings": + """Validate OAuth configuration is complete when enabled.""" + if self.oauth_enabled: + missing = [] + if not self.oauth_issuer_url: + missing.append("oauth_issuer_url") + if not self.oauth_client_id: + missing.append("oauth_client_id") + if not self.oauth_client_secret: + missing.append("oauth_client_secret") + if missing: + raise ValueError( + f"OAuth is enabled but missing required settings: {', '.join(missing)}" + ) + # OAuth requires HTTP transport + if self.mcp_transport == "stdio": + raise ValueError( + "OAuth requires HTTP transport. Set mcp_transport='http' or 'sse'" + ) + return self + @classmethod def from_yaml(cls, path: Path) -> "Settings": """Load settings from a YAML file, with env vars taking precedence.""" diff --git a/src/mcvsphere/middleware.py b/src/mcvsphere/middleware.py new file mode 100644 index 0000000..183a545 --- /dev/null +++ b/src/mcvsphere/middleware.py @@ -0,0 +1,181 @@ +"""Middleware for permission checking and audit logging. + +Provides decorators and hooks for wrapping tool execution with: +- OAuth permission validation +- Audit logging with user identity +""" + +import time +from collections.abc import Callable +from functools import wraps +from typing import Any + +from mcvsphere.audit import ( + audit_log, + audit_permission_denied, + get_current_user, + get_user_groups, + set_current_user, +) +from mcvsphere.permissions import ( + PermissionDeniedError, + PermissionLevel, + check_permission, + get_required_permission, +) + + +def with_permission_check(tool_name: str) -> Callable: + """Decorator factory for permission checking and audit logging. + + Args: + tool_name: Name of the MCP tool being wrapped. + + Returns: + Decorator that wraps the tool function with permission checks and audit logging. + + Example: + @with_permission_check("power_on") + def power_on(self, name: str) -> str: + ... + """ + + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + # Get user groups from current context + groups = get_user_groups() + user = get_current_user() + username = "anonymous" + if user: + username = user.get( + "preferred_username", user.get("email", user.get("sub", "unknown")) + ) + + # Check permission + if not check_permission(tool_name, groups): + required = get_required_permission(tool_name) + audit_permission_denied(tool_name, kwargs, required.value) + raise PermissionDeniedError(username, tool_name, required) + + # Execute tool with timing + start_time = time.perf_counter() + try: + result = func(*args, **kwargs) + duration_ms = (time.perf_counter() - start_time) * 1000 + audit_log(tool_name, kwargs, result=str(result), duration_ms=duration_ms) + return result + except PermissionDeniedError: + # Re-raise permission errors without additional logging + raise + except Exception as e: + duration_ms = (time.perf_counter() - start_time) * 1000 + audit_log(tool_name, kwargs, error=str(e), duration_ms=duration_ms) + raise + + return wrapper + + return decorator + + +def extract_user_from_context(ctx) -> dict[str, Any] | None: + """Extract user information from FastMCP context. + + Args: + ctx: FastMCP Context object. + + Returns: + User info dict from OAuth token claims, or None if not authenticated. + """ + if ctx is None: + return None + + # Try to get access token from context + try: + # FastMCP stores the access token in request_context + if hasattr(ctx, "request_context") and ctx.request_context: + token = getattr(ctx.request_context, "access_token", None) + if token and hasattr(token, "claims"): + return token.claims + except Exception: + pass + + return None + + +def setup_user_context(ctx) -> None: + """Set up user context from FastMCP context for the current request. + + Call this at the start of request handling to make user info + available throughout the request via get_current_user(). + + Args: + ctx: FastMCP Context object. + """ + user_info = extract_user_from_context(ctx) + set_current_user(user_info) + + +class PermissionMiddleware: + """Middleware for adding permission checks to all tools. + + This can be used to wrap mixin tool registration with permission checking. + """ + + def __init__(self, oauth_enabled: bool = False): + """Initialize middleware. + + Args: + oauth_enabled: Whether OAuth authentication is enabled. + """ + self.oauth_enabled = oauth_enabled + + def wrap_tool(self, tool_name: str, func: Callable) -> Callable: + """Wrap a tool function with permission checking. + + Args: + tool_name: Name of the tool. + func: Original tool function. + + Returns: + Wrapped function with permission checks. + """ + if not self.oauth_enabled: + # No auth - just add basic audit logging + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + start_time = time.perf_counter() + try: + result = func(*args, **kwargs) + duration_ms = (time.perf_counter() - start_time) * 1000 + audit_log(tool_name, kwargs, result=str(result), duration_ms=duration_ms) + return result + except Exception as e: + duration_ms = (time.perf_counter() - start_time) * 1000 + audit_log(tool_name, kwargs, error=str(e), duration_ms=duration_ms) + raise + + return wrapper + + # With auth - add permission checking + return with_permission_check(tool_name)(func) + + +def get_permission_summary() -> dict[str, list[str]]: + """Get a summary of tools grouped by permission level. + + Returns: + Dict mapping permission level names to lists of tool names. + """ + from mcvsphere.permissions import TOOL_PERMISSIONS + + summary: dict[str, list[str]] = {level.value: [] for level in PermissionLevel} + + for tool_name, level in TOOL_PERMISSIONS.items(): + summary[level.value].append(tool_name) + + # Sort tool names within each level + for level in summary: + summary[level].sort() + + return summary diff --git a/src/mcvsphere/permissions.py b/src/mcvsphere/permissions.py new file mode 100644 index 0000000..1fda649 --- /dev/null +++ b/src/mcvsphere/permissions.py @@ -0,0 +1,238 @@ +"""Permission escalation based on OAuth claims. + +Defines permission levels and maps: +- Tools → Required permission level +- OAuth groups → Granted permission levels +""" + +from enum import Enum + + +class PermissionLevel(Enum): + """Permission levels for tool access, from least to most privileged.""" + + READ_ONLY = "read_only" # View-only operations + POWER_OPS = "power_ops" # Power on/off, snapshots + VM_LIFECYCLE = "vm_lifecycle" # Create/delete/modify VMs + HOST_ADMIN = "host_admin" # ESXi host operations + FULL_ADMIN = "full_admin" # Everything including guest ops, services + + +# Tool → Required Permission mapping +# Default is READ_ONLY if not listed +TOOL_PERMISSIONS: dict[str, PermissionLevel] = { + # ═══════════════════════════════════════════════════════════════════════ + # READ_ONLY - Safe viewing operations (36 tools) + # ═══════════════════════════════════════════════════════════════════════ + "list_vms": PermissionLevel.READ_ONLY, + "get_vm_info": PermissionLevel.READ_ONLY, + "list_snapshots": PermissionLevel.READ_ONLY, + "get_vm_stats": PermissionLevel.READ_ONLY, + "get_host_stats": PermissionLevel.READ_ONLY, + "list_hosts": PermissionLevel.READ_ONLY, + "get_recent_tasks": PermissionLevel.READ_ONLY, + "get_recent_events": PermissionLevel.READ_ONLY, + "get_alarms": PermissionLevel.READ_ONLY, + "browse_datastore": PermissionLevel.READ_ONLY, + "get_datastore_info": PermissionLevel.READ_ONLY, + "get_network_info": PermissionLevel.READ_ONLY, + "get_resource_pool_info": PermissionLevel.READ_ONLY, + "list_templates": PermissionLevel.READ_ONLY, + "get_vcenter_info": PermissionLevel.READ_ONLY, + "list_disks": PermissionLevel.READ_ONLY, + "list_nics": PermissionLevel.READ_ONLY, + "list_ovf_networks": PermissionLevel.READ_ONLY, + "get_host_info": PermissionLevel.READ_ONLY, + "list_services": PermissionLevel.READ_ONLY, + "get_ntp_config": PermissionLevel.READ_ONLY, + "get_host_hardware": PermissionLevel.READ_ONLY, + "get_host_networking": PermissionLevel.READ_ONLY, + "list_folders": PermissionLevel.READ_ONLY, + "list_recent_tasks": PermissionLevel.READ_ONLY, + "list_recent_events": PermissionLevel.READ_ONLY, + "list_clusters": PermissionLevel.READ_ONLY, + "get_drs_recommendations": PermissionLevel.READ_ONLY, + "get_serial_port": PermissionLevel.READ_ONLY, + "wait_for_vm_tools": PermissionLevel.READ_ONLY, + "get_vm_tools_status": PermissionLevel.READ_ONLY, + "vm_screenshot": PermissionLevel.READ_ONLY, + # ═══════════════════════════════════════════════════════════════════════ + # POWER_OPS - Power and snapshot operations (14 tools) + # ═══════════════════════════════════════════════════════════════════════ + "power_on": PermissionLevel.POWER_OPS, + "power_off": PermissionLevel.POWER_OPS, + "shutdown_guest": PermissionLevel.POWER_OPS, + "reboot_guest": PermissionLevel.POWER_OPS, + "reset_vm": PermissionLevel.POWER_OPS, + "suspend_vm": PermissionLevel.POWER_OPS, + "standby_guest": PermissionLevel.POWER_OPS, + "create_snapshot": PermissionLevel.POWER_OPS, + "revert_to_snapshot": PermissionLevel.POWER_OPS, + "revert_to_current_snapshot": PermissionLevel.POWER_OPS, + "delete_snapshot": PermissionLevel.POWER_OPS, + "delete_all_snapshots": PermissionLevel.POWER_OPS, + "rename_snapshot": PermissionLevel.POWER_OPS, + "connect_nic": PermissionLevel.POWER_OPS, # Connect/disconnect is power-level + # ═══════════════════════════════════════════════════════════════════════ + # VM_LIFECYCLE - Create/delete/modify VMs (28 tools) + # ═══════════════════════════════════════════════════════════════════════ + "create_vm": PermissionLevel.VM_LIFECYCLE, + "clone_vm": PermissionLevel.VM_LIFECYCLE, + "delete_vm": PermissionLevel.VM_LIFECYCLE, + "reconfigure_vm": PermissionLevel.VM_LIFECYCLE, + "rename_vm": PermissionLevel.VM_LIFECYCLE, + "add_disk": PermissionLevel.VM_LIFECYCLE, + "remove_disk": PermissionLevel.VM_LIFECYCLE, + "extend_disk": PermissionLevel.VM_LIFECYCLE, + "attach_iso": PermissionLevel.VM_LIFECYCLE, + "detach_iso": PermissionLevel.VM_LIFECYCLE, + "add_nic": PermissionLevel.VM_LIFECYCLE, + "remove_nic": PermissionLevel.VM_LIFECYCLE, + "change_nic_network": PermissionLevel.VM_LIFECYCLE, + "set_nic_mac": PermissionLevel.VM_LIFECYCLE, + "deploy_ovf": PermissionLevel.VM_LIFECYCLE, + "export_vm_ovf": PermissionLevel.VM_LIFECYCLE, + "convert_to_template": PermissionLevel.VM_LIFECYCLE, + "convert_to_vm": PermissionLevel.VM_LIFECYCLE, + "deploy_from_template": PermissionLevel.VM_LIFECYCLE, + "create_folder": PermissionLevel.VM_LIFECYCLE, + "move_vm_to_folder": PermissionLevel.VM_LIFECYCLE, + "storage_vmotion": PermissionLevel.VM_LIFECYCLE, + "move_vm_disk": PermissionLevel.VM_LIFECYCLE, + "setup_serial_port": PermissionLevel.VM_LIFECYCLE, + "connect_serial_port": PermissionLevel.VM_LIFECYCLE, + "clear_serial_port": PermissionLevel.VM_LIFECYCLE, + "remove_serial_port": PermissionLevel.VM_LIFECYCLE, + # Datastore modifications + "download_from_datastore": PermissionLevel.VM_LIFECYCLE, + "upload_to_datastore": PermissionLevel.VM_LIFECYCLE, + "delete_datastore_file": PermissionLevel.VM_LIFECYCLE, + "create_datastore_folder": PermissionLevel.VM_LIFECYCLE, + "move_datastore_file": PermissionLevel.VM_LIFECYCLE, + "copy_datastore_file": PermissionLevel.VM_LIFECYCLE, + # ═══════════════════════════════════════════════════════════════════════ + # HOST_ADMIN - ESXi host operations (6 tools) + # ═══════════════════════════════════════════════════════════════════════ + "enter_maintenance_mode": PermissionLevel.HOST_ADMIN, + "exit_maintenance_mode": PermissionLevel.HOST_ADMIN, + "reboot_host": PermissionLevel.HOST_ADMIN, + "shutdown_host": PermissionLevel.HOST_ADMIN, + "configure_ntp": PermissionLevel.HOST_ADMIN, + "set_service_policy": PermissionLevel.HOST_ADMIN, + # ═══════════════════════════════════════════════════════════════════════ + # FULL_ADMIN - Everything including guest OS and service control (10 tools) + # ═══════════════════════════════════════════════════════════════════════ + "start_service": PermissionLevel.FULL_ADMIN, + "stop_service": PermissionLevel.FULL_ADMIN, + # Guest OS operations (requires guest credentials, high privilege) + "run_command_in_guest": PermissionLevel.FULL_ADMIN, + "list_guest_processes": PermissionLevel.FULL_ADMIN, + "read_guest_file": PermissionLevel.FULL_ADMIN, + "write_guest_file": PermissionLevel.FULL_ADMIN, + "list_guest_directory": PermissionLevel.FULL_ADMIN, + "create_guest_directory": PermissionLevel.FULL_ADMIN, + "delete_guest_file": PermissionLevel.FULL_ADMIN, +} + + +# OAuth Group → Granted Permission Levels +# Users inherit all permissions from their groups (union of all group permissions) +GROUP_PERMISSIONS: dict[str, set[PermissionLevel]] = { + # View-only access + "vsphere-readers": { + PermissionLevel.READ_ONLY, + }, + # Operators can power on/off, manage snapshots + "vsphere-operators": { + PermissionLevel.READ_ONLY, + PermissionLevel.POWER_OPS, + }, + # Admins can create/delete/modify VMs + "vsphere-admins": { + PermissionLevel.READ_ONLY, + PermissionLevel.POWER_OPS, + PermissionLevel.VM_LIFECYCLE, + }, + # Host admins can manage ESXi hosts + "vsphere-host-admins": { + PermissionLevel.READ_ONLY, + PermissionLevel.POWER_OPS, + PermissionLevel.VM_LIFECYCLE, + PermissionLevel.HOST_ADMIN, + }, + # Super admins have full access + "vsphere-super-admins": { + PermissionLevel.READ_ONLY, + PermissionLevel.POWER_OPS, + PermissionLevel.VM_LIFECYCLE, + PermissionLevel.HOST_ADMIN, + PermissionLevel.FULL_ADMIN, + }, +} + + +class PermissionDeniedError(Exception): + """Raised when user lacks permission for an operation.""" + + def __init__(self, username: str, tool_name: str, required: PermissionLevel): + self.username = username + self.tool_name = tool_name + self.required = required + super().__init__( + f"Permission denied: {username} lacks '{required.value}' permission for '{tool_name}'" + ) + + +def get_user_permissions(groups: list[str] | None) -> set[PermissionLevel]: + """Extract permissions from OAuth groups. + + Args: + groups: List of OAuth group names from token claims. + + Returns: + Set of granted permission levels (union of all group permissions). + """ + if not groups: + return {PermissionLevel.READ_ONLY} + + permissions: set[PermissionLevel] = set() + + for group in groups: + if group in GROUP_PERMISSIONS: + permissions.update(GROUP_PERMISSIONS[group]) + + # Default to read-only if no recognized groups + if not permissions: + permissions.add(PermissionLevel.READ_ONLY) + + return permissions + + +def get_required_permission(tool_name: str) -> PermissionLevel: + """Get required permission level for a tool. + + Args: + tool_name: Name of the MCP tool. + + Returns: + Required permission level (defaults to READ_ONLY if not mapped). + """ + return TOOL_PERMISSIONS.get(tool_name, PermissionLevel.READ_ONLY) + + +def check_permission( + tool_name: str, + groups: list[str] | None, +) -> bool: + """Check if user has permission for a tool. + + Args: + tool_name: Name of the MCP tool to check. + groups: OAuth groups from token claims. + + Returns: + True if user has required permission, False otherwise. + """ + required = get_required_permission(tool_name) + user_perms = get_user_permissions(groups) + return required in user_perms diff --git a/src/mcvsphere/server.py b/src/mcvsphere/server.py index 002d0fd..f031a55 100644 --- a/src/mcvsphere/server.py +++ b/src/mcvsphere/server.py @@ -6,6 +6,7 @@ from pathlib import Path from fastmcp import FastMCP +from mcvsphere.auth import create_auth_provider from mcvsphere.config import Settings, get_settings from mcvsphere.connection import VMwareConnection from mcvsphere.mixins import ( @@ -53,6 +54,9 @@ def create_server(settings: Settings | None = None) -> FastMCP: stream=sys.stderr, # Explicitly use stderr ) + # Create auth provider if OAuth enabled + auth = create_auth_provider(settings) + # Create FastMCP server mcp = FastMCP( name="mcvsphere", @@ -61,6 +65,7 @@ def create_server(settings: Settings | None = None) -> FastMCP: "Provides tools for VM lifecycle management, power operations, " "snapshots, guest OS operations, monitoring, and infrastructure resources." ), + auth=auth, ) # Create shared VMware connection @@ -114,8 +119,8 @@ def run_server(config_path: Path | None = None) -> None: # Load settings settings = Settings.from_yaml(config_path) if config_path else get_settings() - # Only print banner for SSE mode (stdio must stay clean for JSON-RPC) - if settings.mcp_transport == "sse": + # Only print banner for HTTP/SSE modes (stdio must stay clean for JSON-RPC) + if settings.mcp_transport in ("sse", "http"): try: from importlib.metadata import version @@ -125,15 +130,23 @@ def run_server(config_path: Path | None = None) -> None: print(f"mcvsphere v{package_version}", file=sys.stderr) print("─" * 40, file=sys.stderr) + transport_name = "HTTP" if settings.mcp_transport == "http" else "SSE" print( - f"Starting SSE transport on {settings.mcp_host}:{settings.mcp_port}", + f"Starting {transport_name} transport on {settings.mcp_host}:{settings.mcp_port}", file=sys.stderr, ) + if settings.oauth_enabled: + print(f"OAuth: ENABLED via {settings.oauth_issuer_url}", file=sys.stderr) + else: + print("OAuth: disabled", file=sys.stderr) + print("─" * 40, file=sys.stderr) # Create and run server mcp = create_server(settings) - if settings.mcp_transport == "sse": + if settings.mcp_transport == "http": + mcp.run(transport="streamable-http", host=settings.mcp_host, port=settings.mcp_port) + elif settings.mcp_transport == "sse": mcp.run(transport="sse", host=settings.mcp_host, port=settings.mcp_port) else: # stdio mode - suppress banner to keep stdout clean for JSON-RPC diff --git a/uv.lock b/uv.lock index 401ae5f..c173563 100644 --- a/uv.lock +++ b/uv.lock @@ -729,7 +729,7 @@ wheels = [ [[package]] name = "mcvsphere" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "fastmcp" }, From 64ba7a69de51addbb63908a20ea370b725a011c3 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 27 Dec 2025 05:27:21 -0700 Subject: [PATCH 3/3] fix OAuth token validation for Authentik opaque tokens - Remove required_scopes validation (Authentik doesn't embed scopes in JWT) - Add oauth_base_url config for proper HTTPS callback URLs - Add docker-compose.dev.yml for host proxy via Caddy - Update docker-compose.oauth.yml with unique domain label Authentik uses opaque access tokens that don't include scope claims. Authentication is enforced at the IdP level, so scope validation in the token is unnecessary and was causing 401 errors. --- Dockerfile | 2 +- docker-compose.dev.yml | 22 ++++++++++++++++++++++ docker-compose.oauth.yml | 39 +++++++++++++++++++++++++++++++++++++-- src/mcvsphere/auth.py | 17 +++++++++++++---- src/mcvsphere/config.py | 10 +++++++--- src/mcvsphere/server.py | 6 +++--- 6 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 docker-compose.dev.yml diff --git a/Dockerfile b/Dockerfile index 2edc0f1..27f8dbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-install-project --no-dev # Install project -COPY pyproject.toml uv.lock ./ +COPY pyproject.toml uv.lock README.md ./ COPY src ./src RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev --no-editable diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..85ea1f2 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,22 @@ +# Development proxy for mcvsphere running on host +# Usage: docker compose -f docker-compose.dev.yml up -d + +services: + # Proxy container - just provides caddy labels for host-running server + mcvsphere-proxy: + image: alpine:latest + container_name: mcvsphere-proxy + command: ["sleep", "infinity"] + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - caddy + labels: + # Caddy reverse proxy to host-running mcvsphere server + caddy: mcp.l.supported.systems + # Use caddy network gateway (172.18.0.1) to reach host services + caddy.reverse_proxy: "172.18.0.1:8080" + +networks: + caddy: + external: true diff --git a/docker-compose.oauth.yml b/docker-compose.oauth.yml index d79d92c..c1a6f73 100644 --- a/docker-compose.oauth.yml +++ b/docker-compose.oauth.yml @@ -5,6 +5,38 @@ # Requires AUTHENTIK_* environment variables to be set. services: + # ───────────────────────────────────────────────────────────────────────── + # mcvsphere MCP Server (with OAuth + HTTPS via Caddy) + # ───────────────────────────────────────────────────────────────────────── + mcvsphere: + build: + context: . + dockerfile: Dockerfile + container_name: mcvsphere + restart: unless-stopped + volumes: + - ./logs:/app/logs + env_file: + - .env.oauth + environment: + - MCP_TRANSPORT=streamable-http + - MCP_HOST=0.0.0.0 + - MCP_PORT=8080 + networks: + - mcvsphere-network + - caddy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + labels: + # Caddy reverse proxy via caddy-docker-proxy + caddy: mcp.localhost + caddy.reverse_proxy: "{{upstreams 8080}}" + caddy.tls: internal + # ───────────────────────────────────────────────────────────────────────── # PostgreSQL for Authentik # ───────────────────────────────────────────────────────────────────────── @@ -76,6 +108,7 @@ services: networks: - authentik-internal - mcvsphere-network + - caddy healthcheck: test: ["CMD", "ak", "healthcheck"] interval: 30s @@ -83,8 +116,8 @@ services: retries: 3 start_period: 60s labels: - # Caddy reverse proxy (if using caddy-docker-proxy) - caddy: ${AUTHENTIK_HOST:-auth.localhost} + # Caddy reverse proxy via caddy-docker-proxy + caddy: ${AUTHENTIK_HOST:-auth.l.supported.systems} caddy.reverse_proxy: "{{upstreams 9000}}" # ───────────────────────────────────────────────────────────────────────── @@ -119,6 +152,8 @@ networks: mcvsphere-network: external: true name: ${COMPOSE_PROJECT_NAME:-mcvsphere}_default + caddy: + external: true volumes: authentik-db-data: diff --git a/src/mcvsphere/auth.py b/src/mcvsphere/auth.py index 580d896..500f5e9 100644 --- a/src/mcvsphere/auth.py +++ b/src/mcvsphere/auth.py @@ -36,25 +36,34 @@ def create_auth_provider(settings: Settings): config_url = issuer_url # Build base URL for the MCP server - base_url = f"http://{settings.mcp_host}:{settings.mcp_port}" + # This URL is used for OAuth callbacks and must be HTTPS and externally accessible + if settings.oauth_base_url: + base_url = settings.oauth_base_url.rstrip("/") + else: + # Default: construct from host/port (for direct HTTPS access) + host = "localhost" if settings.mcp_host in ("0.0.0.0", "127.0.0.1") else settings.mcp_host + base_url = f"https://{host}:{settings.mcp_port}" logger.info("Configuring OAuth with OIDC provider: %s", issuer_url) + logger.info("OAuth base URL: %s", base_url) try: + # Note: Authentik's access tokens are opaque and don't include scope claims. + # We request scopes during authorization but don't validate them in the token. + # Authentication is sufficient - Authentik enforces scope grants at the IdP level. auth = OIDCProxy( config_url=config_url, client_id=settings.oauth_client_id, client_secret=settings.oauth_client_secret.get_secret_value(), base_url=base_url, - required_scopes=settings.oauth_scopes, - # Allow Claude Code localhost redirects + required_scopes=[], # Don't validate scopes in token (Authentik uses opaque tokens) allowed_client_redirect_uris=[ "http://localhost:*", "http://127.0.0.1:*", ], - # Skip consent screen for MCP clients (they're already trusted) require_authorization_consent=False, ) + logger.info("OAuth authentication enabled via OIDC") return auth diff --git a/src/mcvsphere/config.py b/src/mcvsphere/config.py index 1191a44..f087f83 100644 --- a/src/mcvsphere/config.py +++ b/src/mcvsphere/config.py @@ -49,8 +49,8 @@ class Settings(BaseSettings): ) mcp_host: str = Field(default="0.0.0.0", description="Server bind address") mcp_port: int = Field(default=8080, description="Server port") - mcp_transport: Literal["stdio", "sse", "http"] = Field( - default="stdio", description="MCP transport type (http required for OAuth)" + mcp_transport: Literal["stdio", "sse", "http", "streamable-http"] = Field( + default="stdio", description="MCP transport type (http/streamable-http required for OAuth)" ) # OAuth/OIDC settings @@ -75,6 +75,10 @@ class Settings(BaseSettings): default_factory=list, description="OAuth groups required for access (empty = any authenticated user)", ) + oauth_base_url: str | None = Field( + default=None, + description="External base URL for OAuth callbacks (e.g., https://mcp.localhost). Auto-generated if not specified.", + ) # Logging settings log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field( @@ -109,7 +113,7 @@ class Settings(BaseSettings): # OAuth requires HTTP transport if self.mcp_transport == "stdio": raise ValueError( - "OAuth requires HTTP transport. Set mcp_transport='http' or 'sse'" + "OAuth requires HTTP transport. Set mcp_transport='http', 'sse', or 'streamable-http'" ) return self diff --git a/src/mcvsphere/server.py b/src/mcvsphere/server.py index f031a55..d998286 100644 --- a/src/mcvsphere/server.py +++ b/src/mcvsphere/server.py @@ -120,7 +120,7 @@ def run_server(config_path: Path | None = None) -> None: settings = Settings.from_yaml(config_path) if config_path else get_settings() # Only print banner for HTTP/SSE modes (stdio must stay clean for JSON-RPC) - if settings.mcp_transport in ("sse", "http"): + if settings.mcp_transport in ("sse", "http", "streamable-http"): try: from importlib.metadata import version @@ -130,7 +130,7 @@ def run_server(config_path: Path | None = None) -> None: print(f"mcvsphere v{package_version}", file=sys.stderr) print("─" * 40, file=sys.stderr) - transport_name = "HTTP" if settings.mcp_transport == "http" else "SSE" + transport_name = "HTTP" if settings.mcp_transport in ("http", "streamable-http") else "SSE" print( f"Starting {transport_name} transport on {settings.mcp_host}:{settings.mcp_port}", file=sys.stderr, @@ -144,7 +144,7 @@ def run_server(config_path: Path | None = None) -> None: # Create and run server mcp = create_server(settings) - if settings.mcp_transport == "http": + if settings.mcp_transport in ("http", "streamable-http"): mcp.run(transport="streamable-http", host=settings.mcp_host, port=settings.mcp_port) elif settings.mcp_transport == "sse": mcp.run(transport="sse", host=settings.mcp_host, port=settings.mcp_port)