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:
parent
cda49f2912
commit
64ba7a69de
@ -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
22
docker-compose.dev.yml
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user