fastmcp 2.13.0rc1__py3-none-any.whl → 2.13.0rc3__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/cli/cli.py +1 -0
- fastmcp/cli/install/claude_code.py +3 -3
- fastmcp/client/oauth_callback.py +6 -2
- fastmcp/client/transports.py +1 -0
- fastmcp/resources/types.py +30 -24
- fastmcp/server/auth/handlers/authorize.py +324 -0
- fastmcp/server/auth/jwt_issuer.py +39 -92
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +238 -228
- fastmcp/server/auth/oidc_proxy.py +16 -1
- fastmcp/server/auth/providers/auth0.py +30 -17
- fastmcp/server/auth/providers/aws.py +17 -2
- fastmcp/server/auth/providers/azure.py +69 -31
- fastmcp/server/auth/providers/github.py +17 -2
- fastmcp/server/auth/providers/google.py +18 -3
- fastmcp/server/auth/providers/workos.py +17 -2
- fastmcp/server/context.py +33 -2
- fastmcp/server/http.py +6 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/server.py +47 -26
- fastmcp/settings.py +2 -1
- fastmcp/tools/tool_manager.py +8 -4
- fastmcp/utilities/cli.py +62 -22
- fastmcp/utilities/ui.py +126 -6
- {fastmcp-2.13.0rc1.dist-info → fastmcp-2.13.0rc3.dist-info}/METADATA +5 -5
- {fastmcp-2.13.0rc1.dist-info → fastmcp-2.13.0rc3.dist-info}/RECORD +29 -26
- {fastmcp-2.13.0rc1.dist-info → fastmcp-2.13.0rc3.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.0rc1.dist-info → fastmcp-2.13.0rc3.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.0rc1.dist-info → fastmcp-2.13.0rc3.dist-info}/licenses/LICENSE +0 -0
|
@@ -31,9 +31,11 @@ from urllib.parse import urlencode, urlparse
|
|
|
31
31
|
import httpx
|
|
32
32
|
from authlib.common.security import generate_token
|
|
33
33
|
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
|
34
|
+
from cryptography.fernet import Fernet
|
|
34
35
|
from key_value.aio.adapters.pydantic import PydanticAdapter
|
|
35
36
|
from key_value.aio.protocols import AsyncKeyValue
|
|
36
|
-
from key_value.aio.stores.
|
|
37
|
+
from key_value.aio.stores.disk import DiskStore
|
|
38
|
+
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
|
37
39
|
from mcp.server.auth.handlers.token import TokenErrorResponse, TokenSuccessResponse
|
|
38
40
|
from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
|
|
39
41
|
from mcp.server.auth.json_response import PydanticJSONResponse
|
|
@@ -55,11 +57,14 @@ from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, SecretStr
|
|
|
55
57
|
from starlette.requests import Request
|
|
56
58
|
from starlette.responses import HTMLResponse, RedirectResponse
|
|
57
59
|
from starlette.routing import Route
|
|
60
|
+
from typing_extensions import override
|
|
58
61
|
|
|
62
|
+
from fastmcp import settings
|
|
59
63
|
from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier
|
|
64
|
+
from fastmcp.server.auth.handlers.authorize import AuthorizationHandler
|
|
60
65
|
from fastmcp.server.auth.jwt_issuer import (
|
|
61
66
|
JWTIssuer,
|
|
62
|
-
|
|
67
|
+
derive_jwt_key,
|
|
63
68
|
)
|
|
64
69
|
from fastmcp.server.auth.redirect_validation import (
|
|
65
70
|
validate_redirect_uri,
|
|
@@ -68,9 +73,10 @@ from fastmcp.utilities.logging import get_logger
|
|
|
68
73
|
from fastmcp.utilities.ui import (
|
|
69
74
|
BUTTON_STYLES,
|
|
70
75
|
DETAIL_BOX_STYLES,
|
|
76
|
+
DETAILS_STYLES,
|
|
71
77
|
INFO_BOX_STYLES,
|
|
78
|
+
REDIRECT_SECTION_STYLES,
|
|
72
79
|
TOOLTIP_STYLES,
|
|
73
|
-
create_detail_box,
|
|
74
80
|
create_logo,
|
|
75
81
|
create_page,
|
|
76
82
|
create_secure_html_response,
|
|
@@ -142,12 +148,13 @@ class UpstreamTokenSet(BaseModel):
|
|
|
142
148
|
"""Stored upstream OAuth tokens from identity provider.
|
|
143
149
|
|
|
144
150
|
These tokens are obtained from the upstream provider (Google, GitHub, etc.)
|
|
145
|
-
and
|
|
151
|
+
and stored in plaintext within this model. Encryption is handled transparently
|
|
152
|
+
at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients.
|
|
146
153
|
"""
|
|
147
154
|
|
|
148
155
|
upstream_token_id: str # Unique ID for this token set
|
|
149
|
-
access_token:
|
|
150
|
-
refresh_token:
|
|
156
|
+
access_token: str # Upstream access token
|
|
157
|
+
refresh_token: str | None # Upstream refresh token
|
|
151
158
|
refresh_token_expires_at: (
|
|
152
159
|
float | None
|
|
153
160
|
) # Unix timestamp when refresh token expires (if known)
|
|
@@ -233,16 +240,13 @@ def create_consent_html(
|
|
|
233
240
|
txn_id: str,
|
|
234
241
|
csrf_token: str,
|
|
235
242
|
client_name: str | None = None,
|
|
236
|
-
title: str = "
|
|
243
|
+
title: str = "Application Access Request",
|
|
237
244
|
server_name: str | None = None,
|
|
238
245
|
server_icon_url: str | None = None,
|
|
239
246
|
server_website_url: str | None = None,
|
|
247
|
+
client_website_url: str | None = None,
|
|
240
248
|
) -> str:
|
|
241
249
|
"""Create a styled HTML consent page for OAuth authorization requests."""
|
|
242
|
-
# Format scopes for display
|
|
243
|
-
scopes_display = ", ".join(scopes) if scopes else "None"
|
|
244
|
-
|
|
245
|
-
# Build warning box with client name if available
|
|
246
250
|
import html as html_module
|
|
247
251
|
|
|
248
252
|
client_display = html_module.escape(client_name or client_id)
|
|
@@ -251,29 +255,58 @@ def create_consent_html(
|
|
|
251
255
|
# Make server name a hyperlink if website URL is available
|
|
252
256
|
if server_website_url:
|
|
253
257
|
website_url_escaped = html_module.escape(server_website_url)
|
|
254
|
-
server_display = f'<a href="{website_url_escaped}" target="_blank" rel="noopener noreferrer">{server_name_escaped}</a>'
|
|
258
|
+
server_display = f'<a href="{website_url_escaped}" target="_blank" rel="noopener noreferrer" class="server-name-link">{server_name_escaped}</a>'
|
|
255
259
|
else:
|
|
256
260
|
server_display = server_name_escaped
|
|
257
261
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
<p>
|
|
262
|
+
# Build intro box with call-to-action
|
|
263
|
+
intro_box = f"""
|
|
264
|
+
<div class="info-box">
|
|
265
|
+
<p>The application <strong>{client_display}</strong> wants to access the MCP server <strong>{server_display}</strong>. Please ensure you recognize the callback address below.</p>
|
|
266
|
+
</div>
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
# Build redirect URI section (yellow box, centered)
|
|
270
|
+
redirect_uri_escaped = html_module.escape(redirect_uri)
|
|
271
|
+
redirect_section = f"""
|
|
272
|
+
<div class="redirect-section">
|
|
273
|
+
<span class="label">Credentials will be sent to:</span>
|
|
274
|
+
<div class="value">{redirect_uri_escaped}</div>
|
|
262
275
|
</div>
|
|
263
276
|
"""
|
|
264
277
|
|
|
265
|
-
# Build
|
|
266
|
-
detail_rows = [
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
278
|
+
# Build advanced details with collapsible section
|
|
279
|
+
detail_rows = [
|
|
280
|
+
("Application Name", html_module.escape(client_name or client_id)),
|
|
281
|
+
("Application Website", html_module.escape(client_website_url or "N/A")),
|
|
282
|
+
("Application ID", client_id),
|
|
283
|
+
("Redirect URI", redirect_uri_escaped),
|
|
284
|
+
(
|
|
285
|
+
"Requested Scopes",
|
|
286
|
+
", ".join(html_module.escape(s) for s in scopes) if scopes else "None",
|
|
287
|
+
),
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
detail_rows_html = "\n".join(
|
|
270
291
|
[
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
292
|
+
f"""
|
|
293
|
+
<div class="detail-row">
|
|
294
|
+
<div class="detail-label">{label}:</div>
|
|
295
|
+
<div class="detail-value">{value}</div>
|
|
296
|
+
</div>
|
|
297
|
+
"""
|
|
298
|
+
for label, value in detail_rows
|
|
274
299
|
]
|
|
275
300
|
)
|
|
276
|
-
|
|
301
|
+
|
|
302
|
+
advanced_details = f"""
|
|
303
|
+
<details>
|
|
304
|
+
<summary>Advanced Details</summary>
|
|
305
|
+
<div class="detail-box">
|
|
306
|
+
{detail_rows_html}
|
|
307
|
+
</div>
|
|
308
|
+
</details>
|
|
309
|
+
"""
|
|
277
310
|
|
|
278
311
|
# Build form with buttons
|
|
279
312
|
form = f"""
|
|
@@ -281,13 +314,13 @@ def create_consent_html(
|
|
|
281
314
|
<input type="hidden" name="txn_id" value="{txn_id}" />
|
|
282
315
|
<input type="hidden" name="csrf_token" value="{csrf_token}" />
|
|
283
316
|
<div class="button-group">
|
|
284
|
-
<button type="submit" name="action" value="approve" class="btn-approve">
|
|
317
|
+
<button type="submit" name="action" value="approve" class="btn-approve">Allow Access</button>
|
|
285
318
|
<button type="submit" name="action" value="deny" class="btn-deny">Deny</button>
|
|
286
319
|
</div>
|
|
287
320
|
</form>
|
|
288
321
|
"""
|
|
289
322
|
|
|
290
|
-
# Build help link with tooltip
|
|
323
|
+
# Build help link with tooltip (identical to current implementation)
|
|
291
324
|
help_link = """
|
|
292
325
|
<div class="help-link-container">
|
|
293
326
|
<span class="help-link">
|
|
@@ -312,9 +345,10 @@ def create_consent_html(
|
|
|
312
345
|
content = f"""
|
|
313
346
|
<div class="container">
|
|
314
347
|
{create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
|
|
315
|
-
<h1>
|
|
316
|
-
{
|
|
317
|
-
{
|
|
348
|
+
<h1>Application Access Request</h1>
|
|
349
|
+
{intro_box}
|
|
350
|
+
{redirect_section}
|
|
351
|
+
{advanced_details}
|
|
318
352
|
{form}
|
|
319
353
|
</div>
|
|
320
354
|
{help_link}
|
|
@@ -322,7 +356,12 @@ def create_consent_html(
|
|
|
322
356
|
|
|
323
357
|
# Additional styles needed for this page
|
|
324
358
|
additional_styles = (
|
|
325
|
-
INFO_BOX_STYLES
|
|
359
|
+
INFO_BOX_STYLES
|
|
360
|
+
+ REDIRECT_SECTION_STYLES
|
|
361
|
+
+ DETAILS_STYLES
|
|
362
|
+
+ DETAIL_BOX_STYLES
|
|
363
|
+
+ BUTTON_STYLES
|
|
364
|
+
+ TOOLTIP_STYLES
|
|
326
365
|
)
|
|
327
366
|
|
|
328
367
|
# Need to allow form-action for form submission
|
|
@@ -525,10 +564,10 @@ class OAuthProxy(OAuthProvider):
|
|
|
525
564
|
extra_token_params: dict[str, str] | None = None,
|
|
526
565
|
# Client storage
|
|
527
566
|
client_storage: AsyncKeyValue | None = None,
|
|
528
|
-
# JWT signing key
|
|
567
|
+
# JWT signing key
|
|
529
568
|
jwt_signing_key: str | bytes | None = None,
|
|
530
|
-
#
|
|
531
|
-
|
|
569
|
+
# Consent screen configuration
|
|
570
|
+
require_authorization_consent: bool = True,
|
|
532
571
|
):
|
|
533
572
|
"""Initialize the OAuth proxy provider.
|
|
534
573
|
|
|
@@ -562,14 +601,18 @@ class OAuthProxy(OAuthProvider):
|
|
|
562
601
|
Example: {"audience": "https://api.example.com"}
|
|
563
602
|
extra_token_params: Additional parameters to forward to the upstream token endpoint.
|
|
564
603
|
Useful for provider-specific parameters during token exchange.
|
|
565
|
-
client_storage:
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
604
|
+
client_storage: Storage backend for OAuth state (client registrations, tokens).
|
|
605
|
+
If None, an encrypted DiskStore will be created in the data directory.
|
|
606
|
+
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes).
|
|
607
|
+
If bytes are provided, they will be used as-is.
|
|
608
|
+
If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1.2M iterations).
|
|
609
|
+
If not provided, it will be derived from the upstream client secret using HKDF.
|
|
610
|
+
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
|
611
|
+
When True, users see a consent screen before being redirected to the upstream IdP.
|
|
612
|
+
When False, authorization proceeds directly without user confirmation.
|
|
613
|
+
SECURITY WARNING: Only disable for local development or testing environments.
|
|
572
614
|
"""
|
|
615
|
+
|
|
573
616
|
# Always enable DCR since we implement it locally for MCP clients
|
|
574
617
|
client_registration_options = ClientRegistrationOptions(
|
|
575
618
|
enabled=True,
|
|
@@ -591,12 +634,14 @@ class OAuthProxy(OAuthProvider):
|
|
|
591
634
|
)
|
|
592
635
|
|
|
593
636
|
# Store upstream configuration
|
|
594
|
-
self._upstream_authorization_endpoint = upstream_authorization_endpoint
|
|
595
|
-
self._upstream_token_endpoint = upstream_token_endpoint
|
|
596
|
-
self._upstream_client_id = upstream_client_id
|
|
597
|
-
self._upstream_client_secret = SecretStr(
|
|
598
|
-
|
|
599
|
-
|
|
637
|
+
self._upstream_authorization_endpoint: str = upstream_authorization_endpoint
|
|
638
|
+
self._upstream_token_endpoint: str = upstream_token_endpoint
|
|
639
|
+
self._upstream_client_id: str = upstream_client_id
|
|
640
|
+
self._upstream_client_secret: SecretStr = SecretStr(
|
|
641
|
+
secret_value=upstream_client_secret
|
|
642
|
+
)
|
|
643
|
+
self._upstream_revocation_endpoint: str | None = upstream_revocation_endpoint
|
|
644
|
+
self._default_scope_str: str = " ".join(self.required_scopes or [])
|
|
600
645
|
|
|
601
646
|
# Store redirect configuration
|
|
602
647
|
if not redirect_path:
|
|
@@ -605,55 +650,92 @@ class OAuthProxy(OAuthProvider):
|
|
|
605
650
|
self._redirect_path = (
|
|
606
651
|
redirect_path if redirect_path.startswith("/") else f"/{redirect_path}"
|
|
607
652
|
)
|
|
608
|
-
|
|
609
|
-
if
|
|
610
|
-
logger.info(
|
|
611
|
-
"allowed_client_redirect_uris not specified; accepting all redirect URIs. "
|
|
612
|
-
"Consent flow provides protection against confused deputy attacks. "
|
|
613
|
-
"Configure allowed patterns for defense-in-depth."
|
|
614
|
-
)
|
|
615
|
-
self._allowed_client_redirect_uris = None
|
|
616
|
-
elif (
|
|
653
|
+
|
|
654
|
+
if (
|
|
617
655
|
isinstance(allowed_client_redirect_uris, list)
|
|
618
656
|
and not allowed_client_redirect_uris
|
|
619
657
|
):
|
|
620
658
|
logger.warning(
|
|
621
659
|
"allowed_client_redirect_uris is empty list; no redirect URIs will be accepted. "
|
|
622
|
-
"This will block all OAuth clients."
|
|
660
|
+
+ "This will block all OAuth clients."
|
|
623
661
|
)
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
662
|
+
self._allowed_client_redirect_uris: list[str] | None = (
|
|
663
|
+
allowed_client_redirect_uris
|
|
664
|
+
)
|
|
627
665
|
|
|
628
666
|
# PKCE configuration
|
|
629
|
-
self._forward_pkce = forward_pkce
|
|
667
|
+
self._forward_pkce: bool = forward_pkce
|
|
630
668
|
|
|
631
669
|
# Token endpoint authentication
|
|
632
|
-
self._token_endpoint_auth_method = token_endpoint_auth_method
|
|
670
|
+
self._token_endpoint_auth_method: str | None = token_endpoint_auth_method
|
|
671
|
+
|
|
672
|
+
# Consent screen configuration
|
|
673
|
+
self._require_authorization_consent: bool = require_authorization_consent
|
|
674
|
+
if not require_authorization_consent:
|
|
675
|
+
logger.warning(
|
|
676
|
+
"Authorization consent screen disabled - only use for local development or testing. "
|
|
677
|
+
+ "In production, this screen protects against confused deputy attacks."
|
|
678
|
+
)
|
|
633
679
|
|
|
634
680
|
# Extra parameters for authorization and token endpoints
|
|
635
|
-
self._extra_authorize_params = extra_authorize_params or {}
|
|
636
|
-
self._extra_token_params = extra_token_params or {}
|
|
681
|
+
self._extra_authorize_params: dict[str, str] = extra_authorize_params or {}
|
|
682
|
+
self._extra_token_params: dict[str, str] = extra_token_params or {}
|
|
637
683
|
|
|
638
|
-
|
|
684
|
+
if jwt_signing_key is None:
|
|
685
|
+
jwt_signing_key = derive_jwt_key(
|
|
686
|
+
high_entropy_material=upstream_client_secret,
|
|
687
|
+
salt="fastmcp-jwt-signing-key",
|
|
688
|
+
)
|
|
639
689
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
690
|
+
if isinstance(jwt_signing_key, str):
|
|
691
|
+
if len(jwt_signing_key) < 12:
|
|
692
|
+
logger.warning(
|
|
693
|
+
"jwt_signing_key is less than 12 characters; it is recommended to use a longer. "
|
|
694
|
+
+ "string for the key derivation."
|
|
695
|
+
)
|
|
696
|
+
jwt_signing_key = derive_jwt_key(
|
|
697
|
+
low_entropy_material=jwt_signing_key,
|
|
698
|
+
salt="fastmcp-jwt-signing-key",
|
|
647
699
|
)
|
|
648
700
|
|
|
701
|
+
self._jwt_issuer: JWTIssuer = JWTIssuer(
|
|
702
|
+
issuer=str(self.base_url),
|
|
703
|
+
audience=f"{str(self.base_url).rstrip('/')}/mcp",
|
|
704
|
+
signing_key=jwt_signing_key,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
# If the user does not provide a store, we will provide an encrypted disk store
|
|
708
|
+
if client_storage is None:
|
|
709
|
+
storage_encryption_key = derive_jwt_key(
|
|
710
|
+
high_entropy_material=jwt_signing_key.decode(),
|
|
711
|
+
salt="fastmcp-storage-encryption-key",
|
|
712
|
+
)
|
|
713
|
+
client_storage = FernetEncryptionWrapper(
|
|
714
|
+
key_value=DiskStore(directory=settings.home / "oauth-proxy"),
|
|
715
|
+
fernet=Fernet(key=storage_encryption_key),
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
self._client_storage: AsyncKeyValue = client_storage
|
|
719
|
+
|
|
649
720
|
# Cache HTTPS check to avoid repeated logging
|
|
650
|
-
self._is_https = str(self.base_url).startswith("https://")
|
|
721
|
+
self._is_https: bool = str(self.base_url).startswith("https://")
|
|
651
722
|
if not self._is_https:
|
|
652
723
|
logger.warning(
|
|
653
724
|
"Using non-secure cookies for development; deploy with HTTPS for production."
|
|
654
725
|
)
|
|
655
726
|
|
|
656
|
-
self.
|
|
727
|
+
self._upstream_token_store: PydanticAdapter[UpstreamTokenSet] = PydanticAdapter[
|
|
728
|
+
UpstreamTokenSet
|
|
729
|
+
](
|
|
730
|
+
key_value=self._client_storage,
|
|
731
|
+
pydantic_model=UpstreamTokenSet,
|
|
732
|
+
default_collection="mcp-upstream-tokens",
|
|
733
|
+
raise_on_validation_error=True,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
self._client_store: PydanticAdapter[ProxyDCRClient] = PydanticAdapter[
|
|
737
|
+
ProxyDCRClient
|
|
738
|
+
](
|
|
657
739
|
key_value=self._client_storage,
|
|
658
740
|
pydantic_model=ProxyDCRClient,
|
|
659
741
|
default_collection="mcp-oauth-proxy-clients",
|
|
@@ -662,43 +744,32 @@ class OAuthProxy(OAuthProvider):
|
|
|
662
744
|
|
|
663
745
|
# OAuth transaction storage for IdP callback forwarding
|
|
664
746
|
# Reuse client_storage with different collections for state management
|
|
665
|
-
self._transaction_store = PydanticAdapter[
|
|
747
|
+
self._transaction_store: PydanticAdapter[OAuthTransaction] = PydanticAdapter[
|
|
748
|
+
OAuthTransaction
|
|
749
|
+
](
|
|
666
750
|
key_value=self._client_storage,
|
|
667
751
|
pydantic_model=OAuthTransaction,
|
|
668
752
|
default_collection="mcp-oauth-transactions",
|
|
669
753
|
raise_on_validation_error=True,
|
|
670
754
|
)
|
|
671
755
|
|
|
672
|
-
self._code_store = PydanticAdapter[ClientCode](
|
|
756
|
+
self._code_store: PydanticAdapter[ClientCode] = PydanticAdapter[ClientCode](
|
|
673
757
|
key_value=self._client_storage,
|
|
674
758
|
pydantic_model=ClientCode,
|
|
675
759
|
default_collection="mcp-authorization-codes",
|
|
676
760
|
raise_on_validation_error=True,
|
|
677
761
|
)
|
|
678
762
|
|
|
679
|
-
# Storage for upstream tokens (encrypted at rest)
|
|
680
|
-
self._upstream_token_store = PydanticAdapter[UpstreamTokenSet](
|
|
681
|
-
key_value=self._client_storage,
|
|
682
|
-
pydantic_model=UpstreamTokenSet,
|
|
683
|
-
default_collection="mcp-upstream-tokens",
|
|
684
|
-
raise_on_validation_error=True,
|
|
685
|
-
)
|
|
686
|
-
|
|
687
763
|
# Storage for JTI mappings (FastMCP token -> upstream token)
|
|
688
|
-
self._jti_mapping_store = PydanticAdapter[
|
|
764
|
+
self._jti_mapping_store: PydanticAdapter[JTIMapping] = PydanticAdapter[
|
|
765
|
+
JTIMapping
|
|
766
|
+
](
|
|
689
767
|
key_value=self._client_storage,
|
|
690
768
|
pydantic_model=JTIMapping,
|
|
691
769
|
default_collection="mcp-jti-mappings",
|
|
692
770
|
raise_on_validation_error=True,
|
|
693
771
|
)
|
|
694
772
|
|
|
695
|
-
# JWT issuer and encryption (initialized lazily on first use)
|
|
696
|
-
self._custom_jwt_key = jwt_signing_key
|
|
697
|
-
self._custom_encryption_key = token_encryption_key
|
|
698
|
-
self._jwt_issuer: JWTIssuer | None = None
|
|
699
|
-
self._token_encryption: TokenEncryption | None = None
|
|
700
|
-
self._jwt_initialized = False
|
|
701
|
-
|
|
702
773
|
# Local state for token bookkeeping only (no client caching)
|
|
703
774
|
self._access_tokens: dict[str, AccessToken] = {}
|
|
704
775
|
self._refresh_tokens: dict[str, RefreshToken] = {}
|
|
@@ -708,7 +779,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
708
779
|
self._refresh_to_access: dict[str, str] = {}
|
|
709
780
|
|
|
710
781
|
# Use the provided token validator
|
|
711
|
-
self._token_validator = token_verifier
|
|
782
|
+
self._token_validator: TokenVerifier = token_verifier
|
|
712
783
|
|
|
713
784
|
logger.debug(
|
|
714
785
|
"Initialized OAuth proxy provider with upstream server %s",
|
|
@@ -734,91 +805,11 @@ class OAuthProxy(OAuthProvider):
|
|
|
734
805
|
|
|
735
806
|
return code_verifier, code_challenge
|
|
736
807
|
|
|
737
|
-
# -------------------------------------------------------------------------
|
|
738
|
-
# JWT Token Factory Initialization
|
|
739
|
-
# -------------------------------------------------------------------------
|
|
740
|
-
|
|
741
|
-
async def _ensure_jwt_initialized(self) -> None:
|
|
742
|
-
"""Initialize JWT issuer and token encryption (lazy initialization).
|
|
743
|
-
|
|
744
|
-
Key derivation strategy:
|
|
745
|
-
- Default: Generate random salt at startup, derive ephemeral keys
|
|
746
|
-
→ Keys change on restart, all tokens become invalid
|
|
747
|
-
→ Perfect for development/testing where re-auth is acceptable
|
|
748
|
-
|
|
749
|
-
- Production: User provides explicit keys via parameters
|
|
750
|
-
→ Keys stable across restarts when combined with persistent storage
|
|
751
|
-
→ Tokens survive restart, seamless client reconnection
|
|
752
|
-
"""
|
|
753
|
-
if self._jwt_initialized:
|
|
754
|
-
return
|
|
755
|
-
|
|
756
|
-
# Generate random salt for this server instance (NOT persisted)
|
|
757
|
-
server_salt = secrets.token_urlsafe(32)
|
|
758
|
-
|
|
759
|
-
# Derive or use custom JWT signing key
|
|
760
|
-
from fastmcp.server.auth.jwt_issuer import derive_key_from_secret
|
|
761
|
-
|
|
762
|
-
if self._custom_jwt_key:
|
|
763
|
-
jwt_key = derive_key_from_secret(
|
|
764
|
-
secret=self._custom_jwt_key,
|
|
765
|
-
salt="fastmcp-jwt-signing-v1",
|
|
766
|
-
info=b"HS256",
|
|
767
|
-
)
|
|
768
|
-
logger.info("Using explicit JWT signing key (will survive restarts)")
|
|
769
|
-
else:
|
|
770
|
-
# Ephemeral key from random salt + upstream secret
|
|
771
|
-
upstream_secret = self._upstream_client_secret.get_secret_value()
|
|
772
|
-
jwt_key = derive_key_from_secret(
|
|
773
|
-
secret=upstream_secret,
|
|
774
|
-
salt=f"fastmcp-jwt-signing-v1-{server_salt}",
|
|
775
|
-
info=b"HS256",
|
|
776
|
-
)
|
|
777
|
-
logger.info(
|
|
778
|
-
"Using ephemeral JWT signing key - tokens will NOT survive server restart. "
|
|
779
|
-
"For production, provide explicit jwt_signing_key parameter."
|
|
780
|
-
)
|
|
781
|
-
|
|
782
|
-
# Initialize JWT issuer
|
|
783
|
-
issuer = str(self.base_url)
|
|
784
|
-
audience = f"{str(self.base_url).rstrip('/')}/mcp"
|
|
785
|
-
self._jwt_issuer = JWTIssuer(
|
|
786
|
-
issuer=issuer,
|
|
787
|
-
audience=audience,
|
|
788
|
-
signing_key=jwt_key,
|
|
789
|
-
)
|
|
790
|
-
|
|
791
|
-
# Derive or use custom encryption key
|
|
792
|
-
if self._custom_encryption_key:
|
|
793
|
-
encryption_key = derive_key_from_secret(
|
|
794
|
-
secret=self._custom_encryption_key,
|
|
795
|
-
salt="fastmcp-token-encryption-v1",
|
|
796
|
-
info=b"Fernet",
|
|
797
|
-
)
|
|
798
|
-
# Fernet needs base64url-encoded key
|
|
799
|
-
encryption_key = base64.urlsafe_b64encode(encryption_key)
|
|
800
|
-
logger.info("Using explicit token encryption key (will survive restarts)")
|
|
801
|
-
else:
|
|
802
|
-
# Ephemeral key from random salt + upstream secret
|
|
803
|
-
upstream_secret = self._upstream_client_secret.get_secret_value()
|
|
804
|
-
key_material = derive_key_from_secret(
|
|
805
|
-
secret=upstream_secret,
|
|
806
|
-
salt=f"fastmcp-token-encryption-v1-{server_salt}",
|
|
807
|
-
info=b"Fernet",
|
|
808
|
-
)
|
|
809
|
-
encryption_key = base64.urlsafe_b64encode(key_material)
|
|
810
|
-
logger.info(
|
|
811
|
-
"Using ephemeral token encryption key - encrypted tokens will NOT survive server restart. "
|
|
812
|
-
"For production, provide explicit token_encryption_key parameter."
|
|
813
|
-
)
|
|
814
|
-
|
|
815
|
-
self._token_encryption = TokenEncryption(encryption_key)
|
|
816
|
-
self._jwt_initialized = True
|
|
817
|
-
|
|
818
808
|
# -------------------------------------------------------------------------
|
|
819
809
|
# Client Registration (Local Implementation)
|
|
820
810
|
# -------------------------------------------------------------------------
|
|
821
811
|
|
|
812
|
+
@override
|
|
822
813
|
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
823
814
|
"""Get client information by ID. This is generally the random ID
|
|
824
815
|
provided to the DCR client during registration, not the upstream client ID.
|
|
@@ -834,6 +825,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
834
825
|
|
|
835
826
|
return client
|
|
836
827
|
|
|
828
|
+
@override
|
|
837
829
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
838
830
|
"""Register a client locally
|
|
839
831
|
|
|
@@ -880,6 +872,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
880
872
|
# Authorization Flow (Proxy to Upstream)
|
|
881
873
|
# -------------------------------------------------------------------------
|
|
882
874
|
|
|
875
|
+
@override
|
|
883
876
|
async def authorize(
|
|
884
877
|
self,
|
|
885
878
|
client: OAuthClientInformationFull,
|
|
@@ -891,6 +884,9 @@ class OAuthProxy(OAuthProvider):
|
|
|
891
884
|
1. Store transaction with client details and PKCE (if forwarding)
|
|
892
885
|
2. Return local /consent URL; browser visits consent first
|
|
893
886
|
3. Consent handler redirects to upstream IdP if approved/already approved
|
|
887
|
+
|
|
888
|
+
If consent is disabled (require_authorization_consent=False), skip the consent screen
|
|
889
|
+
and redirect directly to the upstream IdP.
|
|
894
890
|
"""
|
|
895
891
|
# Generate transaction ID for this authorization request
|
|
896
892
|
txn_id = secrets.token_urlsafe(32)
|
|
@@ -906,23 +902,37 @@ class OAuthProxy(OAuthProvider):
|
|
|
906
902
|
)
|
|
907
903
|
|
|
908
904
|
# Store transaction data for IdP callback processing
|
|
905
|
+
transaction = OAuthTransaction(
|
|
906
|
+
txn_id=txn_id,
|
|
907
|
+
client_id=client.client_id,
|
|
908
|
+
client_redirect_uri=str(params.redirect_uri),
|
|
909
|
+
client_state=params.state or "",
|
|
910
|
+
code_challenge=params.code_challenge,
|
|
911
|
+
code_challenge_method=getattr(params, "code_challenge_method", "S256"),
|
|
912
|
+
scopes=params.scopes or [],
|
|
913
|
+
created_at=time.time(),
|
|
914
|
+
resource=getattr(params, "resource", None),
|
|
915
|
+
proxy_code_verifier=proxy_code_verifier,
|
|
916
|
+
)
|
|
909
917
|
await self._transaction_store.put(
|
|
910
918
|
key=txn_id,
|
|
911
|
-
value=
|
|
912
|
-
txn_id=txn_id,
|
|
913
|
-
client_id=client.client_id,
|
|
914
|
-
client_redirect_uri=str(params.redirect_uri),
|
|
915
|
-
client_state=params.state or "",
|
|
916
|
-
code_challenge=params.code_challenge,
|
|
917
|
-
code_challenge_method=getattr(params, "code_challenge_method", "S256"),
|
|
918
|
-
scopes=params.scopes or [],
|
|
919
|
-
created_at=time.time(),
|
|
920
|
-
resource=getattr(params, "resource", None),
|
|
921
|
-
proxy_code_verifier=proxy_code_verifier,
|
|
922
|
-
),
|
|
919
|
+
value=transaction,
|
|
923
920
|
ttl=15 * 60, # Auto-expire after 15 minutes
|
|
924
921
|
)
|
|
925
922
|
|
|
923
|
+
# If consent is disabled, skip consent screen and go directly to upstream IdP
|
|
924
|
+
if not self._require_authorization_consent:
|
|
925
|
+
upstream_url = self._build_upstream_authorize_url(
|
|
926
|
+
txn_id, transaction.model_dump()
|
|
927
|
+
)
|
|
928
|
+
logger.debug(
|
|
929
|
+
"Starting OAuth transaction %s for client %s, redirecting directly to upstream IdP (consent disabled, PKCE forwarding: %s)",
|
|
930
|
+
txn_id,
|
|
931
|
+
client.client_id,
|
|
932
|
+
"enabled" if proxy_code_challenge else "disabled",
|
|
933
|
+
)
|
|
934
|
+
return upstream_url
|
|
935
|
+
|
|
926
936
|
consent_url = f"{str(self.base_url).rstrip('/')}/consent?txn_id={txn_id}"
|
|
927
937
|
|
|
928
938
|
logger.debug(
|
|
@@ -937,6 +947,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
937
947
|
# Authorization Code Handling
|
|
938
948
|
# -------------------------------------------------------------------------
|
|
939
949
|
|
|
950
|
+
@override
|
|
940
951
|
async def load_authorization_code(
|
|
941
952
|
self,
|
|
942
953
|
client: OAuthClientInformationFull,
|
|
@@ -956,7 +967,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
956
967
|
# Check if code expired
|
|
957
968
|
if time.time() > code_model.expires_at:
|
|
958
969
|
logger.debug("Authorization code expired: %s", authorization_code)
|
|
959
|
-
await self._code_store.delete(key=authorization_code)
|
|
970
|
+
_ = await self._code_store.delete(key=authorization_code)
|
|
960
971
|
return None
|
|
961
972
|
|
|
962
973
|
# Verify client ID matches
|
|
@@ -972,13 +983,14 @@ class OAuthProxy(OAuthProvider):
|
|
|
972
983
|
return AuthorizationCode(
|
|
973
984
|
code=authorization_code,
|
|
974
985
|
client_id=client.client_id,
|
|
975
|
-
redirect_uri=code_model.redirect_uri,
|
|
986
|
+
redirect_uri=AnyUrl(url=code_model.redirect_uri),
|
|
976
987
|
redirect_uri_provided_explicitly=True,
|
|
977
988
|
scopes=code_model.scopes,
|
|
978
989
|
expires_at=code_model.expires_at,
|
|
979
990
|
code_challenge=code_model.code_challenge or "",
|
|
980
991
|
)
|
|
981
992
|
|
|
993
|
+
@override
|
|
982
994
|
async def exchange_authorization_code(
|
|
983
995
|
self,
|
|
984
996
|
client: OAuthClientInformationFull,
|
|
@@ -995,11 +1007,6 @@ class OAuthProxy(OAuthProvider):
|
|
|
995
1007
|
|
|
996
1008
|
PKCE validation is handled by the MCP framework before this method is called.
|
|
997
1009
|
"""
|
|
998
|
-
# Ensure JWT issuer is initialized
|
|
999
|
-
await self._ensure_jwt_initialized()
|
|
1000
|
-
assert self._jwt_issuer is not None
|
|
1001
|
-
assert self._token_encryption is not None
|
|
1002
|
-
|
|
1003
1010
|
# Look up stored code data
|
|
1004
1011
|
code_model = await self._code_store.get(key=authorization_code.code)
|
|
1005
1012
|
if not code_model:
|
|
@@ -1050,8 +1057,8 @@ class OAuthProxy(OAuthProvider):
|
|
|
1050
1057
|
# Encrypt and store upstream tokens
|
|
1051
1058
|
upstream_token_set = UpstreamTokenSet(
|
|
1052
1059
|
upstream_token_id=upstream_token_id,
|
|
1053
|
-
access_token=
|
|
1054
|
-
refresh_token=
|
|
1060
|
+
access_token=idp_tokens["access_token"],
|
|
1061
|
+
refresh_token=idp_tokens["refresh_token"]
|
|
1055
1062
|
if idp_tokens.get("refresh_token")
|
|
1056
1063
|
else None,
|
|
1057
1064
|
refresh_token_expires_at=refresh_token_expires_at,
|
|
@@ -1173,11 +1180,6 @@ class OAuthProxy(OAuthProvider):
|
|
|
1173
1180
|
5. Issue new FastMCP access token
|
|
1174
1181
|
6. Keep same FastMCP refresh token (unless upstream rotates)
|
|
1175
1182
|
"""
|
|
1176
|
-
# Ensure JWT issuer is initialized
|
|
1177
|
-
await self._ensure_jwt_initialized()
|
|
1178
|
-
assert self._jwt_issuer is not None
|
|
1179
|
-
assert self._token_encryption is not None
|
|
1180
|
-
|
|
1181
1183
|
# Verify FastMCP refresh token
|
|
1182
1184
|
try:
|
|
1183
1185
|
refresh_payload = self._jwt_issuer.verify_token(refresh_token.token)
|
|
@@ -1206,10 +1208,6 @@ class OAuthProxy(OAuthProvider):
|
|
|
1206
1208
|
logger.error("No upstream refresh token available")
|
|
1207
1209
|
raise TokenError("invalid_grant", "Refresh not supported for this token")
|
|
1208
1210
|
|
|
1209
|
-
upstream_refresh_token = self._token_encryption.decrypt(
|
|
1210
|
-
upstream_token_set.refresh_token
|
|
1211
|
-
)
|
|
1212
|
-
|
|
1213
1211
|
# Refresh upstream token using authlib
|
|
1214
1212
|
oauth_client = AsyncOAuth2Client(
|
|
1215
1213
|
client_id=self._upstream_client_id,
|
|
@@ -1222,7 +1220,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
1222
1220
|
logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8])
|
|
1223
1221
|
token_response: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc]
|
|
1224
1222
|
url=self._upstream_token_endpoint,
|
|
1225
|
-
refresh_token=
|
|
1223
|
+
refresh_token=upstream_token_set.refresh_token,
|
|
1226
1224
|
scope=" ".join(scopes) if scopes else None,
|
|
1227
1225
|
)
|
|
1228
1226
|
logger.debug("Successfully refreshed upstream token")
|
|
@@ -1234,18 +1232,14 @@ class OAuthProxy(OAuthProvider):
|
|
|
1234
1232
|
new_expires_in = int(
|
|
1235
1233
|
token_response.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
|
1236
1234
|
)
|
|
1237
|
-
upstream_token_set.access_token =
|
|
1238
|
-
token_response["access_token"]
|
|
1239
|
-
)
|
|
1235
|
+
upstream_token_set.access_token = token_response["access_token"]
|
|
1240
1236
|
upstream_token_set.expires_at = time.time() + new_expires_in
|
|
1241
1237
|
|
|
1242
1238
|
# Handle upstream refresh token rotation and expiry
|
|
1243
1239
|
new_refresh_expires_in = None
|
|
1244
1240
|
if new_upstream_refresh := token_response.get("refresh_token"):
|
|
1245
|
-
if new_upstream_refresh !=
|
|
1246
|
-
upstream_token_set.refresh_token =
|
|
1247
|
-
new_upstream_refresh
|
|
1248
|
-
)
|
|
1241
|
+
if new_upstream_refresh != upstream_token_set.refresh_token:
|
|
1242
|
+
upstream_token_set.refresh_token = new_upstream_refresh
|
|
1249
1243
|
logger.debug("Upstream refresh token rotated")
|
|
1250
1244
|
|
|
1251
1245
|
# Update refresh token expiry if provided
|
|
@@ -1384,11 +1378,6 @@ class OAuthProxy(OAuthProvider):
|
|
|
1384
1378
|
The FastMCP JWT is a reference token - all authorization data comes
|
|
1385
1379
|
from validating the upstream token via the TokenVerifier.
|
|
1386
1380
|
"""
|
|
1387
|
-
# Ensure JWT issuer and encryption are initialized
|
|
1388
|
-
await self._ensure_jwt_initialized()
|
|
1389
|
-
assert self._jwt_issuer is not None
|
|
1390
|
-
assert self._token_encryption is not None
|
|
1391
|
-
|
|
1392
1381
|
try:
|
|
1393
1382
|
# 1. Verify FastMCP JWT signature and claims
|
|
1394
1383
|
payload = self._jwt_issuer.verify_token(token)
|
|
@@ -1409,15 +1398,12 @@ class OAuthProxy(OAuthProvider):
|
|
|
1409
1398
|
)
|
|
1410
1399
|
return None
|
|
1411
1400
|
|
|
1412
|
-
# 3.
|
|
1413
|
-
|
|
1401
|
+
# 3. Validate with upstream provider (delegated to TokenVerifier)
|
|
1402
|
+
# This calls the real token validator (GitHub API, JWKS, etc.)
|
|
1403
|
+
validated = await self._token_validator.verify_token(
|
|
1414
1404
|
upstream_token_set.access_token
|
|
1415
1405
|
)
|
|
1416
1406
|
|
|
1417
|
-
# 4. Validate with upstream provider (delegated to TokenVerifier)
|
|
1418
|
-
# This calls the real token validator (GitHub API, JWKS, etc.)
|
|
1419
|
-
validated = await self._token_validator.verify_token(upstream_token)
|
|
1420
|
-
|
|
1421
1407
|
if not validated:
|
|
1422
1408
|
logger.debug("Upstream token validation failed")
|
|
1423
1409
|
return None
|
|
@@ -1483,10 +1469,11 @@ class OAuthProxy(OAuthProvider):
|
|
|
1483
1469
|
self,
|
|
1484
1470
|
mcp_path: str | None = None,
|
|
1485
1471
|
) -> list[Route]:
|
|
1486
|
-
"""Get OAuth routes with custom
|
|
1472
|
+
"""Get OAuth routes with custom handlers for better error UX.
|
|
1487
1473
|
|
|
1488
|
-
This method creates standard OAuth routes and replaces
|
|
1489
|
-
|
|
1474
|
+
This method creates standard OAuth routes and replaces:
|
|
1475
|
+
- /authorize endpoint: Enhanced error responses for unregistered clients
|
|
1476
|
+
- /token endpoint: OAuth 2.1 compliant error codes
|
|
1490
1477
|
|
|
1491
1478
|
Args:
|
|
1492
1479
|
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
@@ -1496,6 +1483,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
1496
1483
|
routes = super().get_routes(mcp_path)
|
|
1497
1484
|
custom_routes = []
|
|
1498
1485
|
token_route_found = False
|
|
1486
|
+
authorize_route_found = False
|
|
1499
1487
|
|
|
1500
1488
|
logger.debug(
|
|
1501
1489
|
f"get_routes called - configuring OAuth routes in {len(routes)} routes"
|
|
@@ -1506,8 +1494,30 @@ class OAuthProxy(OAuthProvider):
|
|
|
1506
1494
|
f"Route {i}: {route} - path: {getattr(route, 'path', 'N/A')}, methods: {getattr(route, 'methods', 'N/A')}"
|
|
1507
1495
|
)
|
|
1508
1496
|
|
|
1509
|
-
# Replace the
|
|
1497
|
+
# Replace the authorize endpoint with our enhanced handler for better error UX
|
|
1510
1498
|
if (
|
|
1499
|
+
isinstance(route, Route)
|
|
1500
|
+
and route.path == "/authorize"
|
|
1501
|
+
and route.methods is not None
|
|
1502
|
+
and ("GET" in route.methods or "POST" in route.methods)
|
|
1503
|
+
):
|
|
1504
|
+
authorize_route_found = True
|
|
1505
|
+
# Replace with our enhanced authorization handler
|
|
1506
|
+
authorize_handler = AuthorizationHandler(
|
|
1507
|
+
provider=self,
|
|
1508
|
+
base_url=self.base_url,
|
|
1509
|
+
server_name=None, # Could be extended to pass server metadata
|
|
1510
|
+
server_icon_url=None,
|
|
1511
|
+
)
|
|
1512
|
+
custom_routes.append(
|
|
1513
|
+
Route(
|
|
1514
|
+
path="/authorize",
|
|
1515
|
+
endpoint=authorize_handler.handle,
|
|
1516
|
+
methods=["GET", "POST"],
|
|
1517
|
+
)
|
|
1518
|
+
)
|
|
1519
|
+
# Replace the token endpoint with our custom handler that returns proper OAuth 2.1 error codes
|
|
1520
|
+
elif (
|
|
1511
1521
|
isinstance(route, Route)
|
|
1512
1522
|
and route.path == "/token"
|
|
1513
1523
|
and route.methods is not None
|
|
@@ -1551,7 +1561,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
1551
1561
|
)
|
|
1552
1562
|
|
|
1553
1563
|
logger.debug(
|
|
1554
|
-
f"✅ OAuth routes configured: token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback + consent)"
|
|
1564
|
+
f"✅ OAuth routes configured: authorize_endpoint={authorize_route_found}, token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback + consent)"
|
|
1555
1565
|
)
|
|
1556
1566
|
return custom_routes
|
|
1557
1567
|
|