fastmcp 2.12.0rc1__py3-none-any.whl → 2.12.1__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.
- fastmcp/client/auth/oauth.py +78 -2
- fastmcp/client/elicitation.py +3 -2
- fastmcp/experimental/sampling/handlers/__init__.py +0 -3
- fastmcp/experimental/sampling/handlers/openai.py +16 -9
- fastmcp/server/auth/auth.py +130 -59
- fastmcp/server/auth/oauth_proxy.py +122 -221
- fastmcp/server/auth/providers/azure.py +3 -12
- fastmcp/server/auth/providers/github.py +5 -13
- fastmcp/server/auth/providers/google.py +4 -11
- fastmcp/server/auth/providers/in_memory.py +0 -2
- fastmcp/server/auth/providers/jwt.py +5 -7
- fastmcp/server/auth/providers/workos.py +16 -16
- fastmcp/server/context.py +3 -2
- fastmcp/server/dependencies.py +1 -4
- fastmcp/server/elicitation.py +3 -2
- fastmcp/server/http.py +22 -59
- fastmcp/server/middleware/middleware.py +3 -3
- fastmcp/server/server.py +2 -3
- fastmcp/settings.py +14 -6
- fastmcp/tools/tool.py +2 -2
- fastmcp/utilities/components.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
- fastmcp/utilities/types.py +2 -2
- {fastmcp-2.12.0rc1.dist-info → fastmcp-2.12.1.dist-info}/METADATA +3 -2
- {fastmcp-2.12.0rc1.dist-info → fastmcp-2.12.1.dist-info}/RECORD +28 -29
- fastmcp/server/auth/registry.py +0 -52
- {fastmcp-2.12.0rc1.dist-info → fastmcp-2.12.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.0rc1.dist-info → fastmcp-2.12.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.0rc1.dist-info → fastmcp-2.12.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -18,12 +18,15 @@ production use with enterprise identity providers.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
import hashlib
|
|
21
22
|
import secrets
|
|
22
23
|
import time
|
|
24
|
+
from base64 import urlsafe_b64encode
|
|
23
25
|
from typing import TYPE_CHECKING, Any, Final
|
|
24
26
|
from urllib.parse import urlencode
|
|
25
27
|
|
|
26
28
|
import httpx
|
|
29
|
+
from authlib.common.security import generate_token
|
|
27
30
|
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
|
28
31
|
from mcp.server.auth.provider import (
|
|
29
32
|
AccessToken,
|
|
@@ -39,7 +42,7 @@ from mcp.server.auth.settings import (
|
|
|
39
42
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
40
43
|
from pydantic import AnyHttpUrl, AnyUrl, SecretStr
|
|
41
44
|
from starlette.requests import Request
|
|
42
|
-
from starlette.responses import
|
|
45
|
+
from starlette.responses import RedirectResponse
|
|
43
46
|
from starlette.routing import Route
|
|
44
47
|
|
|
45
48
|
from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier
|
|
@@ -172,7 +175,6 @@ class OAuthProxy(OAuthProvider):
|
|
|
172
175
|
1. Client Registration (DCR):
|
|
173
176
|
- Accept any client registration request
|
|
174
177
|
- Store ProxyDCRClient that accepts dynamic redirect URIs
|
|
175
|
-
- Return shared upstream credentials to all clients
|
|
176
178
|
|
|
177
179
|
2. Authorization:
|
|
178
180
|
- Store transaction mapping client details to proxy flow
|
|
@@ -241,9 +243,13 @@ class OAuthProxy(OAuthProvider):
|
|
|
241
243
|
redirect_path: str = "/auth/callback",
|
|
242
244
|
issuer_url: AnyHttpUrl | str | None = None,
|
|
243
245
|
service_documentation_url: AnyHttpUrl | str | None = None,
|
|
244
|
-
resource_server_url: AnyHttpUrl | str | None = None,
|
|
245
246
|
# Client redirect URI validation
|
|
246
247
|
allowed_client_redirect_uris: list[str] | None = None,
|
|
248
|
+
valid_scopes: list[str] | None = None,
|
|
249
|
+
# PKCE configuration
|
|
250
|
+
forward_pkce: bool = True,
|
|
251
|
+
# Token endpoint authentication
|
|
252
|
+
token_endpoint_auth_method: str | None = None,
|
|
247
253
|
):
|
|
248
254
|
"""Initialize the OAuth proxy provider.
|
|
249
255
|
|
|
@@ -259,17 +265,25 @@ class OAuthProxy(OAuthProvider):
|
|
|
259
265
|
redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback")
|
|
260
266
|
issuer_url: Issuer URL for OAuth metadata (defaults to base_url)
|
|
261
267
|
service_documentation_url: Optional service documentation URL
|
|
262
|
-
resource_server_url: Path of the FastMCP server. If None, FastMCP will
|
|
263
|
-
attempt to overwrite this with the correct path to the server
|
|
264
|
-
e.g. {base_url}/mcp
|
|
265
268
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
266
269
|
Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*").
|
|
267
270
|
If None (default), only localhost redirect URIs are allowed.
|
|
268
271
|
If empty list, all redirect URIs are allowed (not recommended for production).
|
|
269
272
|
These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app.
|
|
273
|
+
valid_scopes: List of all the possible valid scopes for a client.
|
|
274
|
+
These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided.
|
|
275
|
+
forward_pkce: Whether to forward PKCE to upstream server (default True).
|
|
276
|
+
Enable for providers that support/require PKCE (Google, Azure, etc.).
|
|
277
|
+
Disable only if upstream provider doesn't support PKCE.
|
|
278
|
+
token_endpoint_auth_method: Token endpoint authentication method for upstream server.
|
|
279
|
+
Common values: "client_secret_basic", "client_secret_post", "none".
|
|
280
|
+
If None, authlib will use its default (typically "client_secret_basic").
|
|
270
281
|
"""
|
|
271
282
|
# Always enable DCR since we implement it locally for MCP clients
|
|
272
|
-
client_registration_options = ClientRegistrationOptions(
|
|
283
|
+
client_registration_options = ClientRegistrationOptions(
|
|
284
|
+
enabled=True,
|
|
285
|
+
valid_scopes=valid_scopes or token_verifier.required_scopes,
|
|
286
|
+
)
|
|
273
287
|
|
|
274
288
|
# Enable revocation only if upstream endpoint provided
|
|
275
289
|
revocation_options = (
|
|
@@ -283,7 +297,6 @@ class OAuthProxy(OAuthProvider):
|
|
|
283
297
|
client_registration_options=client_registration_options,
|
|
284
298
|
revocation_options=revocation_options,
|
|
285
299
|
required_scopes=token_verifier.required_scopes,
|
|
286
|
-
resource_server_url=resource_server_url,
|
|
287
300
|
)
|
|
288
301
|
|
|
289
302
|
# Store upstream configuration
|
|
@@ -300,6 +313,12 @@ class OAuthProxy(OAuthProvider):
|
|
|
300
313
|
)
|
|
301
314
|
self._allowed_client_redirect_uris = allowed_client_redirect_uris
|
|
302
315
|
|
|
316
|
+
# PKCE configuration
|
|
317
|
+
self._forward_pkce = forward_pkce
|
|
318
|
+
|
|
319
|
+
# Token endpoint authentication
|
|
320
|
+
self._token_endpoint_auth_method = token_endpoint_auth_method
|
|
321
|
+
|
|
303
322
|
# Local state for DCR and token bookkeeping
|
|
304
323
|
self._clients: dict[str, OAuthClientInformationFull] = {}
|
|
305
324
|
self._access_tokens: dict[str, AccessToken] = {}
|
|
@@ -323,72 +342,52 @@ class OAuthProxy(OAuthProvider):
|
|
|
323
342
|
self._upstream_authorization_endpoint,
|
|
324
343
|
)
|
|
325
344
|
|
|
345
|
+
# -------------------------------------------------------------------------
|
|
346
|
+
# PKCE Helper Methods
|
|
347
|
+
# -------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
def _generate_pkce_pair(self) -> tuple[str, str]:
|
|
350
|
+
"""Generate PKCE code verifier and challenge pair.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Tuple of (code_verifier, code_challenge) using S256 method
|
|
354
|
+
"""
|
|
355
|
+
# Generate code verifier: 43-128 characters from unreserved set
|
|
356
|
+
code_verifier = generate_token(48)
|
|
357
|
+
|
|
358
|
+
# Generate code challenge using S256 (SHA256 + base64url)
|
|
359
|
+
challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
|
|
360
|
+
code_challenge = urlsafe_b64encode(challenge_bytes).decode().rstrip("=")
|
|
361
|
+
|
|
362
|
+
return code_verifier, code_challenge
|
|
363
|
+
|
|
326
364
|
# -------------------------------------------------------------------------
|
|
327
365
|
# Client Registration (Local Implementation)
|
|
328
366
|
# -------------------------------------------------------------------------
|
|
329
367
|
|
|
330
368
|
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
331
|
-
"""Get client information by ID.
|
|
369
|
+
"""Get client information by ID. This is generally the random ID
|
|
370
|
+
provided to the DCR client during registration, not the upstream client ID.
|
|
332
371
|
|
|
333
|
-
For unregistered clients, returns
|
|
334
|
-
any localhost redirect URI for DCR clients.
|
|
335
|
-
|
|
336
|
-
Even registered clients use ProxyDCRClient to ensure they can
|
|
337
|
-
authenticate with different dynamic ports on reconnection. This
|
|
338
|
-
handles the case where a client with cached tokens reconnects
|
|
339
|
-
on a different port.
|
|
372
|
+
For unregistered clients, returns None (which will raise an error in the SDK).
|
|
340
373
|
"""
|
|
341
374
|
client = self._clients.get(client_id)
|
|
342
375
|
|
|
343
|
-
if client is None:
|
|
344
|
-
# For unregistered DCR clients, create a permissive client
|
|
345
|
-
# that will accept any localhost redirect URI
|
|
346
|
-
# We need at least one URI for Pydantic validation, but our custom
|
|
347
|
-
# validate_redirect_uri will accept any localhost URI
|
|
348
|
-
client = ProxyDCRClient(
|
|
349
|
-
client_id=client_id,
|
|
350
|
-
client_secret=None,
|
|
351
|
-
redirect_uris=[
|
|
352
|
-
AnyUrl("http://localhost")
|
|
353
|
-
], # Placeholder, validation uses allowed_patterns
|
|
354
|
-
grant_types=["authorization_code", "refresh_token"],
|
|
355
|
-
scope=self._default_scope_str,
|
|
356
|
-
token_endpoint_auth_method="none",
|
|
357
|
-
allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
|
|
358
|
-
)
|
|
359
|
-
logger.debug("Created ProxyDCRClient for unregistered client %s", client_id)
|
|
360
|
-
|
|
361
376
|
return client
|
|
362
377
|
|
|
363
378
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
364
|
-
"""Register a client locally
|
|
365
|
-
|
|
366
|
-
This implementation always uses the upstream client_id and client_secret
|
|
367
|
-
regardless of what the client requests. It modifies the client_info object
|
|
368
|
-
in place since the MCP framework ignores return values.
|
|
369
|
-
|
|
370
|
-
This ensures all clients use the same credentials that are registered
|
|
371
|
-
with the upstream server.
|
|
379
|
+
"""Register a client locally
|
|
372
380
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
The flow:
|
|
379
|
-
1. Client provides its desired redirect URIs (dynamic localhost ports)
|
|
380
|
-
2. We create a ProxyDCRClient that will accept ANY localhost URI
|
|
381
|
-
3. We store this flexible client for future authentications
|
|
382
|
-
4. When client reconnects with a different port, ProxyDCRClient accepts it
|
|
381
|
+
When a client registers, we create a ProxyDCRClient that is more
|
|
382
|
+
forgiving about validating redirect URIs, since the DCR client's
|
|
383
|
+
redirect URI will likely be localhost or unknown to the proxied IDP. The
|
|
384
|
+
proxied IDP only knows about this server's fixed redirect URI.
|
|
383
385
|
"""
|
|
384
|
-
# Always use the upstream credentials
|
|
385
|
-
upstream_id = self._upstream_client_id
|
|
386
|
-
upstream_secret = self._upstream_client_secret.get_secret_value()
|
|
387
386
|
|
|
388
387
|
# Create a ProxyDCRClient with configured redirect URI validation
|
|
389
388
|
proxy_client = ProxyDCRClient(
|
|
390
|
-
client_id=
|
|
391
|
-
client_secret=
|
|
389
|
+
client_id=client_info.client_id,
|
|
390
|
+
client_secret=client_info.client_secret,
|
|
392
391
|
redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")],
|
|
393
392
|
grant_types=client_info.grant_types
|
|
394
393
|
or ["authorization_code", "refresh_token"],
|
|
@@ -397,8 +396,8 @@ class OAuthProxy(OAuthProvider):
|
|
|
397
396
|
allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
|
|
398
397
|
)
|
|
399
398
|
|
|
400
|
-
# Store the ProxyDCRClient
|
|
401
|
-
self._clients[
|
|
399
|
+
# Store the ProxyDCRClient
|
|
400
|
+
self._clients[client_info.client_id] = proxy_client
|
|
402
401
|
|
|
403
402
|
# Log redirect URIs to help users discover what patterns they might need
|
|
404
403
|
if client_info.redirect_uris:
|
|
@@ -411,7 +410,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
411
410
|
|
|
412
411
|
logger.debug(
|
|
413
412
|
"Registered client %s with %d redirect URIs",
|
|
414
|
-
|
|
413
|
+
client_info.client_id,
|
|
415
414
|
len(proxy_client.redirect_uris),
|
|
416
415
|
)
|
|
417
416
|
|
|
@@ -428,14 +427,25 @@ class OAuthProxy(OAuthProvider):
|
|
|
428
427
|
|
|
429
428
|
This implements the DCR-compliant proxy pattern:
|
|
430
429
|
1. Store transaction with client details and PKCE challenge
|
|
431
|
-
2.
|
|
432
|
-
3.
|
|
430
|
+
2. Generate proxy's own PKCE parameters if forwarding is enabled
|
|
431
|
+
3. Use transaction ID as state for IdP
|
|
432
|
+
4. Redirect to IdP with our fixed callback URL and proxy's PKCE
|
|
433
433
|
"""
|
|
434
434
|
# Generate transaction ID for this authorization request
|
|
435
435
|
txn_id = secrets.token_urlsafe(32)
|
|
436
436
|
|
|
437
|
+
# Generate proxy's own PKCE parameters if forwarding is enabled
|
|
438
|
+
proxy_code_verifier = None
|
|
439
|
+
proxy_code_challenge = None
|
|
440
|
+
if self._forward_pkce and params.code_challenge:
|
|
441
|
+
proxy_code_verifier, proxy_code_challenge = self._generate_pkce_pair()
|
|
442
|
+
logger.debug(
|
|
443
|
+
"Generated proxy PKCE for transaction %s (forwarding client PKCE to upstream)",
|
|
444
|
+
txn_id,
|
|
445
|
+
)
|
|
446
|
+
|
|
437
447
|
# Store transaction data for IdP callback processing
|
|
438
|
-
|
|
448
|
+
transaction_data = {
|
|
439
449
|
"client_id": client.client_id,
|
|
440
450
|
"client_redirect_uri": str(params.redirect_uri),
|
|
441
451
|
"client_state": params.state,
|
|
@@ -445,6 +455,12 @@ class OAuthProxy(OAuthProvider):
|
|
|
445
455
|
"created_at": time.time(),
|
|
446
456
|
}
|
|
447
457
|
|
|
458
|
+
# Store proxy's PKCE verifier if we're forwarding
|
|
459
|
+
if proxy_code_verifier:
|
|
460
|
+
transaction_data["proxy_code_verifier"] = proxy_code_verifier
|
|
461
|
+
|
|
462
|
+
self._oauth_transactions[txn_id] = transaction_data
|
|
463
|
+
|
|
448
464
|
# Build query parameters for upstream IdP authorization request
|
|
449
465
|
# Use our fixed IdP callback and transaction ID as state
|
|
450
466
|
query_params: dict[str, Any] = {
|
|
@@ -460,14 +476,24 @@ class OAuthProxy(OAuthProvider):
|
|
|
460
476
|
if scopes_to_use:
|
|
461
477
|
query_params["scope"] = " ".join(scopes_to_use)
|
|
462
478
|
|
|
479
|
+
# Forward proxy's PKCE challenge to upstream if enabled
|
|
480
|
+
if proxy_code_challenge:
|
|
481
|
+
query_params["code_challenge"] = proxy_code_challenge
|
|
482
|
+
query_params["code_challenge_method"] = "S256"
|
|
483
|
+
logger.debug(
|
|
484
|
+
"Forwarding proxy PKCE challenge to upstream for transaction %s",
|
|
485
|
+
txn_id,
|
|
486
|
+
)
|
|
487
|
+
|
|
463
488
|
# Build the upstream authorization URL
|
|
464
489
|
separator = "&" if "?" in self._upstream_authorization_endpoint else "?"
|
|
465
490
|
upstream_url = f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}"
|
|
466
491
|
|
|
467
492
|
logger.debug(
|
|
468
|
-
"Starting OAuth transaction %s for client %s, redirecting to IdP",
|
|
493
|
+
"Starting OAuth transaction %s for client %s, redirecting to IdP (PKCE forwarding: %s)",
|
|
469
494
|
txn_id,
|
|
470
495
|
client.client_id,
|
|
496
|
+
"enabled" if proxy_code_challenge else "disabled",
|
|
471
497
|
)
|
|
472
498
|
return upstream_url
|
|
473
499
|
|
|
@@ -604,6 +630,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
604
630
|
oauth_client = AsyncOAuth2Client(
|
|
605
631
|
client_id=self._upstream_client_id,
|
|
606
632
|
client_secret=self._upstream_client_secret.get_secret_value(),
|
|
633
|
+
token_endpoint_auth_method=self._token_endpoint_auth_method,
|
|
607
634
|
timeout=HTTP_TIMEOUT_SECONDS,
|
|
608
635
|
)
|
|
609
636
|
|
|
@@ -728,163 +755,22 @@ class OAuthProxy(OAuthProvider):
|
|
|
728
755
|
|
|
729
756
|
logger.debug("Token revoked successfully")
|
|
730
757
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
"""Custom token endpoint using authlib for upstream requests.
|
|
737
|
-
|
|
738
|
-
This handler uses authlib's OAuth2Client to forward token requests to the
|
|
739
|
-
upstream OAuth server, automatically handling response format differences.
|
|
740
|
-
"""
|
|
741
|
-
try:
|
|
742
|
-
# Parse the incoming request form data
|
|
743
|
-
form_data = await request.form()
|
|
744
|
-
|
|
745
|
-
# Log the incoming request (with sensitive data redacted)
|
|
746
|
-
redacted_form = {
|
|
747
|
-
k: (
|
|
748
|
-
str(v)[:8] + "..."
|
|
749
|
-
if k in {"code", "code_verifier", "client_secret", "refresh_token"}
|
|
750
|
-
and v
|
|
751
|
-
else str(v)
|
|
752
|
-
)
|
|
753
|
-
for k, v in form_data.items()
|
|
754
|
-
}
|
|
755
|
-
logger.debug("Proxy token request form data: %s", redacted_form)
|
|
756
|
-
|
|
757
|
-
# Create authlib OAuth2 client
|
|
758
|
-
oauth_client = AsyncOAuth2Client(
|
|
759
|
-
client_id=self._upstream_client_id,
|
|
760
|
-
client_secret=self._upstream_client_secret.get_secret_value(),
|
|
761
|
-
timeout=HTTP_TIMEOUT_SECONDS,
|
|
762
|
-
)
|
|
763
|
-
|
|
764
|
-
grant_type = str(form_data.get("grant_type", ""))
|
|
765
|
-
|
|
766
|
-
if grant_type == "authorization_code":
|
|
767
|
-
# Authorization code grant
|
|
768
|
-
try:
|
|
769
|
-
token_data: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc]
|
|
770
|
-
url=self._upstream_token_endpoint,
|
|
771
|
-
code=str(form_data.get("code", "")),
|
|
772
|
-
redirect_uri=str(form_data.get("redirect_uri", "")),
|
|
773
|
-
code_verifier=str(form_data.get("code_verifier"))
|
|
774
|
-
if "code_verifier" in form_data
|
|
775
|
-
else None,
|
|
776
|
-
)
|
|
777
|
-
|
|
778
|
-
# Store tokens locally for tracking
|
|
779
|
-
if "access_token" in token_data:
|
|
780
|
-
self._store_tokens_from_response(token_data)
|
|
781
|
-
|
|
782
|
-
logger.debug(
|
|
783
|
-
"Successfully proxied authorization code exchange via authlib"
|
|
784
|
-
)
|
|
785
|
-
|
|
786
|
-
except Exception as e:
|
|
787
|
-
logger.error("Authlib authorization code exchange failed: %s", e)
|
|
788
|
-
return JSONResponse(
|
|
789
|
-
content={
|
|
790
|
-
"error": "invalid_grant",
|
|
791
|
-
"error_description": f"Authorization code exchange failed: {e}",
|
|
792
|
-
},
|
|
793
|
-
status_code=400,
|
|
794
|
-
)
|
|
795
|
-
|
|
796
|
-
elif grant_type == "refresh_token":
|
|
797
|
-
# Refresh token grant
|
|
798
|
-
try:
|
|
799
|
-
token_data: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc]
|
|
800
|
-
url=self._upstream_token_endpoint,
|
|
801
|
-
refresh_token=str(form_data.get("refresh_token", "")),
|
|
802
|
-
scope=str(form_data.get("scope"))
|
|
803
|
-
if "scope" in form_data
|
|
804
|
-
else None,
|
|
805
|
-
)
|
|
806
|
-
|
|
807
|
-
logger.debug(
|
|
808
|
-
"Successfully proxied refresh token exchange via authlib"
|
|
809
|
-
)
|
|
810
|
-
|
|
811
|
-
except Exception as e:
|
|
812
|
-
logger.error("Authlib refresh token exchange failed: %s", e)
|
|
813
|
-
return JSONResponse(
|
|
814
|
-
content={
|
|
815
|
-
"error": "invalid_grant",
|
|
816
|
-
"error_description": f"Refresh token exchange failed: {e}",
|
|
817
|
-
},
|
|
818
|
-
status_code=400,
|
|
819
|
-
)
|
|
820
|
-
else:
|
|
821
|
-
# Unsupported grant type
|
|
822
|
-
logger.error("Unsupported grant type: %s", grant_type)
|
|
823
|
-
return JSONResponse(
|
|
824
|
-
content={
|
|
825
|
-
"error": "unsupported_grant_type",
|
|
826
|
-
"error_description": f"Grant type '{grant_type}' not supported by proxy",
|
|
827
|
-
},
|
|
828
|
-
status_code=400,
|
|
829
|
-
)
|
|
830
|
-
|
|
831
|
-
return JSONResponse(content=token_data)
|
|
832
|
-
|
|
833
|
-
except Exception as e:
|
|
834
|
-
logger.error("Error in proxy token handler: %s", e, exc_info=True)
|
|
835
|
-
return JSONResponse(
|
|
836
|
-
content={
|
|
837
|
-
"error": "server_error",
|
|
838
|
-
"error_description": "Internal server error",
|
|
839
|
-
},
|
|
840
|
-
status_code=500,
|
|
841
|
-
)
|
|
842
|
-
|
|
843
|
-
def _store_tokens_from_response(self, token_data: dict[str, Any]) -> None:
|
|
844
|
-
"""Store tokens from upstream response for local tracking."""
|
|
845
|
-
try:
|
|
846
|
-
access_token_value = token_data.get("access_token")
|
|
847
|
-
refresh_token_value = token_data.get("refresh_token")
|
|
848
|
-
expires_in = int(
|
|
849
|
-
token_data.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
|
850
|
-
)
|
|
851
|
-
expires_at = int(time.time() + expires_in)
|
|
852
|
-
|
|
853
|
-
if access_token_value:
|
|
854
|
-
access_token = AccessToken(
|
|
855
|
-
token=access_token_value,
|
|
856
|
-
client_id=self._upstream_client_id,
|
|
857
|
-
scopes=[], # Will be determined by token validation
|
|
858
|
-
expires_at=expires_at,
|
|
859
|
-
)
|
|
860
|
-
self._access_tokens[access_token_value] = access_token
|
|
861
|
-
|
|
862
|
-
if refresh_token_value:
|
|
863
|
-
refresh_token = RefreshToken(
|
|
864
|
-
token=refresh_token_value,
|
|
865
|
-
client_id=self._upstream_client_id,
|
|
866
|
-
scopes=[],
|
|
867
|
-
expires_at=None,
|
|
868
|
-
)
|
|
869
|
-
self._refresh_tokens[refresh_token_value] = refresh_token
|
|
870
|
-
|
|
871
|
-
# Maintain token relationships
|
|
872
|
-
self._access_to_refresh[access_token_value] = refresh_token_value
|
|
873
|
-
self._refresh_to_access[refresh_token_value] = access_token_value
|
|
874
|
-
|
|
875
|
-
logger.debug("Stored tokens from upstream response for tracking")
|
|
876
|
-
|
|
877
|
-
except Exception as e:
|
|
878
|
-
logger.warning("Failed to store tokens from upstream response: %s", e)
|
|
879
|
-
|
|
880
|
-
def get_routes(self) -> list[Route]:
|
|
758
|
+
def get_routes(
|
|
759
|
+
self,
|
|
760
|
+
mcp_path: str | None = None,
|
|
761
|
+
mcp_endpoint: Any | None = None,
|
|
762
|
+
) -> list[Route]:
|
|
881
763
|
"""Get OAuth routes with custom proxy token handler.
|
|
882
764
|
|
|
883
765
|
This method creates standard OAuth routes and replaces the token endpoint
|
|
884
766
|
with our proxy handler that forwards requests to the upstream OAuth server.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
770
|
+
mcp_endpoint: The MCP endpoint handler to protect with auth
|
|
885
771
|
"""
|
|
886
772
|
# Get standard OAuth routes from parent class
|
|
887
|
-
routes = super().get_routes()
|
|
773
|
+
routes = super().get_routes(mcp_path, mcp_endpoint)
|
|
888
774
|
custom_routes = []
|
|
889
775
|
token_route_found = False
|
|
890
776
|
|
|
@@ -972,6 +858,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
972
858
|
oauth_client = AsyncOAuth2Client(
|
|
973
859
|
client_id=self._upstream_client_id,
|
|
974
860
|
client_secret=self._upstream_client_secret.get_secret_value(),
|
|
861
|
+
token_endpoint_auth_method=self._token_endpoint_auth_method,
|
|
975
862
|
timeout=HTTP_TIMEOUT_SECONDS,
|
|
976
863
|
)
|
|
977
864
|
|
|
@@ -983,14 +870,28 @@ class OAuthProxy(OAuthProvider):
|
|
|
983
870
|
f"Exchanging IdP code for tokens with redirect_uri: {idp_redirect_uri}"
|
|
984
871
|
)
|
|
985
872
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
873
|
+
# Include proxy's code_verifier if we forwarded PKCE
|
|
874
|
+
proxy_code_verifier = transaction.get("proxy_code_verifier")
|
|
875
|
+
if proxy_code_verifier:
|
|
876
|
+
logger.debug(
|
|
877
|
+
"Including proxy code_verifier in token exchange for transaction %s",
|
|
878
|
+
txn_id,
|
|
879
|
+
)
|
|
880
|
+
idp_tokens: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc]
|
|
881
|
+
url=self._upstream_token_endpoint,
|
|
882
|
+
code=idp_code,
|
|
883
|
+
redirect_uri=idp_redirect_uri,
|
|
884
|
+
code_verifier=proxy_code_verifier,
|
|
885
|
+
)
|
|
886
|
+
else:
|
|
887
|
+
idp_tokens: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc]
|
|
888
|
+
url=self._upstream_token_endpoint,
|
|
889
|
+
code=idp_code,
|
|
890
|
+
redirect_uri=idp_redirect_uri,
|
|
891
|
+
)
|
|
991
892
|
|
|
992
893
|
logger.debug(
|
|
993
|
-
f"Successfully exchanged IdP code for tokens (transaction: {txn_id})"
|
|
894
|
+
f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})"
|
|
994
895
|
)
|
|
995
896
|
|
|
996
897
|
except Exception as e:
|
|
@@ -12,7 +12,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
12
12
|
|
|
13
13
|
from fastmcp.server.auth import AccessToken, TokenVerifier
|
|
14
14
|
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|
15
|
-
from fastmcp.server.auth.registry import register_provider
|
|
16
15
|
from fastmcp.utilities.auth import parse_scopes
|
|
17
16
|
from fastmcp.utilities.logging import get_logger
|
|
18
17
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
@@ -36,7 +35,6 @@ class AzureProviderSettings(BaseSettings):
|
|
|
36
35
|
redirect_path: str | None = None
|
|
37
36
|
required_scopes: list[str] | None = None
|
|
38
37
|
timeout_seconds: int | None = None
|
|
39
|
-
resource_server_url: str | None = None
|
|
40
38
|
allowed_client_redirect_uris: list[str] | None = None
|
|
41
39
|
|
|
42
40
|
@field_validator("required_scopes", mode="before")
|
|
@@ -116,7 +114,6 @@ class AzureTokenVerifier(TokenVerifier):
|
|
|
116
114
|
return None
|
|
117
115
|
|
|
118
116
|
|
|
119
|
-
@register_provider("AZURE")
|
|
120
117
|
class AzureProvider(OAuthProxy):
|
|
121
118
|
"""Azure (Microsoft Entra) OAuth provider for FastMCP.
|
|
122
119
|
|
|
@@ -162,7 +159,6 @@ class AzureProvider(OAuthProxy):
|
|
|
162
159
|
redirect_path: str | NotSetT = NotSet,
|
|
163
160
|
required_scopes: list[str] | None | NotSetT = NotSet,
|
|
164
161
|
timeout_seconds: int | NotSetT = NotSet,
|
|
165
|
-
resource_server_url: str | NotSetT = NotSet,
|
|
166
162
|
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
|
167
163
|
):
|
|
168
164
|
"""Initialize Azure OAuth provider.
|
|
@@ -175,8 +171,6 @@ class AzureProvider(OAuthProxy):
|
|
|
175
171
|
redirect_path: Redirect path configured in Azure (defaults to "/auth/callback")
|
|
176
172
|
required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"])
|
|
177
173
|
timeout_seconds: HTTP request timeout for Azure API calls
|
|
178
|
-
resource_server_url: Path of the FastMCP server (defaults to base_url). If your MCP endpoint is at
|
|
179
|
-
a different path like {base_url}/mcp, specify it here for RFC 8707 compliance.
|
|
180
174
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
181
175
|
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
|
182
176
|
"""
|
|
@@ -191,7 +185,6 @@ class AzureProvider(OAuthProxy):
|
|
|
191
185
|
"redirect_path": redirect_path,
|
|
192
186
|
"required_scopes": required_scopes,
|
|
193
187
|
"timeout_seconds": timeout_seconds,
|
|
194
|
-
"resource_server_url": resource_server_url,
|
|
195
188
|
"allowed_client_redirect_uris": allowed_client_redirect_uris,
|
|
196
189
|
}.items()
|
|
197
190
|
if v is not NotSet
|
|
@@ -217,7 +210,7 @@ class AzureProvider(OAuthProxy):
|
|
|
217
210
|
|
|
218
211
|
# Apply defaults
|
|
219
212
|
tenant_id_final = settings.tenant_id
|
|
220
|
-
|
|
213
|
+
|
|
221
214
|
redirect_path_final = settings.redirect_path or "/auth/callback"
|
|
222
215
|
timeout_seconds_final = settings.timeout_seconds or 10
|
|
223
216
|
# Default scopes for Azure - User.Read gives us access to user info via Graph API
|
|
@@ -227,7 +220,6 @@ class AzureProvider(OAuthProxy):
|
|
|
227
220
|
"openid",
|
|
228
221
|
"profile",
|
|
229
222
|
]
|
|
230
|
-
resource_server_url_final = settings.resource_server_url or base_url_final
|
|
231
223
|
allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
|
|
232
224
|
|
|
233
225
|
# Extract secret string from SecretStr
|
|
@@ -256,11 +248,10 @@ class AzureProvider(OAuthProxy):
|
|
|
256
248
|
upstream_client_id=settings.client_id,
|
|
257
249
|
upstream_client_secret=client_secret_str,
|
|
258
250
|
token_verifier=token_verifier,
|
|
259
|
-
base_url=
|
|
251
|
+
base_url=settings.base_url,
|
|
260
252
|
redirect_path=redirect_path_final,
|
|
261
|
-
issuer_url=
|
|
253
|
+
issuer_url=settings.base_url,
|
|
262
254
|
allowed_client_redirect_uris=allowed_client_redirect_uris_final,
|
|
263
|
-
resource_server_url=resource_server_url_final,
|
|
264
255
|
)
|
|
265
256
|
|
|
266
257
|
logger.info(
|
|
@@ -28,7 +28,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
28
28
|
from fastmcp.server.auth import TokenVerifier
|
|
29
29
|
from fastmcp.server.auth.auth import AccessToken
|
|
30
30
|
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|
31
|
-
from fastmcp.server.auth.registry import register_provider
|
|
32
31
|
from fastmcp.utilities.auth import parse_scopes
|
|
33
32
|
from fastmcp.utilities.logging import get_logger
|
|
34
33
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
@@ -51,7 +50,6 @@ class GitHubProviderSettings(BaseSettings):
|
|
|
51
50
|
redirect_path: str | None = None
|
|
52
51
|
required_scopes: list[str] | None = None
|
|
53
52
|
timeout_seconds: int | None = None
|
|
54
|
-
resource_server_url: AnyHttpUrl | str | None = None
|
|
55
53
|
allowed_client_redirect_uris: list[str] | None = None
|
|
56
54
|
|
|
57
55
|
@field_validator("required_scopes", mode="before")
|
|
@@ -165,7 +163,6 @@ class GitHubTokenVerifier(TokenVerifier):
|
|
|
165
163
|
return None
|
|
166
164
|
|
|
167
165
|
|
|
168
|
-
@register_provider("GitHub")
|
|
169
166
|
class GitHubProvider(OAuthProxy):
|
|
170
167
|
"""Complete GitHub OAuth provider for FastMCP.
|
|
171
168
|
|
|
@@ -187,7 +184,7 @@ class GitHubProvider(OAuthProxy):
|
|
|
187
184
|
auth = GitHubProvider(
|
|
188
185
|
client_id="Ov23li...",
|
|
189
186
|
client_secret="abc123...",
|
|
190
|
-
base_url="https://my-server.com"
|
|
187
|
+
base_url="https://my-server.com"
|
|
191
188
|
)
|
|
192
189
|
|
|
193
190
|
mcp = FastMCP("My App", auth=auth)
|
|
@@ -203,7 +200,6 @@ class GitHubProvider(OAuthProxy):
|
|
|
203
200
|
redirect_path: str | NotSetT = NotSet,
|
|
204
201
|
required_scopes: list[str] | NotSetT = NotSet,
|
|
205
202
|
timeout_seconds: int | NotSetT = NotSet,
|
|
206
|
-
resource_server_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
207
203
|
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
|
208
204
|
):
|
|
209
205
|
"""Initialize GitHub OAuth provider.
|
|
@@ -215,11 +211,10 @@ class GitHubProvider(OAuthProxy):
|
|
|
215
211
|
redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback")
|
|
216
212
|
required_scopes: Required GitHub scopes (defaults to ["user"])
|
|
217
213
|
timeout_seconds: HTTP request timeout for GitHub API calls
|
|
218
|
-
resource_server_url: Path of the FastMCP server (defaults to base_url). If your MCP endpoint is at
|
|
219
|
-
a different path like {base_url}/mcp, specify it here for RFC 8707 compliance.
|
|
220
214
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
221
215
|
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
|
222
216
|
"""
|
|
217
|
+
|
|
223
218
|
settings = GitHubProviderSettings.model_validate(
|
|
224
219
|
{
|
|
225
220
|
k: v
|
|
@@ -230,7 +225,6 @@ class GitHubProvider(OAuthProxy):
|
|
|
230
225
|
"redirect_path": redirect_path,
|
|
231
226
|
"required_scopes": required_scopes,
|
|
232
227
|
"timeout_seconds": timeout_seconds,
|
|
233
|
-
"resource_server_url": resource_server_url,
|
|
234
228
|
"allowed_client_redirect_uris": allowed_client_redirect_uris,
|
|
235
229
|
}.items()
|
|
236
230
|
if v is not NotSet
|
|
@@ -248,11 +242,10 @@ class GitHubProvider(OAuthProxy):
|
|
|
248
242
|
)
|
|
249
243
|
|
|
250
244
|
# Apply defaults
|
|
251
|
-
|
|
245
|
+
|
|
252
246
|
redirect_path_final = settings.redirect_path or "/auth/callback"
|
|
253
247
|
timeout_seconds_final = settings.timeout_seconds or 10
|
|
254
248
|
required_scopes_final = settings.required_scopes or ["user"]
|
|
255
|
-
resource_server_url_final = settings.resource_server_url or base_url_final
|
|
256
249
|
allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
|
|
257
250
|
|
|
258
251
|
# Create GitHub token verifier
|
|
@@ -273,11 +266,10 @@ class GitHubProvider(OAuthProxy):
|
|
|
273
266
|
upstream_client_id=settings.client_id,
|
|
274
267
|
upstream_client_secret=client_secret_str,
|
|
275
268
|
token_verifier=token_verifier,
|
|
276
|
-
base_url=
|
|
269
|
+
base_url=settings.base_url,
|
|
277
270
|
redirect_path=redirect_path_final,
|
|
278
|
-
issuer_url=
|
|
271
|
+
issuer_url=settings.base_url, # We act as the issuer for client registration
|
|
279
272
|
allowed_client_redirect_uris=allowed_client_redirect_uris_final,
|
|
280
|
-
resource_server_url=resource_server_url_final,
|
|
281
273
|
)
|
|
282
274
|
|
|
283
275
|
logger.info(
|