golf-mcp 0.2.16__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.
Files changed (52) hide show
  1. golf/__init__.py +1 -0
  2. golf/auth/__init__.py +277 -0
  3. golf/auth/api_key.py +73 -0
  4. golf/auth/factory.py +360 -0
  5. golf/auth/helpers.py +175 -0
  6. golf/auth/providers.py +586 -0
  7. golf/auth/registry.py +256 -0
  8. golf/cli/__init__.py +1 -0
  9. golf/cli/branding.py +191 -0
  10. golf/cli/main.py +377 -0
  11. golf/commands/__init__.py +5 -0
  12. golf/commands/build.py +81 -0
  13. golf/commands/init.py +290 -0
  14. golf/commands/run.py +137 -0
  15. golf/core/__init__.py +1 -0
  16. golf/core/builder.py +1884 -0
  17. golf/core/builder_auth.py +209 -0
  18. golf/core/builder_metrics.py +221 -0
  19. golf/core/builder_telemetry.py +99 -0
  20. golf/core/config.py +199 -0
  21. golf/core/parser.py +1085 -0
  22. golf/core/telemetry.py +492 -0
  23. golf/core/transformer.py +231 -0
  24. golf/examples/__init__.py +0 -0
  25. golf/examples/basic/.env.example +4 -0
  26. golf/examples/basic/README.md +133 -0
  27. golf/examples/basic/auth.py +76 -0
  28. golf/examples/basic/golf.json +5 -0
  29. golf/examples/basic/prompts/welcome.py +27 -0
  30. golf/examples/basic/resources/current_time.py +34 -0
  31. golf/examples/basic/resources/info.py +28 -0
  32. golf/examples/basic/resources/weather/city.py +46 -0
  33. golf/examples/basic/resources/weather/client.py +48 -0
  34. golf/examples/basic/resources/weather/current.py +36 -0
  35. golf/examples/basic/resources/weather/forecast.py +36 -0
  36. golf/examples/basic/tools/calculator.py +94 -0
  37. golf/examples/basic/tools/say/hello.py +65 -0
  38. golf/metrics/__init__.py +10 -0
  39. golf/metrics/collector.py +320 -0
  40. golf/metrics/registry.py +12 -0
  41. golf/telemetry/__init__.py +23 -0
  42. golf/telemetry/instrumentation.py +1402 -0
  43. golf/utilities/__init__.py +12 -0
  44. golf/utilities/context.py +53 -0
  45. golf/utilities/elicitation.py +170 -0
  46. golf/utilities/sampling.py +221 -0
  47. golf_mcp-0.2.16.dist-info/METADATA +262 -0
  48. golf_mcp-0.2.16.dist-info/RECORD +52 -0
  49. golf_mcp-0.2.16.dist-info/WHEEL +5 -0
  50. golf_mcp-0.2.16.dist-info/entry_points.txt +2 -0
  51. golf_mcp-0.2.16.dist-info/licenses/LICENSE +201 -0
  52. golf_mcp-0.2.16.dist-info/top_level.txt +1 -0
