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
|
uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
# Install project
|
# Install project
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock README.md ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --frozen --no-dev --no-editable
|
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.
|
# Requires AUTHENTIK_* environment variables to be set.
|
||||||
|
|
||||||
services:
|
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
|
# PostgreSQL for Authentik
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
@ -76,6 +108,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- authentik-internal
|
- authentik-internal
|
||||||
- mcvsphere-network
|
- mcvsphere-network
|
||||||
|
- caddy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "ak", "healthcheck"]
|
test: ["CMD", "ak", "healthcheck"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@ -83,8 +116,8 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
labels:
|
labels:
|
||||||
# Caddy reverse proxy (if using caddy-docker-proxy)
|
# Caddy reverse proxy via caddy-docker-proxy
|
||||||
caddy: ${AUTHENTIK_HOST:-auth.localhost}
|
caddy: ${AUTHENTIK_HOST:-auth.l.supported.systems}
|
||||||
caddy.reverse_proxy: "{{upstreams 9000}}"
|
caddy.reverse_proxy: "{{upstreams 9000}}"
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
@ -119,6 +152,8 @@ networks:
|
|||||||
mcvsphere-network:
|
mcvsphere-network:
|
||||||
external: true
|
external: true
|
||||||
name: ${COMPOSE_PROJECT_NAME:-mcvsphere}_default
|
name: ${COMPOSE_PROJECT_NAME:-mcvsphere}_default
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
authentik-db-data:
|
authentik-db-data:
|
||||||
|
|||||||
@ -36,25 +36,34 @@ def create_auth_provider(settings: Settings):
|
|||||||
config_url = issuer_url
|
config_url = issuer_url
|
||||||
|
|
||||||
# Build base URL for the MCP server
|
# 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("Configuring OAuth with OIDC provider: %s", issuer_url)
|
||||||
|
logger.info("OAuth base URL: %s", base_url)
|
||||||
|
|
||||||
try:
|
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(
|
auth = OIDCProxy(
|
||||||
config_url=config_url,
|
config_url=config_url,
|
||||||
client_id=settings.oauth_client_id,
|
client_id=settings.oauth_client_id,
|
||||||
client_secret=settings.oauth_client_secret.get_secret_value(),
|
client_secret=settings.oauth_client_secret.get_secret_value(),
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
required_scopes=settings.oauth_scopes,
|
required_scopes=[], # Don't validate scopes in token (Authentik uses opaque tokens)
|
||||||
# Allow Claude Code localhost redirects
|
|
||||||
allowed_client_redirect_uris=[
|
allowed_client_redirect_uris=[
|
||||||
"http://localhost:*",
|
"http://localhost:*",
|
||||||
"http://127.0.0.1:*",
|
"http://127.0.0.1:*",
|
||||||
],
|
],
|
||||||
# Skip consent screen for MCP clients (they're already trusted)
|
|
||||||
require_authorization_consent=False,
|
require_authorization_consent=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("OAuth authentication enabled via OIDC")
|
logger.info("OAuth authentication enabled via OIDC")
|
||||||
return auth
|
return auth
|
||||||
|
|
||||||
|
|||||||
@ -49,8 +49,8 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
mcp_host: str = Field(default="0.0.0.0", description="Server bind address")
|
mcp_host: str = Field(default="0.0.0.0", description="Server bind address")
|
||||||
mcp_port: int = Field(default=8080, description="Server port")
|
mcp_port: int = Field(default=8080, description="Server port")
|
||||||
mcp_transport: Literal["stdio", "sse", "http"] = Field(
|
mcp_transport: Literal["stdio", "sse", "http", "streamable-http"] = Field(
|
||||||
default="stdio", description="MCP transport type (http required for OAuth)"
|
default="stdio", description="MCP transport type (http/streamable-http required for OAuth)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# OAuth/OIDC settings
|
# OAuth/OIDC settings
|
||||||
@ -75,6 +75,10 @@ class Settings(BaseSettings):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="OAuth groups required for access (empty = any authenticated user)",
|
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
|
# Logging settings
|
||||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
|
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
|
||||||
@ -109,7 +113,7 @@ class Settings(BaseSettings):
|
|||||||
# OAuth requires HTTP transport
|
# OAuth requires HTTP transport
|
||||||
if self.mcp_transport == "stdio":
|
if self.mcp_transport == "stdio":
|
||||||
raise ValueError(
|
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
|
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()
|
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)
|
# 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:
|
try:
|
||||||
from importlib.metadata import version
|
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(f"mcvsphere v{package_version}", file=sys.stderr)
|
||||||
print("─" * 40, 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(
|
print(
|
||||||
f"Starting {transport_name} transport on {settings.mcp_host}:{settings.mcp_port}",
|
f"Starting {transport_name} transport on {settings.mcp_host}:{settings.mcp_port}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
@ -144,7 +144,7 @@ def run_server(config_path: Path | None = None) -> None:
|
|||||||
# Create and run server
|
# Create and run server
|
||||||
mcp = create_server(settings)
|
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)
|
mcp.run(transport="streamable-http", host=settings.mcp_host, port=settings.mcp_port)
|
||||||
elif settings.mcp_transport == "sse":
|
elif settings.mcp_transport == "sse":
|
||||||
mcp.run(transport="sse", host=settings.mcp_host, port=settings.mcp_port)
|
mcp.run(transport="sse", host=settings.mcp_host, port=settings.mcp_port)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user