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.
This commit is contained in:
Ryan Malloy 2025-12-27 05:27:21 -07:00
parent cda49f2912
commit 64ba7a69de
6 changed files with 83 additions and 13 deletions

View File

@ -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

22
docker-compose.dev.yml Normal file
View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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)