golf/auth/providers.py ADDED
@@ -0,0 +1,586 @@
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
+ # Scopes this resource supports (advertised via /.well-known/oauth-protected-resource)
303
+ scopes_supported: list[str] = Field(
304
+ default_factory=list,
305
+ description="Scopes this resource supports (advertised via /.well-known/oauth-protected-resource)",
306
+ )
307
+
308
+ # Token verification (delegate to another config)
309
+ token_verifier_config: JWTAuthConfig | StaticTokenConfig = Field(
310
+ ..., description="Configuration for the underlying token verifier"
311
+ )
312
+
313
+ # Environment variable names for runtime configuration
314
+ authorization_servers_env_var: str | None = Field(
315
+ None, description="Environment variable name for comma-separated authorization server URLs"
316
+ )
317
+ resource_server_url_env_var: str | None = Field(
318
+ None, description="Environment variable name for resource server URL"
319
+ )
320
+
321
+ @field_validator("authorization_servers")
322
+ @classmethod
323
+ def validate_authorization_servers(cls, v: list[str]) -> list[str]:
324
+ """Validate authorization servers are non-empty and valid URLs."""
325
+ if not v:
326
+ raise ValueError(
327
+ "authorization_servers cannot be empty - at least one authorization server URL is required"
328
+ )
329
+
330
+ valid_urls = []
331
+ for url in v:
332
+ url = url.strip()
333
+ if not url:
334
+ raise ValueError("authorization_servers cannot contain empty URLs")
335
+
336
+ # Validate URL format
337
+ try:
338
+ parsed = urlparse(url)
339
+ if not parsed.scheme or not parsed.netloc:
340
+ raise ValueError(
341
+ f"Invalid URL format for authorization server: '{url}' - must include scheme and netloc"
342
+ )
343
+ if parsed.scheme not in ("http", "https"):
344
+ raise ValueError(f"Authorization server URL must use http or https scheme: '{url}'")
345
+ except Exception as e:
346
+ raise ValueError(f"Invalid authorization server URL '{url}': {e}") from e
347
+
348
+ valid_urls.append(url)
349
+
350
+ return valid_urls
351
+
352
+ @field_validator("resource_server_url")
353
+ @classmethod
354
+ def validate_resource_server_url(cls, v: str) -> str:
355
+ """Validate resource server URL is a valid URL."""
356
+ if not v or not v.strip():
357
+ raise ValueError("resource_server_url cannot be empty")
358
+
359
+ url = v.strip()
360
+ try:
361
+ parsed = urlparse(url)
362
+ if not parsed.scheme or not parsed.netloc:
363
+ raise ValueError(f"Invalid URL format for resource server: '{url}' - must include scheme and netloc")
364
+ if parsed.scheme not in ("http", "https"):
365
+ raise ValueError(f"Resource server URL must use http or https scheme: '{url}'")
366
+ except Exception as e:
367
+ raise ValueError(f"Invalid resource server URL '{url}': {e}") from e
368
+
369
+ return url
370
+
371
+ @field_validator("scopes_supported")
372
+ @classmethod
373
+ def validate_scopes_supported(cls, v: list[str]) -> list[str]:
374
+ """Validate scopes_supported format and security."""
375
+ if not v:
376
+ return v
377
+
378
+ cleaned_scopes = []
379
+ for scope in v:
380
+ scope = scope.strip()
381
+ if not scope:
382
+ raise ValueError("Scopes cannot be empty or whitespace-only")
383
+
384
+ # OAuth 2.0 scope format validation (RFC 6749)
385
+ if not all(32 < ord(c) < 127 and c not in ' "\\' for c in scope):
386
+ raise ValueError(
387
+ f"Invalid scope format: '{scope}' - must be ASCII printable without spaces, quotes, or backslashes"
388
+ )
389
+
390
+ # Reasonable length limit to prevent abuse
391
+ if len(scope) > 128:
392
+ raise ValueError(f"Scope too long: '{scope}' - maximum 128 characters")
393
+
394
+ # Warn about potentially dangerous scope names
395
+ dangerous_scopes = {"admin", "root", "superuser", "system", "*", "all"}
396
+ if scope.lower() in dangerous_scopes:
397
+ import warnings
398
+
399
+ warnings.warn(
400
+ f"Potentially dangerous scope detected: '{scope}'. "
401
+ "Consider using more specific, principle-of-least-privilege scopes.",
402
+ UserWarning,
403
+ stacklevel=2,
404
+ )
405
+
406
+ cleaned_scopes.append(scope)
407
+
408
+ return cleaned_scopes
409
+
410
+ @model_validator(mode="after")
411
+ def validate_token_verifier_compatibility(self) -> "RemoteAuthConfig":
412
+ """Validate that the token verifier config is compatible with token verification."""
413
+ # The duck-typing check is already handled by the factory function, but we can
414
+ # add a basic sanity check here that the config types are ones we know work
415
+ config = self.token_verifier_config
416
+
417
+ if not isinstance(config, JWTAuthConfig | StaticTokenConfig):
418
+ raise ValueError(
419
+ f"token_verifier_config must be JWTAuthConfig or StaticTokenConfig, got {type(config).__name__}"
420
+ )
421
+
422
+ # For JWT configs, ensure they have the minimum required fields
423
+ if isinstance(config, JWTAuthConfig) and (
424
+ not config.public_key
425
+ and not config.jwks_uri
426
+ and not config.public_key_env_var
427
+ and not config.jwks_uri_env_var
428
+ ):
429
+ raise ValueError(
430
+ "JWT token verifier config must provide public_key, jwks_uri, or their environment variable equivalents"
431
+ )
432
+
433
+ # For static token configs, ensure they have tokens
434
+ if isinstance(config, StaticTokenConfig) and not config.tokens:
435
+ raise ValueError("Static token verifier config must provide at least one token")
436
+
437
+ # Convenience: if user didn't set scopes_supported, default to verifier.required_scopes
438
+ if not self.scopes_supported:
439
+ if hasattr(config, "required_scopes") and config.required_scopes:
440
+ self.scopes_supported = list(config.required_scopes)
441
+
442
+ return self
443
+
444
+
445
+ class OAuthProxyConfig(BaseModel):
446
+ """Configuration for OAuth proxy functionality (requires golf-mcp-enterprise).
447
+
448
+ This configuration enables bridging MCP clients (which expect Dynamic Client
449
+ Registration) with OAuth providers that use fixed client credentials like
450
+ GitHub Apps, Google Cloud Console apps, Okta Web Applications, etc.
451
+
452
+ The proxy acts as a DCR-capable authorization server to MCP clients while
453
+ using your fixed upstream client credentials with the actual OAuth provider.
454
+
455
+ Note: This class provides configuration only. The actual implementation
456
+ requires the golf-mcp-enterprise package.
457
+ """
458
+
459
+ provider_type: Literal["oauth_proxy"] = "oauth_proxy"
460
+
461
+ # OAuth provider configuration
462
+ authorization_endpoint: str = Field(..., description="OAuth provider's authorization endpoint URL")
463
+ token_endpoint: str = Field(..., description="OAuth provider's token endpoint URL")
464
+ client_id: str = Field(..., description="Your registered client ID with the OAuth provider")
465
+ client_secret: str = Field(..., description="Your registered client secret with the OAuth provider")
466
+ revocation_endpoint: str | None = Field(None, description="Optional token revocation endpoint")
467
+
468
+ # This proxy server configuration
469
+ base_url: str = Field(..., description="Public URL of this OAuth proxy server")
470
+ redirect_path: str = Field("/oauth/callback", description="OAuth callback path (must match provider registration)")
471
+
472
+ # Scopes and token verification
473
+ scopes_supported: list[str] | None = Field(
474
+ None, description="Scopes supported by this proxy (optional, can be empty for intelligent fallback)"
475
+ )
476
+ token_verifier_config: JWTAuthConfig | StaticTokenConfig = Field(
477
+ ..., description="Token verifier configuration for validating upstream tokens"
478
+ )
479
+
480
+ # Environment variable names for runtime configuration
481
+ authorization_endpoint_env_var: str | None = Field(
482
+ None, description="Environment variable name for authorization endpoint"
483
+ )
484
+ token_endpoint_env_var: str | None = Field(None, description="Environment variable name for token endpoint")
485
+ client_id_env_var: str | None = Field(None, description="Environment variable name for client ID")
486
+ client_secret_env_var: str | None = Field(None, description="Environment variable name for client secret")
487
+ revocation_endpoint_env_var: str | None = Field(
488
+ None, description="Environment variable name for revocation endpoint"
489
+ )
490
+ base_url_env_var: str | None = Field(None, description="Environment variable name for base URL")
491
+
492
+ @field_validator("authorization_endpoint", "token_endpoint", "base_url")
493
+ @classmethod
494
+ def validate_required_urls(cls, v: str) -> str:
495
+ """Validate required URLs are properly formatted."""
496
+ if not v or not v.strip():
497
+ raise ValueError("URL cannot be empty")
498
+
499
+ url = v.strip()
500
+ try:
501
+ from urllib.parse import urlparse
502
+
503
+ parsed = urlparse(url)
504
+ if not parsed.scheme or not parsed.netloc:
505
+ raise ValueError(f"Invalid URL format: '{url}' - must include scheme and netloc")
506
+ if parsed.scheme not in ("http", "https"):
507
+ raise ValueError(f"URL must use http or https scheme: '{url}'")
508
+ except Exception as e:
509
+ if isinstance(e, ValueError):
510
+ raise
511
+ raise ValueError(f"Invalid URL '{url}': {e}") from e
512
+
513
+ return url
514
+
515
+ @field_validator("revocation_endpoint")
516
+ @classmethod
517
+ def validate_optional_url(cls, v: str | None) -> str | None:
518
+ """Validate optional URLs are properly formatted."""
519
+ if not v:
520
+ return v
521
+
522
+ url = v.strip()
523
+ if not url:
524
+ return None
525
+
526
+ try:
527
+ from urllib.parse import urlparse
528
+
529
+ parsed = urlparse(url)
530
+ if not parsed.scheme or not parsed.netloc:
531
+ raise ValueError(f"Invalid URL format: '{url}' - must include scheme and netloc")
532
+ if parsed.scheme not in ("http", "https"):
533
+ raise ValueError(f"URL must use http or https scheme: '{url}'")
534
+ except Exception as e:
535
+ if isinstance(e, ValueError):
536
+ raise
537
+ raise ValueError(f"Invalid URL '{url}': {e}") from e
538
+
539
+ return url
540
+
541
+ @model_validator(mode="after")
542
+ def validate_oauth_proxy_config(self) -> "OAuthProxyConfig":
543
+ """Validate OAuth proxy configuration consistency."""
544
+ # Validate token verifier config is compatible
545
+ if not isinstance(self.token_verifier_config, JWTAuthConfig | StaticTokenConfig):
546
+ raise ValueError(
547
+ f"token_verifier_config must be JWTAuthConfig or StaticTokenConfig, "
548
+ f"got {type(self.token_verifier_config).__name__}"
549
+ )
550
+
551
+ # Warn about HTTPS requirements in production
552
+ is_production = (
553
+ os.environ.get("GOLF_ENV", "").lower() in ("prod", "production")
554
+ or os.environ.get("NODE_ENV", "").lower() == "production"
555
+ or os.environ.get("ENVIRONMENT", "").lower() in ("prod", "production")
556
+ )
557
+
558
+ if is_production:
559
+ from urllib.parse import urlparse
560
+
561
+ urls_to_check = [
562
+ ("base_url", self.base_url),
563
+ ("authorization_endpoint", self.authorization_endpoint),
564
+ ("token_endpoint", self.token_endpoint),
565
+ ]
566
+
567
+ if self.revocation_endpoint:
568
+ urls_to_check.append(("revocation_endpoint", self.revocation_endpoint))
569
+
570
+ for field_name, url in urls_to_check:
571
+ parsed = urlparse(url)
572
+ if parsed.scheme == "http":
573
+ import warnings
574
+
575
+ warnings.warn(
576
+ f"OAuth proxy {field_name} '{url}' uses HTTP in production environment. "
577
+ "HTTPS is strongly recommended for OAuth endpoints to prevent token interception.",
578
+ UserWarning,
579
+ stacklevel=2,
580
+ )
581
+
582
+ return self
583
+
584
+
585
+ # Union type for all auth configurations
586
+ AuthConfig = JWTAuthConfig | StaticTokenConfig | OAuthServerConfig | RemoteAuthConfig | OAuthProxyConfig