Skip to content

Commit 6b7a81d

Browse files
authored
Merge pull request #131 from golf-mcp/claude/dynamic-redirect-uris-nRi48
Add dynamic redirect URI validation for OAuth proxy
2 parents 5a51065 + b41fadc commit 6b7a81d

File tree

6 files changed

+487
-27
lines changed

6 files changed

+487
-27
lines changed

src/golf/auth/__init__.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
OAuthServerConfig,
1616
RemoteAuthConfig,
1717
OAuthProxyConfig,
18+
# Type aliases for dynamic redirect URI configuration
19+
RedirectPatternsProvider,
20+
RedirectSchemesProvider,
21+
RedirectUriValidator,
1822
)
1923
from .factory import (
2024
create_auth_provider,
@@ -53,6 +57,10 @@
5357
"OAuthServerConfig",
5458
"RemoteAuthConfig",
5559
"OAuthProxyConfig",
60+
# Type aliases for dynamic redirect URI configuration
61+
"RedirectPatternsProvider",
62+
"RedirectSchemesProvider",
63+
"RedirectUriValidator",
5664
# Factory functions
5765
"create_auth_provider",
5866
"create_simple_jwt_provider",
@@ -202,6 +210,13 @@ def configure_oauth_proxy(
202210
scopes_supported: list[str] | None = None,
203211
revocation_endpoint: str | None = None,
204212
redirect_path: str = "/oauth/callback",
213+
# Static redirect URI configuration
214+
allowed_redirect_patterns: list[str] | None = None,
215+
allowed_redirect_schemes: list[str] | None = None,
216+
# Dynamic redirect URI configuration (callables for runtime evaluation)
217+
allowed_redirect_patterns_func: RedirectPatternsProvider | None = None,
218+
allowed_redirect_schemes_func: RedirectSchemesProvider | None = None,
219+
redirect_uri_validator: RedirectUriValidator | None = None,
205220
**env_vars: str,
206221
) -> None:
207222
"""Configure OAuth proxy authentication for non-DCR providers.
@@ -210,6 +225,10 @@ def configure_oauth_proxy(
210225
For each parameter, you can provide the value directly or use the
211226
corresponding *_env_var parameter to specify an environment variable name.
212227
228+
Redirect URI validation supports both static and dynamic configuration:
229+
- Static: Use allowed_redirect_patterns and allowed_redirect_schemes lists
230+
- Dynamic: Use callable functions that are evaluated at runtime for each request
231+
213232
Examples:
214233
# Direct values (backward compatible)
215234
configure_oauth_proxy(
@@ -231,11 +250,32 @@ def configure_oauth_proxy(
231250
token_verifier_config=jwt_config,
232251
)
233252
234-
# Mixed (direct values with env var overrides)
253+
# Dynamic redirect URI validation with feature flags
254+
def get_allowed_patterns():
255+
# Could fetch from Amplitude, LaunchDarkly, database, etc.
256+
if amplitude.is_enabled("new-redirect-uris"):
257+
return ["https://new-app.example.com/*"]
258+
return ["https://legacy-app.example.com/*"]
259+
260+
configure_oauth_proxy(
261+
authorization_endpoint="https://auth.example.com/authorize",
262+
token_endpoint="https://auth.example.com/token",
263+
client_id="my-client",
264+
client_secret="my-secret",
265+
base_url="https://myserver.com",
266+
token_verifier_config=jwt_config,
267+
allowed_redirect_patterns_func=get_allowed_patterns,
268+
)
269+
270+
# Custom redirect URI validator for complex logic
271+
def validate_redirect_uri(uri: str) -> bool:
272+
# Custom validation logic - check database, feature flags, etc.
273+
allowed = fetch_allowed_uris_from_database()
274+
return uri in allowed
275+
235276
configure_oauth_proxy(
236-
authorization_endpoint="https://default.example.com/authorize",
237-
authorization_endpoint_env_var="OAUTH_AUTH_ENDPOINT", # Overrides at runtime
238-
# ...
277+
# ... other config ...
278+
redirect_uri_validator=validate_redirect_uri,
239279
)
240280
241281
Args:
@@ -248,13 +288,20 @@ def configure_oauth_proxy(
248288
scopes_supported: List of OAuth scopes this proxy supports
249289
revocation_endpoint: Optional token revocation endpoint
250290
redirect_path: OAuth callback path (default: "/oauth/callback")
291+
allowed_redirect_patterns: Static list of redirect URI patterns
292+
allowed_redirect_schemes: Static list of allowed URI schemes
293+
allowed_redirect_patterns_func: Callable returning patterns (evaluated per request)
294+
allowed_redirect_schemes_func: Callable returning schemes (evaluated per request)
295+
redirect_uri_validator: Custom validator function for redirect URIs
251296
**env_vars: Environment variable names for runtime configuration
252297
- authorization_endpoint_env_var: Env var for authorization endpoint
253298
- token_endpoint_env_var: Env var for token endpoint
254299
- client_id_env_var: Env var for client ID
255300
- client_secret_env_var: Env var for client secret
256301
- base_url_env_var: Env var for base URL
257302
- revocation_endpoint_env_var: Env var for revocation endpoint
303+
- allowed_redirect_patterns_env_var: Env var for redirect patterns
304+
- allowed_redirect_schemes_env_var: Env var for redirect schemes
258305
259306
Raises:
260307
ValueError: If token_verifier_config is not provided or invalid
@@ -281,6 +328,13 @@ def configure_oauth_proxy(
281328
redirect_path=redirect_path,
282329
scopes_supported=scopes_supported,
283330
token_verifier_config=token_verifier_config,
331+
# Static redirect URI configuration
332+
allowed_redirect_patterns=allowed_redirect_patterns,
333+
allowed_redirect_schemes=allowed_redirect_schemes,
334+
# Dynamic redirect URI configuration
335+
allowed_redirect_patterns_func=allowed_redirect_patterns_func,
336+
allowed_redirect_schemes_func=allowed_redirect_schemes_func,
337+
redirect_uri_validator=redirect_uri_validator,
284338
**env_vars,
285339
)
286340
configure_auth(config)

src/golf/auth/factory.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,21 @@ def _create_oauth_proxy_provider(config: OAuthProxyConfig) -> "AuthProvider":
452452
"Please install it with: pip install golf-mcp-enterprise"
453453
) from None
454454

455+
# Resolve static redirect patterns from environment variables
456+
allowed_redirect_patterns = config.allowed_redirect_patterns
457+
if config.allowed_redirect_patterns_env_var:
458+
env_value = os.environ.get(config.allowed_redirect_patterns_env_var)
459+
if env_value:
460+
# Split comma-separated values and strip whitespace
461+
allowed_redirect_patterns = [p.strip() for p in env_value.split(",") if p.strip()]
462+
463+
allowed_redirect_schemes = config.allowed_redirect_schemes
464+
if config.allowed_redirect_schemes_env_var:
465+
env_value = os.environ.get(config.allowed_redirect_schemes_env_var)
466+
if env_value:
467+
# Split comma-separated values and strip whitespace
468+
allowed_redirect_schemes = [s.strip() for s in env_value.split(",") if s.strip()]
469+
455470
# Create a new config with resolved values for the enterprise package
456471
resolved_config = OAuthProxyConfig(
457472
authorization_endpoint=authorization_endpoint,
@@ -463,6 +478,13 @@ def _create_oauth_proxy_provider(config: OAuthProxyConfig) -> "AuthProvider":
463478
redirect_path=config.redirect_path,
464479
scopes_supported=config.scopes_supported,
465480
token_verifier_config=config.token_verifier_config,
481+
# Static redirect URI configuration (resolved from env vars)
482+
allowed_redirect_patterns=allowed_redirect_patterns,
483+
allowed_redirect_schemes=allowed_redirect_schemes,
484+
# Dynamic redirect URI configuration (pass through callables)
485+
allowed_redirect_patterns_func=config.allowed_redirect_patterns_func,
486+
allowed_redirect_schemes_func=config.allowed_redirect_schemes_func,
487+
redirect_uri_validator=config.redirect_uri_validator,
466488
)
467489

468490
return create_oauth_proxy_provider(resolved_config)

src/golf/auth/providers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,22 @@
55
"""
66

77
import os
8+
from collections.abc import Callable
89
from typing import Any, Literal
910
from urllib.parse import urlparse
1011

1112
from pydantic import BaseModel, Field, field_validator, model_validator
1213

1314

15+
# Type aliases for dynamic redirect URI configuration
16+
# A callable that returns a list of redirect URI patterns
17+
RedirectPatternsProvider = Callable[[], list[str]]
18+
# A callable that returns a list of allowed URI schemes
19+
RedirectSchemesProvider = Callable[[], list[str]]
20+
# A callable that validates a redirect URI directly (returns True if allowed)
21+
RedirectUriValidator = Callable[[str], bool]
22+
23+
1424
class JWTAuthConfig(BaseModel):
1525
"""Configuration for JWT token verification using FastMCP's JWTVerifier.
1626
@@ -490,19 +500,42 @@ class OAuthProxyConfig(BaseModel):
490500
base_url_env_var: str | None = Field(None, description="Environment variable name for base URL")
491501

492502
# Redirect URI validation configuration (extends defaults)
503+
# Static patterns - evaluated once at startup
493504
allowed_redirect_patterns: list[str] | None = Field(
494505
None, description="Additional redirect URI patterns to allow (extends default localhost patterns)"
495506
)
496507
allowed_redirect_patterns_env_var: str | None = Field(
497508
None, description="Environment variable name for comma-separated redirect patterns"
498509
)
510+
# Static schemes - evaluated once at startup
499511
allowed_redirect_schemes: list[str] | None = Field(
500512
None, description="Additional URI schemes to allow (extends http/https/cursor/warp)"
501513
)
502514
allowed_redirect_schemes_env_var: str | None = Field(
503515
None, description="Environment variable name for comma-separated redirect schemes"
504516
)
505517

518+
# Dynamic redirect URI validation (evaluated at runtime for each request)
519+
# These allow integration with feature flags, databases, or other dynamic sources
520+
allowed_redirect_patterns_func: RedirectPatternsProvider | None = Field(
521+
None,
522+
description="Callable that returns redirect URI patterns dynamically. "
523+
"Called on each authorization request. Useful for feature flags or dynamic configuration.",
524+
)
525+
allowed_redirect_schemes_func: RedirectSchemesProvider | None = Field(
526+
None,
527+
description="Callable that returns allowed URI schemes dynamically. "
528+
"Called on each authorization request. Useful for feature flags or dynamic configuration.",
529+
)
530+
redirect_uri_validator: RedirectUriValidator | None = Field(
531+
None,
532+
description="Custom validator function that receives the full redirect URI and returns True if allowed. "
533+
"Takes precedence over pattern/scheme matching when provided. "
534+
"Useful for complex validation logic or external lookups.",
535+
)
536+
537+
model_config = {"arbitrary_types_allowed": True}
538+
506539
@field_validator("authorization_endpoint", "token_endpoint", "base_url")
507540
@classmethod
508541
def validate_required_urls(cls, v: str | None) -> str | None:

src/golf/core/builder.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,16 @@ def _generate_server(self) -> None:
861861
transport=self.settings.transport,
862862
)
863863

864+
# Copy auth.py to dist if it contains callable fields (dynamic config)
865+
if auth_components.get("copy_auth_file"):
866+
auth_src = self.project_path / "auth.py"
867+
auth_dst = self.output_dir / "auth.py"
868+
if auth_src.exists():
869+
shutil.copy(auth_src, auth_dst)
870+
console.print("[dim]Copied auth.py for runtime configuration[/dim]")
871+
else:
872+
console.print("[yellow]Warning: auth.py not found but copy_auth_file was requested[/yellow]")
873+
864874
# Create imports section
865875
imports = [
866876
"from fastmcp import FastMCP",

src/golf/core/builder_auth.py

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@
1010
from golf.auth.providers import AuthConfig
1111

1212

13+
def _config_has_callables(config: AuthConfig) -> bool:
14+
"""Check if an auth config has any callable fields that can't be serialized.
15+
16+
Callable fields (like allowed_redirect_patterns_func) cannot be embedded
17+
in generated code using repr(), so configs with callables need to use
18+
runtime config loading instead.
19+
"""
20+
# Check for OAuthProxyConfig callable fields
21+
callable_fields = [
22+
"allowed_redirect_patterns_func",
23+
"allowed_redirect_schemes_func",
24+
"redirect_uri_validator",
25+
]
26+
27+
for field_name in callable_fields:
28+
if hasattr(config, field_name) and getattr(config, field_name) is not None:
29+
return True
30+
31+
return False
32+
33+
1334
def generate_auth_code(
1435
server_name: str,
1536
host: str = "localhost",
@@ -47,29 +68,60 @@ def generate_auth_code(
4768
"Please update your auth.py file."
4869
)
4970

50-
# Generate modern auth components with embedded configuration
51-
auth_imports = [
52-
"import os",
53-
"import sys",
54-
"from golf.auth.factory import create_auth_provider",
55-
"from golf.auth.providers import RemoteAuthConfig, JWTAuthConfig, StaticTokenConfig, OAuthServerConfig, OAuthProxyConfig",
56-
]
71+
# Check if the auth config has callable fields (can't be embedded with repr)
72+
has_callable_fields = _config_has_callables(auth_config)
5773

58-
# Embed the auth configuration directly in the generated code
59-
# Convert the auth config to its string representation for embedding
60-
auth_config_repr = repr(auth_config)
74+
if has_callable_fields:
75+
# For configs with callables, import and use auth module at runtime
76+
# auth.py is copied to dist and imported to register the config
77+
auth_imports = [
78+
"import os",
79+
"import sys",
80+
"from golf.auth import get_auth_config",
81+
"from golf.auth.factory import create_auth_provider",
82+
"# Import auth module to execute configure_*() and register auth config",
83+
"import auth # noqa: F401 - executes auth.py to register config",
84+
]
6185

62-
setup_code_lines = [
63-
"# Modern FastMCP 2.11+ authentication setup with embedded configuration",
64-
f"auth_config = {auth_config_repr}",
65-
"try:",
66-
" auth_provider = create_auth_provider(auth_config)",
67-
" # Authentication configured with {auth_config.provider_type} provider",
68-
"except Exception as e:",
69-
" print(f'Authentication setup failed: {e}', file=sys.stderr)",
70-
" auth_provider = None",
71-
"",
72-
]
86+
setup_code_lines = [
87+
"# Modern FastMCP 2.11+ authentication setup (runtime config with callables)",
88+
"# Auth config registered by auth.py import above",
89+
"auth_config = get_auth_config()",
90+
"try:",
91+
" auth_provider = create_auth_provider(auth_config)",
92+
f" # Authentication configured with {auth_config.provider_type} provider",
93+
"except Exception as e:",
94+
" print(f'Authentication setup failed: {{e}}', file=sys.stderr)",
95+
" auth_provider = None",
96+
"",
97+
]
98+
else:
99+
# For configs without callables, embed the configuration directly
100+
auth_imports = [
101+
"import os",
102+
"import sys",
103+
"from golf.auth.factory import create_auth_provider",
104+
"from golf.auth.providers import (",
105+
" RemoteAuthConfig, JWTAuthConfig, StaticTokenConfig,",
106+
" OAuthServerConfig, OAuthProxyConfig,",
107+
")",
108+
]
109+
110+
# Embed the auth configuration directly in the generated code
111+
# Convert the auth config to its string representation for embedding
112+
auth_config_repr = repr(auth_config)
113+
114+
setup_code_lines = [
115+
"# Modern FastMCP 2.11+ authentication setup with embedded configuration",
116+
f"auth_config = {auth_config_repr}",
117+
"try:",
118+
" auth_provider = create_auth_provider(auth_config)",
119+
f" # Authentication configured with {auth_config.provider_type} provider",
120+
"except Exception as e:",
121+
" print(f'Authentication setup failed: {{e}}', file=sys.stderr)",
122+
" auth_provider = None",
123+
"",
124+
]
73125

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

84137

0 commit comments

Comments
 (0)