Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 0.2.18 | :white_check_mark: |
| 0.2.17 | :white_check_mark: |
| 0.2.16 | :white_check_mark: |
| 0.2.15 | :white_check_mark: |
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "golf-mcp"
version = "0.2.17"
version = "0.2.18"
description = "Framework for building MCP servers"
authors = [
{name = "Antoni Gmitruk", email = "antoni@golf.dev"}
Expand Down Expand Up @@ -66,7 +66,7 @@ golf = ["examples/**/*"]

[tool.poetry]
name = "golf-mcp"
version = "0.2.17"
version = "0.2.18"
description = "Framework for building MCP servers with zero boilerplate"
authors = ["Antoni Gmitruk <antoni@golf.dev>"]
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/golf/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.17"
__version__ = "0.2.18"
90 changes: 67 additions & 23 deletions src/golf/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,40 +193,84 @@ def configure_dev_auth(


def configure_oauth_proxy(
authorization_endpoint: str,
token_endpoint: str,
client_id: str,
client_secret: str,
base_url: str,
token_verifier_config: JWTAuthConfig | StaticTokenConfig,
authorization_endpoint: str | None = None,
token_endpoint: str | None = None,
client_id: str | None = None,
client_secret: str | None = None,
base_url: str | None = None,
token_verifier_config: JWTAuthConfig | StaticTokenConfig | None = None,
scopes_supported: list[str] | None = None,
revocation_endpoint: str | None = None,
redirect_path: str = "/oauth/callback",
**env_vars: str,
) -> None:
"""Configure OAuth proxy authentication for non-DCR providers.

This sets up an OAuth proxy that bridges MCP clients (expecting DCR) with
traditional OAuth providers like GitHub, Google, Okta Web Apps that use
fixed client credentials.
All parameters can be provided either directly or via environment variables.
For each parameter, you can provide the value directly or use the
corresponding *_env_var parameter to specify an environment variable name.

Examples:
# Direct values (backward compatible)
configure_oauth_proxy(
authorization_endpoint="https://auth.example.com/authorize",
token_endpoint="https://auth.example.com/token",
client_id="my-client",
client_secret="my-secret",
base_url="https://myserver.com",
token_verifier_config=jwt_config,
)

# Environment variables only (new behavior)
configure_oauth_proxy(
authorization_endpoint_env_var="OAUTH_AUTH_ENDPOINT",
token_endpoint_env_var="OAUTH_TOKEN_ENDPOINT",
client_id_env_var="OAUTH_CLIENT_ID",
client_secret_env_var="OAUTH_CLIENT_SECRET",
base_url_env_var="OAUTH_BASE_URL",
token_verifier_config=jwt_config,
)

# Mixed (direct values with env var overrides)
configure_oauth_proxy(
authorization_endpoint="https://default.example.com/authorize",
authorization_endpoint_env_var="OAUTH_AUTH_ENDPOINT", # Overrides at runtime
# ...
)

Args:
authorization_endpoint: Provider's authorization URL
token_endpoint: Provider's token endpoint URL
client_id: Your client ID registered with the provider
client_secret: Your client secret from the provider
base_url: This proxy server's public URL
token_verifier_config: JWT or static token config for token verification
scopes_supported: Scopes to advertise to MCP clients
authorization_endpoint: OAuth provider's authorization endpoint URL
token_endpoint: OAuth provider's token endpoint URL
client_id: Your registered client ID with the OAuth provider
client_secret: Your registered client secret with the OAuth provider
base_url: Public URL of this OAuth proxy server
token_verifier_config: JWT or Static token configuration for verifying tokens
scopes_supported: List of OAuth scopes this proxy supports
revocation_endpoint: Optional token revocation endpoint
redirect_path: OAuth callback path (default: /oauth/callback)
**env_vars: Environment variable names (authorization_endpoint_env_var,
token_endpoint_env_var, client_id_env_var, client_secret_env_var,
base_url_env_var, revocation_endpoint_env_var)

Note:
Requires golf-mcp-enterprise package for implementation.
redirect_path: OAuth callback path (default: "/oauth/callback")
**env_vars: Environment variable names for runtime configuration
- authorization_endpoint_env_var: Env var for authorization endpoint
- token_endpoint_env_var: Env var for token endpoint
- client_id_env_var: Env var for client ID
- client_secret_env_var: Env var for client secret
- base_url_env_var: Env var for base URL
- revocation_endpoint_env_var: Env var for revocation endpoint

Raises:
ValueError: If token_verifier_config is not provided or invalid
ValueError: If required fields lack both direct value and env var
"""
# Validate token_verifier_config is provided (always required)
if token_verifier_config is None:
raise ValueError("token_verifier_config is required and must be JWTAuthConfig or StaticTokenConfig")

if not isinstance(token_verifier_config, (JWTAuthConfig, StaticTokenConfig)):
raise ValueError(
f"token_verifier_config must be JWTAuthConfig or StaticTokenConfig, "
f"got {type(token_verifier_config).__name__}"
)

# Create config with all parameters (None values are OK now)
config = OAuthProxyConfig(
authorization_endpoint=authorization_endpoint,
token_endpoint=token_endpoint,
Expand Down
217 changes: 208 additions & 9 deletions src/golf/auth/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,19 +254,218 @@ def _create_remote_provider(config: RemoteAuthConfig) -> "AuthProvider":


def _create_oauth_proxy_provider(config: OAuthProxyConfig) -> "AuthProvider":
"""Create OAuth proxy provider - requires enterprise package."""
"""Create OAuth proxy provider from configuration with runtime validation."""
# Resolve runtime values from environment variables
authorization_endpoint = config.authorization_endpoint
if config.authorization_endpoint_env_var:
env_value = os.environ.get(config.authorization_endpoint_env_var)
if env_value:
# Validate the URL from environment
env_value = env_value.strip()
try:
from urllib.parse import urlparse
parsed = urlparse(env_value)
if not parsed.scheme or not parsed.netloc:
raise ValueError(
f"Invalid authorization_endpoint from environment variable "
f"{config.authorization_endpoint_env_var}: '{env_value}' - "
f"must be a valid URL with scheme and netloc"
)
if parsed.scheme not in ("http", "https"):
raise ValueError(
f"Authorization endpoint from {config.authorization_endpoint_env_var} "
f"must use http or https: '{env_value}'"
)
authorization_endpoint = env_value
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(
f"Invalid authorization_endpoint from {config.authorization_endpoint_env_var}: {e}"
) from e

token_endpoint = config.token_endpoint
if config.token_endpoint_env_var:
env_value = os.environ.get(config.token_endpoint_env_var)
if env_value:
# Validate the URL from environment
env_value = env_value.strip()
try:
from urllib.parse import urlparse
parsed = urlparse(env_value)
if not parsed.scheme or not parsed.netloc:
raise ValueError(
f"Invalid token_endpoint from environment variable "
f"{config.token_endpoint_env_var}: '{env_value}'"
)
if parsed.scheme not in ("http", "https"):
raise ValueError(
f"Token endpoint from {config.token_endpoint_env_var} "
f"must use http or https: '{env_value}'"
)
token_endpoint = env_value
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(
f"Invalid token_endpoint from {config.token_endpoint_env_var}: {e}"
) from e

client_id = config.client_id
if config.client_id_env_var:
env_value = os.environ.get(config.client_id_env_var)
if env_value:
client_id = env_value.strip()
if not client_id:
raise ValueError(
f"Client ID from environment variable {config.client_id_env_var} cannot be empty"
)

client_secret = config.client_secret
if config.client_secret_env_var:
env_value = os.environ.get(config.client_secret_env_var)
if env_value:
client_secret = env_value.strip()
if not client_secret:
raise ValueError(
f"Client secret from environment variable {config.client_secret_env_var} cannot be empty"
)

base_url = config.base_url
if config.base_url_env_var:
env_value = os.environ.get(config.base_url_env_var)
if env_value:
# Validate the URL from environment
env_value = env_value.strip()
try:
from urllib.parse import urlparse
parsed = urlparse(env_value)
if not parsed.scheme or not parsed.netloc:
raise ValueError(
f"Invalid base_url from environment variable "
f"{config.base_url_env_var}: '{env_value}'"
)
if parsed.scheme not in ("http", "https"):
raise ValueError(
f"Base URL from {config.base_url_env_var} "
f"must use http or https: '{env_value}'"
)
base_url = env_value
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(
f"Invalid base_url from {config.base_url_env_var}: {e}"
) from e

revocation_endpoint = config.revocation_endpoint
if config.revocation_endpoint_env_var:
env_value = os.environ.get(config.revocation_endpoint_env_var)
if env_value:
# Validate optional URL from environment
env_value = env_value.strip()
if env_value: # Only validate if not empty
try:
from urllib.parse import urlparse
parsed = urlparse(env_value)
if not parsed.scheme or not parsed.netloc:
raise ValueError(
f"Invalid revocation_endpoint from environment variable "
f"{config.revocation_endpoint_env_var}: '{env_value}'"
)
if parsed.scheme not in ("http", "https"):
raise ValueError(
f"Revocation endpoint from {config.revocation_endpoint_env_var} "
f"must use http or https: '{env_value}'"
)
revocation_endpoint = env_value
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(
f"Invalid revocation_endpoint from {config.revocation_endpoint_env_var}: {e}"
) from e

# Final validation: ensure all required fields have values after env resolution
if not authorization_endpoint:
env_var_hint = f" (environment variable {config.authorization_endpoint_env_var} is not set)" \
if config.authorization_endpoint_env_var else ""
raise ValueError(f"Authorization endpoint is required but not provided{env_var_hint}")

if not token_endpoint:
env_var_hint = f" (environment variable {config.token_endpoint_env_var} is not set)" \
if config.token_endpoint_env_var else ""
raise ValueError(f"Token endpoint is required but not provided{env_var_hint}")

if not client_id:
env_var_hint = f" (environment variable {config.client_id_env_var} is not set)" \
if config.client_id_env_var else ""
raise ValueError(f"Client ID is required but not provided{env_var_hint}")

if not client_secret:
env_var_hint = f" (environment variable {config.client_secret_env_var} is not set)" \
if config.client_secret_env_var else ""
raise ValueError(f"Client secret is required but not provided{env_var_hint}")

if not base_url:
env_var_hint = f" (environment variable {config.base_url_env_var} is not set)" \
if config.base_url_env_var else ""
raise ValueError(f"Base URL is required but not provided{env_var_hint}")

# Production security checks
is_production = (
os.environ.get("GOLF_ENV", "").lower() in ("prod", "production")
or os.environ.get("NODE_ENV", "").lower() == "production"
or os.environ.get("ENVIRONMENT", "").lower() in ("prod", "production")
)

if is_production:
from urllib.parse import urlparse

# Check for HTTPS in production
for url_name, url_value in [
("authorization_endpoint", authorization_endpoint),
("token_endpoint", token_endpoint),
("base_url", base_url),
]:
parsed = urlparse(url_value)
if parsed.scheme == "http":
raise ValueError(
f"OAuth proxy {url_name} must use HTTPS in production environment: '{url_value}'"
)

# Check for localhost in production
parsed_base = urlparse(base_url)
if parsed_base.hostname in ("localhost", "127.0.0.1", "0.0.0.0"):
raise ValueError(
f"OAuth proxy base_url cannot use localhost/loopback addresses in production: '{base_url}'"
)

# Import and create the OAuth proxy provider
try:
# Try to import from enterprise package
from golf_enterprise import create_oauth_proxy_provider

return create_oauth_proxy_provider(config)
except ImportError as e:
except ImportError:
# Provide helpful error message
raise ImportError(
"OAuth Proxy requires golf-mcp-enterprise package. "
"This feature provides OAuth proxy functionality for non-DCR providers "
"(GitHub, Google, Okta Web Apps, etc.). "
"Contact sales@golf.dev for enterprise licensing."
) from e
"OAuth proxy authentication requires the golf-mcp-enterprise package. "
"Please install it with: pip install golf-mcp-enterprise"
) from None

# Create a new config with resolved values for the enterprise package
resolved_config = OAuthProxyConfig(
authorization_endpoint=authorization_endpoint,
token_endpoint=token_endpoint,
client_id=client_id,
client_secret=client_secret,
revocation_endpoint=revocation_endpoint,
base_url=base_url,
redirect_path=config.redirect_path,
scopes_supported=config.scopes_supported,
token_verifier_config=config.token_verifier_config,
)

return create_oauth_proxy_provider(resolved_config)


def create_simple_jwt_provider(
Expand Down
Loading
Loading