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
62 changes: 58 additions & 4 deletions src/golf/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
OAuthServerConfig,
RemoteAuthConfig,
OAuthProxyConfig,
# Type aliases for dynamic redirect URI configuration
RedirectPatternsProvider,
RedirectSchemesProvider,
RedirectUriValidator,
)
from .factory import (
create_auth_provider,
Expand Down Expand Up @@ -53,6 +57,10 @@
"OAuthServerConfig",
"RemoteAuthConfig",
"OAuthProxyConfig",
# Type aliases for dynamic redirect URI configuration
"RedirectPatternsProvider",
"RedirectSchemesProvider",
"RedirectUriValidator",
# Factory functions
"create_auth_provider",
"create_simple_jwt_provider",
Expand Down Expand Up @@ -202,6 +210,13 @@ def configure_oauth_proxy(
scopes_supported: list[str] | None = None,
revocation_endpoint: str | None = None,
redirect_path: str = "/oauth/callback",
# Static redirect URI configuration
allowed_redirect_patterns: list[str] | None = None,
allowed_redirect_schemes: list[str] | None = None,
# Dynamic redirect URI configuration (callables for runtime evaluation)
allowed_redirect_patterns_func: RedirectPatternsProvider | None = None,
allowed_redirect_schemes_func: RedirectSchemesProvider | None = None,
redirect_uri_validator: RedirectUriValidator | None = None,
**env_vars: str,
) -> None:
"""Configure OAuth proxy authentication for non-DCR providers.
Expand All @@ -210,6 +225,10 @@ def configure_oauth_proxy(
For each parameter, you can provide the value directly or use the
corresponding *_env_var parameter to specify an environment variable name.

Redirect URI validation supports both static and dynamic configuration:
- Static: Use allowed_redirect_patterns and allowed_redirect_schemes lists
- Dynamic: Use callable functions that are evaluated at runtime for each request

Examples:
# Direct values (backward compatible)
configure_oauth_proxy(
Expand All @@ -231,11 +250,32 @@ def configure_oauth_proxy(
token_verifier_config=jwt_config,
)

# Mixed (direct values with env var overrides)
# Dynamic redirect URI validation with feature flags
def get_allowed_patterns():
# Could fetch from Amplitude, LaunchDarkly, database, etc.
if amplitude.is_enabled("new-redirect-uris"):
return ["https://new-app.example.com/*"]
return ["https://legacy-app.example.com/*"]

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,
allowed_redirect_patterns_func=get_allowed_patterns,
)

# Custom redirect URI validator for complex logic
def validate_redirect_uri(uri: str) -> bool:
# Custom validation logic - check database, feature flags, etc.
allowed = fetch_allowed_uris_from_database()
return uri in allowed

configure_oauth_proxy(
authorization_endpoint="https://default.example.com/authorize",
authorization_endpoint_env_var="OAUTH_AUTH_ENDPOINT", # Overrides at runtime
# ...
# ... other config ...
redirect_uri_validator=validate_redirect_uri,
)

Args:
Expand All @@ -248,13 +288,20 @@ def configure_oauth_proxy(
scopes_supported: List of OAuth scopes this proxy supports
revocation_endpoint: Optional token revocation endpoint
redirect_path: OAuth callback path (default: "/oauth/callback")
allowed_redirect_patterns: Static list of redirect URI patterns
allowed_redirect_schemes: Static list of allowed URI schemes
allowed_redirect_patterns_func: Callable returning patterns (evaluated per request)
allowed_redirect_schemes_func: Callable returning schemes (evaluated per request)
redirect_uri_validator: Custom validator function for redirect URIs
**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
- allowed_redirect_patterns_env_var: Env var for redirect patterns
- allowed_redirect_schemes_env_var: Env var for redirect schemes

Raises:
ValueError: If token_verifier_config is not provided or invalid
Expand All @@ -281,6 +328,13 @@ def configure_oauth_proxy(
redirect_path=redirect_path,
scopes_supported=scopes_supported,
token_verifier_config=token_verifier_config,
# Static redirect URI configuration
allowed_redirect_patterns=allowed_redirect_patterns,
allowed_redirect_schemes=allowed_redirect_schemes,
# Dynamic redirect URI configuration
allowed_redirect_patterns_func=allowed_redirect_patterns_func,
allowed_redirect_schemes_func=allowed_redirect_schemes_func,
redirect_uri_validator=redirect_uri_validator,
**env_vars,
)
configure_auth(config)
Expand Down
22 changes: 22 additions & 0 deletions src/golf/auth/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,21 @@ def _create_oauth_proxy_provider(config: OAuthProxyConfig) -> "AuthProvider":
"Please install it with: pip install golf-mcp-enterprise"
) from None

# Resolve static redirect patterns from environment variables
allowed_redirect_patterns = config.allowed_redirect_patterns
if config.allowed_redirect_patterns_env_var:
env_value = os.environ.get(config.allowed_redirect_patterns_env_var)
if env_value:
# Split comma-separated values and strip whitespace
allowed_redirect_patterns = [p.strip() for p in env_value.split(",") if p.strip()]

allowed_redirect_schemes = config.allowed_redirect_schemes
if config.allowed_redirect_schemes_env_var:
env_value = os.environ.get(config.allowed_redirect_schemes_env_var)
if env_value:
# Split comma-separated values and strip whitespace
allowed_redirect_schemes = [s.strip() for s in env_value.split(",") if s.strip()]

# Create a new config with resolved values for the enterprise package
resolved_config = OAuthProxyConfig(
authorization_endpoint=authorization_endpoint,
Expand All @@ -463,6 +478,13 @@ def _create_oauth_proxy_provider(config: OAuthProxyConfig) -> "AuthProvider":
redirect_path=config.redirect_path,
scopes_supported=config.scopes_supported,
token_verifier_config=config.token_verifier_config,
# Static redirect URI configuration (resolved from env vars)
allowed_redirect_patterns=allowed_redirect_patterns,
allowed_redirect_schemes=allowed_redirect_schemes,
# Dynamic redirect URI configuration (pass through callables)
allowed_redirect_patterns_func=config.allowed_redirect_patterns_func,
allowed_redirect_schemes_func=config.allowed_redirect_schemes_func,
redirect_uri_validator=config.redirect_uri_validator,
)

return create_oauth_proxy_provider(resolved_config)
Expand Down
33 changes: 33 additions & 0 deletions src/golf/auth/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@
"""

import os
from collections.abc import Callable
from typing import Any, Literal
from urllib.parse import urlparse

from pydantic import BaseModel, Field, field_validator, model_validator


# Type aliases for dynamic redirect URI configuration
# A callable that returns a list of redirect URI patterns
RedirectPatternsProvider = Callable[[], list[str]]
# A callable that returns a list of allowed URI schemes
RedirectSchemesProvider = Callable[[], list[str]]
# A callable that validates a redirect URI directly (returns True if allowed)
RedirectUriValidator = Callable[[str], bool]


class JWTAuthConfig(BaseModel):
"""Configuration for JWT token verification using FastMCP's JWTVerifier.

Expand Down Expand Up @@ -490,19 +500,42 @@ class OAuthProxyConfig(BaseModel):
base_url_env_var: str | None = Field(None, description="Environment variable name for base URL")

# Redirect URI validation configuration (extends defaults)
# Static patterns - evaluated once at startup
allowed_redirect_patterns: list[str] | None = Field(
None, description="Additional redirect URI patterns to allow (extends default localhost patterns)"
)
allowed_redirect_patterns_env_var: str | None = Field(
None, description="Environment variable name for comma-separated redirect patterns"
)
# Static schemes - evaluated once at startup
allowed_redirect_schemes: list[str] | None = Field(
None, description="Additional URI schemes to allow (extends http/https/cursor/warp)"
)
allowed_redirect_schemes_env_var: str | None = Field(
None, description="Environment variable name for comma-separated redirect schemes"
)

# Dynamic redirect URI validation (evaluated at runtime for each request)
# These allow integration with feature flags, databases, or other dynamic sources
allowed_redirect_patterns_func: RedirectPatternsProvider | None = Field(
None,
description="Callable that returns redirect URI patterns dynamically. "
"Called on each authorization request. Useful for feature flags or dynamic configuration.",
)
allowed_redirect_schemes_func: RedirectSchemesProvider | None = Field(
None,
description="Callable that returns allowed URI schemes dynamically. "
"Called on each authorization request. Useful for feature flags or dynamic configuration.",
)
redirect_uri_validator: RedirectUriValidator | None = Field(
None,
description="Custom validator function that receives the full redirect URI and returns True if allowed. "
"Takes precedence over pattern/scheme matching when provided. "
"Useful for complex validation logic or external lookups.",
)

model_config = {"arbitrary_types_allowed": True}

@field_validator("authorization_endpoint", "token_endpoint", "base_url")
@classmethod
def validate_required_urls(cls, v: str | None) -> str | None:
Expand Down
10 changes: 10 additions & 0 deletions src/golf/core/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,16 @@ def _generate_server(self) -> None:
transport=self.settings.transport,
)

# Copy auth.py to dist if it contains callable fields (dynamic config)
if auth_components.get("copy_auth_file"):
auth_src = self.project_path / "auth.py"
auth_dst = self.output_dir / "auth.py"
if auth_src.exists():
shutil.copy(auth_src, auth_dst)
console.print("[dim]Copied auth.py for runtime configuration[/dim]")
else:
console.print("[yellow]Warning: auth.py not found but copy_auth_file was requested[/yellow]")

# Create imports section
imports = [
"from fastmcp import FastMCP",
Expand Down
95 changes: 74 additions & 21 deletions src/golf/core/builder_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@
from golf.auth.providers import AuthConfig


def _config_has_callables(config: AuthConfig) -> bool:
"""Check if an auth config has any callable fields that can't be serialized.

Callable fields (like allowed_redirect_patterns_func) cannot be embedded
in generated code using repr(), so configs with callables need to use
runtime config loading instead.
"""
# Check for OAuthProxyConfig callable fields
callable_fields = [
"allowed_redirect_patterns_func",
"allowed_redirect_schemes_func",
"redirect_uri_validator",
]

for field_name in callable_fields:
if hasattr(config, field_name) and getattr(config, field_name) is not None:
return True

return False


def generate_auth_code(
server_name: str,
host: str = "localhost",
Expand Down Expand Up @@ -47,29 +68,60 @@ def generate_auth_code(
"Please update your auth.py file."
)

# Generate modern auth components with embedded configuration
auth_imports = [
"import os",
"import sys",
"from golf.auth.factory import create_auth_provider",
"from golf.auth.providers import RemoteAuthConfig, JWTAuthConfig, StaticTokenConfig, OAuthServerConfig, OAuthProxyConfig",
]
# Check if the auth config has callable fields (can't be embedded with repr)
has_callable_fields = _config_has_callables(auth_config)

# Embed the auth configuration directly in the generated code
# Convert the auth config to its string representation for embedding
auth_config_repr = repr(auth_config)
if has_callable_fields:
# For configs with callables, import and use auth module at runtime
# auth.py is copied to dist and imported to register the config
auth_imports = [
"import os",
"import sys",
"from golf.auth import get_auth_config",
"from golf.auth.factory import create_auth_provider",
"# Import auth module to execute configure_*() and register auth config",
"import auth # noqa: F401 - executes auth.py to register config",
]

setup_code_lines = [
"# Modern FastMCP 2.11+ authentication setup with embedded configuration",
f"auth_config = {auth_config_repr}",
"try:",
" auth_provider = create_auth_provider(auth_config)",
" # Authentication configured with {auth_config.provider_type} provider",
"except Exception as e:",
" print(f'Authentication setup failed: {e}', file=sys.stderr)",
" auth_provider = None",
"",
]
setup_code_lines = [
"# Modern FastMCP 2.11+ authentication setup (runtime config with callables)",
"# Auth config registered by auth.py import above",
"auth_config = get_auth_config()",
"try:",
" auth_provider = create_auth_provider(auth_config)",
f" # Authentication configured with {auth_config.provider_type} provider",
"except Exception as e:",
" print(f'Authentication setup failed: {{e}}', file=sys.stderr)",
" auth_provider = None",
"",
]
else:
# For configs without callables, embed the configuration directly
auth_imports = [
"import os",
"import sys",
"from golf.auth.factory import create_auth_provider",
"from golf.auth.providers import (",
" RemoteAuthConfig, JWTAuthConfig, StaticTokenConfig,",
" OAuthServerConfig, OAuthProxyConfig,",
")",
]

# Embed the auth configuration directly in the generated code
# Convert the auth config to its string representation for embedding
auth_config_repr = repr(auth_config)

setup_code_lines = [
"# Modern FastMCP 2.11+ authentication setup with embedded configuration",
f"auth_config = {auth_config_repr}",
"try:",
" auth_provider = create_auth_provider(auth_config)",
f" # Authentication configured with {auth_config.provider_type} provider",
"except Exception as e:",
" print(f'Authentication setup failed: {{e}}', file=sys.stderr)",
" auth_provider = None",
"",
]

# FastMCP constructor arguments - FastMCP 2.11+ uses auth parameter
fastmcp_args = {"auth": "auth_provider"}
Expand All @@ -79,6 +131,7 @@ def generate_auth_code(
"setup_code": setup_code_lines,
"fastmcp_args": fastmcp_args,
"has_auth": True,
"copy_auth_file": has_callable_fields, # Copy auth.py to dist for runtime loading
}


Expand Down
Loading