From 64ba7a69de51addbb63908a20ea370b725a011c3 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 27 Dec 2025 05:27:21 -0700 Subject: [PATCH] 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)