golf-mcp 0.1.20__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of golf-mcp might be problematic. Click here for more details.
- golf/__init__.py +9 -1
- golf/_endpoints.py +6 -0
- golf/_endpoints_fallback.py +10 -0
- golf/auth/__init__.py +188 -84
- golf/auth/api_key.py +6 -14
- golf/auth/factory.py +333 -0
- golf/auth/helpers.py +12 -42
- golf/auth/providers.py +396 -0
- golf/auth/registry.py +256 -0
- golf/cli/branding.py +192 -0
- golf/cli/main.py +28 -69
- golf/commands/__init__.py +2 -0
- golf/commands/build.py +4 -7
- golf/commands/init.py +30 -53
- golf/commands/run.py +50 -20
- golf/core/builder.py +355 -414
- golf/core/builder_auth.py +63 -144
- golf/core/builder_telemetry.py +26 -3
- golf/core/config.py +38 -59
- golf/core/parser.py +132 -139
- golf/core/platform.py +12 -10
- golf/core/telemetry.py +11 -19
- golf/core/transformer.py +38 -15
- golf/examples/__pycache__/__init__.cpython-311.pyc +0 -0
- golf/examples/basic/.coverage +0 -0
- golf/examples/basic/.env.example +8 -4
- golf/examples/basic/README.md +117 -45
- golf/examples/basic/__pycache__/auth.cpython-311.pyc +0 -0
- golf/examples/basic/auth.py +76 -0
- golf/examples/basic/golf.json +2 -5
- golf/examples/basic/htmlcov/.gitignore +2 -0
- golf/examples/basic/htmlcov/class_index.html +547 -0
- golf/examples/basic/htmlcov/coverage_html_cb_6fb7b396.js +733 -0
- golf/examples/basic/htmlcov/favicon_32_cb_58284776.png +0 -0
- golf/examples/basic/htmlcov/function_index.html +2091 -0
- golf/examples/basic/htmlcov/index.html +349 -0
- golf/examples/basic/htmlcov/keybd_closed_cb_ce680311.png +0 -0
- golf/examples/basic/htmlcov/status.json +1 -0
- golf/examples/basic/htmlcov/style_cb_8e611ae1.css +337 -0
- golf/examples/basic/htmlcov/z_1c9a91c0e91c8496___init___py.html +323 -0
- golf/examples/basic/htmlcov/z_1c9a91c0e91c8496_api_key_py.html +170 -0
- golf/examples/basic/htmlcov/z_1c9a91c0e91c8496_factory_py.html +430 -0
- golf/examples/basic/htmlcov/z_1c9a91c0e91c8496_helpers_py.html +288 -0
- golf/examples/basic/htmlcov/z_1c9a91c0e91c8496_providers_py.html +493 -0
- golf/examples/basic/htmlcov/z_1c9a91c0e91c8496_registry_py.html +353 -0
- golf/examples/basic/htmlcov/z_3ec3b3f490dc0950___init___py.html +120 -0
- golf/examples/basic/htmlcov/z_3ec3b3f490dc0950_instrumentation_py.html +1535 -0
- golf/examples/basic/htmlcov/z_4b8b9dd4ccccc5db___init___py.html +98 -0
- golf/examples/basic/htmlcov/z_4b8b9dd4ccccc5db_branding_py.html +289 -0
- golf/examples/basic/htmlcov/z_4b8b9dd4ccccc5db_main_py.html +476 -0
- golf/examples/basic/htmlcov/z_5a6c4e6bcc86fb2f___init___py.html +97 -0
- golf/examples/basic/htmlcov/z_6cadab9ec0df475d___init___py.html +102 -0
- golf/examples/basic/htmlcov/z_6cadab9ec0df475d_build_py.html +178 -0
- golf/examples/basic/htmlcov/z_6cadab9ec0df475d_init_py.html +387 -0
- golf/examples/basic/htmlcov/z_6cadab9ec0df475d_run_py.html +222 -0
- golf/examples/basic/htmlcov/z_6fcdee0582ba84e4___init___py.html +106 -0
- golf/examples/basic/htmlcov/z_6fcdee0582ba84e4__endpoints_fallback_py.html +107 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217___init___py.html +98 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217_builder_auth_py.html +306 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217_builder_metrics_py.html +329 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217_builder_py.html +1471 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217_builder_telemetry_py.html +186 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217_config_py.html +315 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217_parser_py.html +1149 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217_platform_py.html +279 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217_telemetry_py.html +589 -0
- golf/examples/basic/htmlcov/z_7ba499ed22986217_transformer_py.html +286 -0
- golf/examples/basic/htmlcov/z_7d7da37693a43688___init___py.html +107 -0
- golf/examples/basic/htmlcov/z_7d7da37693a43688_collector_py.html +417 -0
- golf/examples/basic/htmlcov/z_7d7da37693a43688_registry_py.html +109 -0
- golf/examples/basic/htmlcov/z_abe733142b40ad4e___init___py.html +109 -0
- golf/examples/basic/htmlcov/z_abe733142b40ad4e_context_py.html +150 -0
- golf/examples/basic/htmlcov/z_abe733142b40ad4e_elicitation_py.html +267 -0
- golf/examples/basic/htmlcov/z_abe733142b40ad4e_sampling_py.html +318 -0
- golf/examples/basic/prompts/__pycache__/welcome.cpython-311.pyc +0 -0
- golf/examples/basic/prompts/welcome.py +3 -5
- golf/examples/basic/resources/__pycache__/current_time.cpython-311.pyc +0 -0
- golf/examples/basic/resources/__pycache__/info.cpython-311.pyc +0 -0
- golf/examples/basic/resources/current_time.py +5 -13
- golf/examples/basic/resources/weather/__pycache__/common.cpython-311.pyc +0 -0
- golf/examples/basic/resources/weather/__pycache__/current.cpython-311.pyc +0 -0
- golf/examples/basic/resources/weather/__pycache__/forecast.cpython-311.pyc +0 -0
- golf/examples/basic/resources/weather/city.py +46 -0
- golf/examples/basic/resources/weather/common.py +4 -11
- golf/examples/basic/resources/weather/current.py +5 -5
- golf/examples/basic/resources/weather/forecast.py +5 -5
- golf/examples/basic/tools/__pycache__/calculator.cpython-311.pyc +0 -0
- golf/examples/basic/tools/calculator.py +94 -0
- golf/examples/basic/tools/say/__pycache__/hello.cpython-311.pyc +0 -0
- golf/examples/basic/tools/say/hello.py +65 -0
- golf/metrics/collector.py +100 -19
- golf/telemetry/__init__.py +4 -0
- golf/telemetry/instrumentation.py +484 -178
- golf/utilities/__init__.py +12 -0
- golf/utilities/context.py +53 -0
- golf/utilities/elicitation.py +170 -0
- golf/utilities/sampling.py +221 -0
- {golf_mcp-0.1.20.dist-info → golf_mcp-0.2.0.dist-info}/METADATA +51 -104
- golf_mcp-0.2.0.dist-info/RECORD +110 -0
- golf/auth/oauth.py +0 -861
- golf/auth/provider.py +0 -115
- golf/examples/api_key/.env +0 -2
- golf/examples/api_key/.env.example +0 -1
- golf/examples/api_key/README.md +0 -84
- golf/examples/api_key/golf.json +0 -8
- golf/examples/api_key/pre_build.py +0 -11
- golf/examples/api_key/tools/issues/create.py +0 -93
- golf/examples/api_key/tools/issues/list.py +0 -92
- golf/examples/api_key/tools/repos/list.py +0 -111
- golf/examples/api_key/tools/search/code.py +0 -106
- golf/examples/api_key/tools/users/get.py +0 -82
- golf/examples/basic/.env +0 -5
- golf/examples/basic/pre_build.py +0 -28
- golf/examples/basic/tools/github_user.py +0 -65
- golf/examples/basic/tools/hello.py +0 -34
- golf/examples/basic/tools/payments/charge.py +0 -70
- golf/examples/basic/tools/payments/common.py +0 -36
- golf/examples/basic/tools/payments/refund.py +0 -61
- golf_mcp-0.1.20.dist-info/RECORD +0 -60
- {golf_mcp-0.1.20.dist-info → golf_mcp-0.2.0.dist-info}/WHEEL +0 -0
- {golf_mcp-0.1.20.dist-info → golf_mcp-0.2.0.dist-info}/entry_points.txt +0 -0
- {golf_mcp-0.1.20.dist-info → golf_mcp-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {golf_mcp-0.1.20.dist-info → golf_mcp-0.2.0.dist-info}/top_level.txt +0 -0
golf/auth/providers.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Modern authentication provider configurations for Golf MCP servers.
|
|
2
|
+
|
|
3
|
+
This module provides configuration classes for FastMCP 2.11+ authentication providers,
|
|
4
|
+
replacing the legacy custom OAuth implementation with the new built-in auth system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JWTAuthConfig(BaseModel):
|
|
15
|
+
"""Configuration for JWT token verification using FastMCP's JWTVerifier.
|
|
16
|
+
|
|
17
|
+
Use this when you have JWT tokens issued by an external OAuth server
|
|
18
|
+
(like Auth0, Okta, etc.) and want to verify them in your Golf server.
|
|
19
|
+
|
|
20
|
+
Security Note:
|
|
21
|
+
For production use, it's strongly recommended to specify both `issuer` and `audience`
|
|
22
|
+
to ensure tokens are validated against the expected issuer and intended audience.
|
|
23
|
+
This prevents token misuse across different services or environments.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
provider_type: Literal["jwt"] = "jwt"
|
|
27
|
+
|
|
28
|
+
# JWT verification settings
|
|
29
|
+
public_key: str | None = Field(None, description="PEM-encoded public key for JWT verification")
|
|
30
|
+
jwks_uri: str | None = Field(None, description="URI to fetch JSON Web Key Set for verification")
|
|
31
|
+
issuer: str | None = Field(None, description="Expected JWT issuer claim (strongly recommended for production)")
|
|
32
|
+
audience: str | list[str] | None = Field(
|
|
33
|
+
None, description="Expected JWT audience claim(s) (strongly recommended for production)"
|
|
34
|
+
)
|
|
35
|
+
algorithm: str = Field("RS256", description="JWT signing algorithm")
|
|
36
|
+
|
|
37
|
+
# Scope and access control
|
|
38
|
+
required_scopes: list[str] = Field(default_factory=list, description="Scopes required for all requests")
|
|
39
|
+
|
|
40
|
+
# Environment variable names for runtime configuration
|
|
41
|
+
public_key_env_var: str | None = Field(None, description="Environment variable name for public key")
|
|
42
|
+
jwks_uri_env_var: str | None = Field(None, description="Environment variable name for JWKS URI")
|
|
43
|
+
issuer_env_var: str | None = Field(None, description="Environment variable name for issuer")
|
|
44
|
+
audience_env_var: str | None = Field(None, description="Environment variable name for audience")
|
|
45
|
+
|
|
46
|
+
@model_validator(mode="after")
|
|
47
|
+
def validate_jwt_config(self) -> "JWTAuthConfig":
|
|
48
|
+
"""Validate JWT configuration requirements."""
|
|
49
|
+
# Ensure exactly one of public_key or jwks_uri is provided
|
|
50
|
+
if not self.public_key and not self.jwks_uri and not self.public_key_env_var and not self.jwks_uri_env_var:
|
|
51
|
+
raise ValueError("Either public_key, jwks_uri, or their environment variable equivalents must be provided")
|
|
52
|
+
|
|
53
|
+
if (self.public_key or self.public_key_env_var) and (self.jwks_uri or self.jwks_uri_env_var):
|
|
54
|
+
raise ValueError("Provide either public_key or jwks_uri (or their env vars), not both")
|
|
55
|
+
|
|
56
|
+
# Warn about missing issuer/audience in production-like environments
|
|
57
|
+
is_production = (
|
|
58
|
+
os.environ.get("GOLF_ENV", "").lower() in ("prod", "production")
|
|
59
|
+
or os.environ.get("NODE_ENV", "").lower() == "production"
|
|
60
|
+
or os.environ.get("ENVIRONMENT", "").lower() in ("prod", "production")
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if is_production:
|
|
64
|
+
missing_fields = []
|
|
65
|
+
if not self.issuer and not self.issuer_env_var:
|
|
66
|
+
missing_fields.append("issuer")
|
|
67
|
+
if not self.audience and not self.audience_env_var:
|
|
68
|
+
missing_fields.append("audience")
|
|
69
|
+
|
|
70
|
+
if missing_fields:
|
|
71
|
+
import warnings
|
|
72
|
+
|
|
73
|
+
warnings.warn(
|
|
74
|
+
f"JWT configuration is missing recommended fields for production: {', '.join(missing_fields)}. "
|
|
75
|
+
"This may allow tokens from unintended issuers or audiences to be accepted.",
|
|
76
|
+
UserWarning,
|
|
77
|
+
stacklevel=2,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StaticTokenConfig(BaseModel):
|
|
84
|
+
"""Configuration for static token verification for development/testing.
|
|
85
|
+
|
|
86
|
+
Use this for local development and testing when you need predictable
|
|
87
|
+
API keys without setting up a full OAuth server.
|
|
88
|
+
|
|
89
|
+
WARNING: Never use in production!
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
provider_type: Literal["static"] = "static"
|
|
93
|
+
|
|
94
|
+
# Static tokens mapping: token_string -> metadata
|
|
95
|
+
tokens: dict[str, dict[str, Any]] = Field(
|
|
96
|
+
default_factory=dict,
|
|
97
|
+
description="Static tokens with their metadata (client_id, scopes, expires_at)",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Scope and access control
|
|
101
|
+
required_scopes: list[str] = Field(default_factory=list, description="Scopes required for all requests")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OAuthServerConfig(BaseModel):
|
|
105
|
+
"""Configuration for full OAuth authorization server using FastMCP's OAuthProvider.
|
|
106
|
+
|
|
107
|
+
Use this when you want your Golf server to act as a complete OAuth server,
|
|
108
|
+
handling authorization flows and token issuance.
|
|
109
|
+
|
|
110
|
+
Security Considerations:
|
|
111
|
+
- URLs are validated to prevent SSRF attacks
|
|
112
|
+
- Scopes are validated against OAuth 2.0 standards
|
|
113
|
+
- Base URL must use HTTPS in production environments
|
|
114
|
+
- Client registration is disabled for security
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
provider_type: Literal["oauth_server"] = "oauth_server"
|
|
118
|
+
|
|
119
|
+
# OAuth server URLs
|
|
120
|
+
base_url: str = Field(..., description="Public URL of this Golf server (must use HTTPS in production)")
|
|
121
|
+
issuer_url: str | None = Field(None, description="OAuth issuer URL (defaults to base_url, must be HTTPS)")
|
|
122
|
+
service_documentation_url: str | None = Field(None, description="URL of service documentation")
|
|
123
|
+
|
|
124
|
+
# Client registration settings
|
|
125
|
+
valid_scopes: list[str] = Field(
|
|
126
|
+
default_factory=list, description="Valid scopes for client registration (OAuth 2.0 format)"
|
|
127
|
+
)
|
|
128
|
+
default_scopes: list[str] = Field(default_factory=list, description="Default scopes for new clients")
|
|
129
|
+
|
|
130
|
+
# Token revocation settings
|
|
131
|
+
allow_token_revocation: bool = Field(True, description="Allow token revocation")
|
|
132
|
+
|
|
133
|
+
# Access control
|
|
134
|
+
required_scopes: list[str] = Field(default_factory=list, description="Scopes required for all requests")
|
|
135
|
+
|
|
136
|
+
# Environment variable names for runtime configuration
|
|
137
|
+
base_url_env_var: str | None = Field(None, description="Environment variable name for base URL")
|
|
138
|
+
|
|
139
|
+
@field_validator("base_url")
|
|
140
|
+
@classmethod
|
|
141
|
+
def validate_base_url(cls, v: str) -> str:
|
|
142
|
+
"""Validate base URL for security and format compliance."""
|
|
143
|
+
if not v or not v.strip():
|
|
144
|
+
raise ValueError("base_url cannot be empty")
|
|
145
|
+
|
|
146
|
+
url = v.strip()
|
|
147
|
+
try:
|
|
148
|
+
parsed = urlparse(url)
|
|
149
|
+
if not parsed.scheme or not parsed.netloc:
|
|
150
|
+
raise ValueError(f"Invalid base URL format: '{url}' - must include scheme and netloc")
|
|
151
|
+
|
|
152
|
+
if parsed.scheme not in ("http", "https"):
|
|
153
|
+
raise ValueError(f"Base URL must use http or https scheme: '{url}'")
|
|
154
|
+
|
|
155
|
+
# Warn about HTTP in production-like environments
|
|
156
|
+
is_production = (
|
|
157
|
+
os.environ.get("GOLF_ENV", "").lower() in ("prod", "production")
|
|
158
|
+
or os.environ.get("NODE_ENV", "").lower() == "production"
|
|
159
|
+
or os.environ.get("ENVIRONMENT", "").lower() in ("prod", "production")
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if is_production and parsed.scheme == "http":
|
|
163
|
+
import warnings
|
|
164
|
+
|
|
165
|
+
warnings.warn(
|
|
166
|
+
f"Base URL '{url}' uses HTTP in production environment. "
|
|
167
|
+
"HTTPS is strongly recommended for OAuth servers to prevent token interception.",
|
|
168
|
+
UserWarning,
|
|
169
|
+
stacklevel=2,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Prevent common SSRF targets
|
|
173
|
+
if parsed.hostname in ("localhost", "127.0.0.1", "0.0.0.0"):
|
|
174
|
+
if is_production:
|
|
175
|
+
raise ValueError(f"Base URL cannot use localhost/loopback addresses in production: '{url}'")
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
if isinstance(e, ValueError):
|
|
179
|
+
raise
|
|
180
|
+
raise ValueError(f"Invalid base URL '{url}': {e}") from e
|
|
181
|
+
|
|
182
|
+
return url
|
|
183
|
+
|
|
184
|
+
@field_validator("issuer_url", "service_documentation_url")
|
|
185
|
+
@classmethod
|
|
186
|
+
def validate_optional_urls(cls, v: str | None) -> str | None:
|
|
187
|
+
"""Validate optional URLs for security and format compliance."""
|
|
188
|
+
if not v:
|
|
189
|
+
return v
|
|
190
|
+
|
|
191
|
+
url = v.strip()
|
|
192
|
+
if not url:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
parsed = urlparse(url)
|
|
197
|
+
if not parsed.scheme or not parsed.netloc:
|
|
198
|
+
raise ValueError(f"Invalid URL format: '{url}' - must include scheme and netloc")
|
|
199
|
+
|
|
200
|
+
if parsed.scheme not in ("http", "https"):
|
|
201
|
+
raise ValueError(f"URL must use http or https scheme: '{url}'")
|
|
202
|
+
|
|
203
|
+
# Check for HTTPS requirement in production for issuer URL
|
|
204
|
+
if v == cls.__dict__.get("issuer_url"): # This is the issuer_url field
|
|
205
|
+
is_production = (
|
|
206
|
+
os.environ.get("GOLF_ENV", "").lower() in ("prod", "production")
|
|
207
|
+
or os.environ.get("NODE_ENV", "").lower() == "production"
|
|
208
|
+
or os.environ.get("ENVIRONMENT", "").lower() in ("prod", "production")
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if is_production and parsed.scheme == "http":
|
|
212
|
+
import warnings
|
|
213
|
+
|
|
214
|
+
warnings.warn(
|
|
215
|
+
f"Issuer URL '{url}' uses HTTP in production. HTTPS is required for OAuth issuer URLs.",
|
|
216
|
+
UserWarning,
|
|
217
|
+
stacklevel=2,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
if isinstance(e, ValueError):
|
|
222
|
+
raise
|
|
223
|
+
raise ValueError(f"Invalid URL '{url}': {e}") from e
|
|
224
|
+
|
|
225
|
+
return url
|
|
226
|
+
|
|
227
|
+
@field_validator("valid_scopes", "default_scopes", "required_scopes")
|
|
228
|
+
@classmethod
|
|
229
|
+
def validate_scopes(cls, v: list[str]) -> list[str]:
|
|
230
|
+
"""Validate OAuth 2.0 scopes format and security."""
|
|
231
|
+
if not v:
|
|
232
|
+
return v
|
|
233
|
+
|
|
234
|
+
valid_scopes = []
|
|
235
|
+
for scope in v:
|
|
236
|
+
scope = scope.strip()
|
|
237
|
+
if not scope:
|
|
238
|
+
raise ValueError("Scopes cannot be empty or whitespace-only")
|
|
239
|
+
|
|
240
|
+
# OAuth 2.0 scope format validation (RFC 6749)
|
|
241
|
+
# Scopes should be ASCII printable characters except space, and no control characters
|
|
242
|
+
if not all(32 < ord(c) < 127 and c not in ' "\\' for c in scope):
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"Invalid scope format: '{scope}' - must be ASCII printable without spaces, quotes, or backslashes"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Reasonable length limit to prevent abuse
|
|
248
|
+
if len(scope) > 128:
|
|
249
|
+
raise ValueError(f"Scope too long: '{scope}' - maximum 128 characters")
|
|
250
|
+
|
|
251
|
+
# Prevent potentially dangerous scope names
|
|
252
|
+
dangerous_scopes = {"admin", "root", "superuser", "system", "*", "all"}
|
|
253
|
+
if scope.lower() in dangerous_scopes:
|
|
254
|
+
import warnings
|
|
255
|
+
|
|
256
|
+
warnings.warn(
|
|
257
|
+
f"Potentially dangerous scope detected: '{scope}'. "
|
|
258
|
+
"Consider using more specific, principle-of-least-privilege scopes.",
|
|
259
|
+
UserWarning,
|
|
260
|
+
stacklevel=2,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
valid_scopes.append(scope)
|
|
264
|
+
|
|
265
|
+
return valid_scopes
|
|
266
|
+
|
|
267
|
+
@model_validator(mode="after")
|
|
268
|
+
def validate_oauth_server_config(self) -> "OAuthServerConfig":
|
|
269
|
+
"""Validate OAuth server configuration for security and consistency."""
|
|
270
|
+
# Validate default_scopes are subset of valid_scopes
|
|
271
|
+
if self.default_scopes and self.valid_scopes:
|
|
272
|
+
invalid_defaults = set(self.default_scopes) - set(self.valid_scopes)
|
|
273
|
+
if invalid_defaults:
|
|
274
|
+
raise ValueError(f"default_scopes contains invalid scopes not in valid_scopes: {invalid_defaults}")
|
|
275
|
+
|
|
276
|
+
# Validate required_scopes are subset of valid_scopes
|
|
277
|
+
if self.required_scopes and self.valid_scopes:
|
|
278
|
+
invalid_required = set(self.required_scopes) - set(self.valid_scopes)
|
|
279
|
+
if invalid_required:
|
|
280
|
+
raise ValueError(f"required_scopes contains invalid scopes not in valid_scopes: {invalid_required}")
|
|
281
|
+
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class RemoteAuthConfig(BaseModel):
|
|
286
|
+
"""Configuration for remote authorization server integration.
|
|
287
|
+
|
|
288
|
+
Use this when you have token verification logic and want to advertise
|
|
289
|
+
the authorization servers that issue valid tokens (RFC 9728 compliance).
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
provider_type: Literal["remote"] = "remote"
|
|
293
|
+
|
|
294
|
+
# Authorization servers that issue tokens
|
|
295
|
+
authorization_servers: list[str] = Field(
|
|
296
|
+
..., description="List of authorization server URLs that issue valid tokens"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# This server's URL
|
|
300
|
+
resource_server_url: str = Field(..., description="URL of this resource server")
|
|
301
|
+
|
|
302
|
+
# Token verification (delegate to another config)
|
|
303
|
+
token_verifier_config: JWTAuthConfig | StaticTokenConfig = Field(
|
|
304
|
+
..., description="Configuration for the underlying token verifier"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Environment variable names for runtime configuration
|
|
308
|
+
authorization_servers_env_var: str | None = Field(
|
|
309
|
+
None, description="Environment variable name for comma-separated authorization server URLs"
|
|
310
|
+
)
|
|
311
|
+
resource_server_url_env_var: str | None = Field(
|
|
312
|
+
None, description="Environment variable name for resource server URL"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
@field_validator("authorization_servers")
|
|
316
|
+
@classmethod
|
|
317
|
+
def validate_authorization_servers(cls, v: list[str]) -> list[str]:
|
|
318
|
+
"""Validate authorization servers are non-empty and valid URLs."""
|
|
319
|
+
if not v:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
"authorization_servers cannot be empty - at least one authorization server URL is required"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
valid_urls = []
|
|
325
|
+
for url in v:
|
|
326
|
+
url = url.strip()
|
|
327
|
+
if not url:
|
|
328
|
+
raise ValueError("authorization_servers cannot contain empty URLs")
|
|
329
|
+
|
|
330
|
+
# Validate URL format
|
|
331
|
+
try:
|
|
332
|
+
parsed = urlparse(url)
|
|
333
|
+
if not parsed.scheme or not parsed.netloc:
|
|
334
|
+
raise ValueError(
|
|
335
|
+
f"Invalid URL format for authorization server: '{url}' - must include scheme and netloc"
|
|
336
|
+
)
|
|
337
|
+
if parsed.scheme not in ("http", "https"):
|
|
338
|
+
raise ValueError(f"Authorization server URL must use http or https scheme: '{url}'")
|
|
339
|
+
except Exception as e:
|
|
340
|
+
raise ValueError(f"Invalid authorization server URL '{url}': {e}") from e
|
|
341
|
+
|
|
342
|
+
valid_urls.append(url)
|
|
343
|
+
|
|
344
|
+
return valid_urls
|
|
345
|
+
|
|
346
|
+
@field_validator("resource_server_url")
|
|
347
|
+
@classmethod
|
|
348
|
+
def validate_resource_server_url(cls, v: str) -> str:
|
|
349
|
+
"""Validate resource server URL is a valid URL."""
|
|
350
|
+
if not v or not v.strip():
|
|
351
|
+
raise ValueError("resource_server_url cannot be empty")
|
|
352
|
+
|
|
353
|
+
url = v.strip()
|
|
354
|
+
try:
|
|
355
|
+
parsed = urlparse(url)
|
|
356
|
+
if not parsed.scheme or not parsed.netloc:
|
|
357
|
+
raise ValueError(f"Invalid URL format for resource server: '{url}' - must include scheme and netloc")
|
|
358
|
+
if parsed.scheme not in ("http", "https"):
|
|
359
|
+
raise ValueError(f"Resource server URL must use http or https scheme: '{url}'")
|
|
360
|
+
except Exception as e:
|
|
361
|
+
raise ValueError(f"Invalid resource server URL '{url}': {e}") from e
|
|
362
|
+
|
|
363
|
+
return url
|
|
364
|
+
|
|
365
|
+
@model_validator(mode="after")
|
|
366
|
+
def validate_token_verifier_compatibility(self) -> "RemoteAuthConfig":
|
|
367
|
+
"""Validate that the token verifier config is compatible with token verification."""
|
|
368
|
+
# The duck-typing check is already handled by the factory function, but we can
|
|
369
|
+
# add a basic sanity check here that the config types are ones we know work
|
|
370
|
+
config = self.token_verifier_config
|
|
371
|
+
|
|
372
|
+
if not isinstance(config, JWTAuthConfig | StaticTokenConfig):
|
|
373
|
+
raise ValueError(
|
|
374
|
+
f"token_verifier_config must be JWTAuthConfig or StaticTokenConfig, got {type(config).__name__}"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# For JWT configs, ensure they have the minimum required fields
|
|
378
|
+
if isinstance(config, JWTAuthConfig) and (
|
|
379
|
+
not config.public_key
|
|
380
|
+
and not config.jwks_uri
|
|
381
|
+
and not config.public_key_env_var
|
|
382
|
+
and not config.jwks_uri_env_var
|
|
383
|
+
):
|
|
384
|
+
raise ValueError(
|
|
385
|
+
"JWT token verifier config must provide public_key, jwks_uri, or their environment variable equivalents"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# For static token configs, ensure they have tokens
|
|
389
|
+
if isinstance(config, StaticTokenConfig) and not config.tokens:
|
|
390
|
+
raise ValueError("Static token verifier config must provide at least one token")
|
|
391
|
+
|
|
392
|
+
return self
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# Union type for all auth configurations
|
|
396
|
+
AuthConfig = JWTAuthConfig | StaticTokenConfig | OAuthServerConfig | RemoteAuthConfig
|
golf/auth/registry.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Provider registry system for extensible authentication providers.
|
|
2
|
+
|
|
3
|
+
This module provides a registry-based dispatch system that allows custom
|
|
4
|
+
authentication providers to be added without modifying the core factory code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Protocol, TYPE_CHECKING
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from fastmcp.server.auth.auth import AuthProvider
|
|
12
|
+
|
|
13
|
+
from .providers import AuthConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthProviderFactory(Protocol):
|
|
17
|
+
"""Protocol for auth provider factory functions.
|
|
18
|
+
|
|
19
|
+
Custom provider factories must implement this interface to be compatible
|
|
20
|
+
with the registry system.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __call__(self, config: AuthConfig) -> "AuthProvider":
|
|
24
|
+
"""Create an AuthProvider from configuration.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config: Authentication configuration object
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Configured FastMCP AuthProvider instance
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If configuration is invalid
|
|
34
|
+
ImportError: If required dependencies are missing
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseProviderPlugin(ABC):
|
|
40
|
+
"""Base class for auth provider plugins.
|
|
41
|
+
|
|
42
|
+
Provider plugins can extend this class to provide both configuration
|
|
43
|
+
and factory logic in a single cohesive unit.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def provider_type(self) -> str:
|
|
49
|
+
"""Return the provider type identifier."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def config_class(self) -> type[AuthConfig]:
|
|
55
|
+
"""Return the configuration class for this provider."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def create_provider(self, config: AuthConfig) -> "AuthProvider":
|
|
60
|
+
"""Create the auth provider from configuration.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
config: Authentication configuration (must be instance of config_class)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Configured FastMCP AuthProvider instance
|
|
67
|
+
"""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def validate_config(self, config: AuthConfig) -> None:
|
|
71
|
+
"""Validate the configuration before creating provider.
|
|
72
|
+
|
|
73
|
+
Override this method to add custom validation logic.
|
|
74
|
+
Default implementation checks config is correct type.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
config: Configuration to validate
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: If configuration is invalid
|
|
81
|
+
"""
|
|
82
|
+
if not isinstance(config, self.config_class):
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"Expected {self.config_class.__name__} for {self.provider_type} provider, got {type(config).__name__}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AuthProviderRegistry:
|
|
89
|
+
"""Registry for authentication provider factories and plugins.
|
|
90
|
+
|
|
91
|
+
This registry allows custom authentication providers to be registered
|
|
92
|
+
without modifying the core factory code. Providers can be registered
|
|
93
|
+
either as simple factory functions or as full plugin classes.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self) -> None:
|
|
97
|
+
self._factories: dict[str, AuthProviderFactory] = {}
|
|
98
|
+
self._plugins: dict[str, BaseProviderPlugin] = {}
|
|
99
|
+
|
|
100
|
+
def register_factory(self, provider_type: str, factory: AuthProviderFactory) -> None:
|
|
101
|
+
"""Register a factory function for a provider type.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
provider_type: Unique identifier for the provider type
|
|
105
|
+
factory: Factory function that creates providers
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ValueError: If provider_type is already registered
|
|
109
|
+
"""
|
|
110
|
+
if provider_type in self._factories or provider_type in self._plugins:
|
|
111
|
+
raise ValueError(f"Provider type '{provider_type}' is already registered")
|
|
112
|
+
|
|
113
|
+
self._factories[provider_type] = factory
|
|
114
|
+
|
|
115
|
+
def register_plugin(self, plugin: BaseProviderPlugin) -> None:
|
|
116
|
+
"""Register a provider plugin.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
plugin: Provider plugin instance
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ValueError: If provider type is already registered
|
|
123
|
+
"""
|
|
124
|
+
provider_type = plugin.provider_type
|
|
125
|
+
if provider_type in self._factories or provider_type in self._plugins:
|
|
126
|
+
raise ValueError(f"Provider type '{provider_type}' is already registered")
|
|
127
|
+
|
|
128
|
+
self._plugins[provider_type] = plugin
|
|
129
|
+
|
|
130
|
+
def unregister(self, provider_type: str) -> None:
|
|
131
|
+
"""Unregister a provider type.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
provider_type: Provider type to remove
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
KeyError: If provider type is not registered
|
|
138
|
+
"""
|
|
139
|
+
if provider_type in self._factories:
|
|
140
|
+
del self._factories[provider_type]
|
|
141
|
+
elif provider_type in self._plugins:
|
|
142
|
+
del self._plugins[provider_type]
|
|
143
|
+
else:
|
|
144
|
+
raise KeyError(f"Provider type '{provider_type}' is not registered")
|
|
145
|
+
|
|
146
|
+
def get_factory(self, provider_type: str) -> AuthProviderFactory:
|
|
147
|
+
"""Get factory function for a provider type.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
provider_type: Provider type to look up
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Factory function for the provider type
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
KeyError: If provider type is not registered
|
|
157
|
+
"""
|
|
158
|
+
# Check factories first
|
|
159
|
+
if provider_type in self._factories:
|
|
160
|
+
return self._factories[provider_type]
|
|
161
|
+
|
|
162
|
+
# Check plugins
|
|
163
|
+
if provider_type in self._plugins:
|
|
164
|
+
plugin = self._plugins[provider_type]
|
|
165
|
+
|
|
166
|
+
# Wrap plugin method to match factory signature
|
|
167
|
+
def plugin_factory(config: AuthConfig) -> "AuthProvider":
|
|
168
|
+
plugin.validate_config(config)
|
|
169
|
+
return plugin.create_provider(config)
|
|
170
|
+
|
|
171
|
+
return plugin_factory
|
|
172
|
+
|
|
173
|
+
raise KeyError(f"No provider registered for type '{provider_type}'")
|
|
174
|
+
|
|
175
|
+
def create_provider(self, config: AuthConfig) -> "AuthProvider":
|
|
176
|
+
"""Create a provider from configuration using the registry.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
config: Authentication configuration
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Configured AuthProvider instance
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
KeyError: If provider type is not registered
|
|
186
|
+
ValueError: If configuration is invalid
|
|
187
|
+
"""
|
|
188
|
+
provider_type = getattr(config, "provider_type", None)
|
|
189
|
+
if not provider_type:
|
|
190
|
+
raise ValueError(f"Configuration {type(config).__name__} missing provider_type attribute")
|
|
191
|
+
|
|
192
|
+
factory = self.get_factory(provider_type)
|
|
193
|
+
return factory(config)
|
|
194
|
+
|
|
195
|
+
def list_providers(self) -> list[str]:
|
|
196
|
+
"""List all registered provider types.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of provider type identifiers
|
|
200
|
+
"""
|
|
201
|
+
return sorted(list(self._factories.keys()) + list(self._plugins.keys()))
|
|
202
|
+
|
|
203
|
+
def is_registered(self, provider_type: str) -> bool:
|
|
204
|
+
"""Check if a provider type is registered.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
provider_type: Provider type to check
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
True if provider type is registered
|
|
211
|
+
"""
|
|
212
|
+
return provider_type in self._factories or provider_type in self._plugins
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Global registry instance
|
|
216
|
+
_default_registry = AuthProviderRegistry()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_provider_registry() -> AuthProviderRegistry:
|
|
220
|
+
"""Get the default provider registry.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Default AuthProviderRegistry instance
|
|
224
|
+
"""
|
|
225
|
+
return _default_registry
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def register_provider_factory(provider_type: str, factory: AuthProviderFactory) -> None:
|
|
229
|
+
"""Register a factory function in the default registry.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
provider_type: Unique identifier for the provider type
|
|
233
|
+
factory: Factory function that creates providers
|
|
234
|
+
"""
|
|
235
|
+
_default_registry.register_factory(provider_type, factory)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def register_provider_plugin(plugin: BaseProviderPlugin) -> None:
|
|
239
|
+
"""Register a provider plugin in the default registry.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
plugin: Provider plugin instance
|
|
243
|
+
"""
|
|
244
|
+
_default_registry.register_plugin(plugin)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def create_auth_provider_from_registry(config: AuthConfig) -> "AuthProvider":
|
|
248
|
+
"""Create an auth provider using the default registry.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
config: Authentication configuration
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Configured AuthProvider instance
|
|
255
|
+
"""
|
|
256
|
+
return _default_registry.create_provider(config)
|