Skip to content

Commit b41fadc

Browse files
committed
feat(auth): implement runtime configuration handling for callable fields
1 parent f7947c8 commit b41fadc

File tree

3 files changed

+146
-21
lines changed

3 files changed

+146
-21
lines changed

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

tests/auth/test_providers.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
_create_remote_provider,
1313
_create_oauth_proxy_provider,
1414
)
15+
from golf.core.builder_auth import _config_has_callables
1516

1617

1718
class TestJWTProviderCreation:
@@ -615,3 +616,64 @@ def sync_db_lookup_wrapper(uri: str) -> bool:
615616

616617
# Validator should now accept the new URI
617618
assert config.redirect_uri_validator("https://new-uri.example.com/callback") is True
619+
620+
621+
class TestConfigHasCallables:
622+
"""Test the _config_has_callables helper function for builder_auth."""
623+
624+
def _create_base_config(self, **kwargs) -> OAuthProxyConfig:
625+
"""Create a base OAuth proxy config with required fields for testing."""
626+
token_verifier = StaticTokenConfig(tokens={"test-token": {"client_id": "test", "scopes": ["read"]}})
627+
defaults = {
628+
"authorization_endpoint": "https://auth.example.com/authorize",
629+
"token_endpoint": "https://auth.example.com/token",
630+
"client_id": "test-client",
631+
"client_secret": "test-secret",
632+
"base_url": "https://proxy.example.com",
633+
"token_verifier_config": token_verifier,
634+
}
635+
defaults.update(kwargs)
636+
return OAuthProxyConfig(**defaults)
637+
638+
def test_config_without_callables_returns_false(self) -> None:
639+
"""Test that config without callable fields returns False."""
640+
config = self._create_base_config(
641+
allowed_redirect_patterns=["https://example.com/*"],
642+
allowed_redirect_schemes=["https"],
643+
)
644+
assert _config_has_callables(config) is False
645+
646+
def test_config_with_patterns_func_returns_true(self) -> None:
647+
"""Test that config with allowed_redirect_patterns_func returns True."""
648+
config = self._create_base_config(
649+
allowed_redirect_patterns_func=lambda: ["https://example.com/*"],
650+
)
651+
assert _config_has_callables(config) is True
652+
653+
def test_config_with_schemes_func_returns_true(self) -> None:
654+
"""Test that config with allowed_redirect_schemes_func returns True."""
655+
config = self._create_base_config(
656+
allowed_redirect_schemes_func=lambda: ["myapp"],
657+
)
658+
assert _config_has_callables(config) is True
659+
660+
def test_config_with_validator_returns_true(self) -> None:
661+
"""Test that config with redirect_uri_validator returns True."""
662+
config = self._create_base_config(
663+
redirect_uri_validator=lambda uri: True,
664+
)
665+
assert _config_has_callables(config) is True
666+
667+
def test_jwt_config_returns_false(self) -> None:
668+
"""Test that JWT config (no callable fields) returns False."""
669+
config = JWTAuthConfig(
670+
jwks_uri="https://auth.example.com/.well-known/jwks.json",
671+
issuer="https://auth.example.com",
672+
audience="my-api",
673+
)
674+
assert _config_has_callables(config) is False
675+
676+
def test_static_token_config_returns_false(self) -> None:
677+
"""Test that StaticTokenConfig (no callable fields) returns False."""
678+
config = StaticTokenConfig(tokens={"test-token": {"client_id": "test", "scopes": ["read"]}})
679+
assert _config_has_callables(config) is False

0 commit comments

Comments
 (0)