fastmcp 2.13.0.2__py3-none-any.whl → 2.13.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/cli/cli.py +3 -4
- fastmcp/cli/install/cursor.py +12 -6
- fastmcp/client/auth/oauth.py +11 -6
- fastmcp/client/client.py +86 -20
- fastmcp/client/transports.py +4 -4
- fastmcp/experimental/utilities/openapi/director.py +13 -14
- fastmcp/experimental/utilities/openapi/parser.py +18 -15
- fastmcp/mcp_config.py +1 -1
- fastmcp/resources/resource_manager.py +3 -3
- fastmcp/server/auth/__init__.py +4 -0
- fastmcp/server/auth/auth.py +28 -9
- fastmcp/server/auth/handlers/authorize.py +7 -5
- fastmcp/server/auth/oauth_proxy.py +170 -30
- fastmcp/server/auth/oidc_proxy.py +28 -9
- fastmcp/server/auth/providers/azure.py +26 -5
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +1 -1
- fastmcp/server/auth/providers/in_memory.py +25 -1
- fastmcp/server/auth/providers/jwt.py +38 -26
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/supabase.py +21 -5
- fastmcp/server/auth/providers/workos.py +1 -1
- fastmcp/server/context.py +50 -8
- fastmcp/server/dependencies.py +8 -2
- fastmcp/server/middleware/caching.py +9 -2
- fastmcp/server/middleware/logging.py +2 -2
- fastmcp/server/middleware/middleware.py +2 -2
- fastmcp/server/proxy.py +1 -1
- fastmcp/server/server.py +11 -5
- fastmcp/tools/tool.py +33 -8
- fastmcp/utilities/components.py +2 -2
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/logging.py +13 -9
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
- fastmcp/utilities/openapi.py +2 -2
- fastmcp/utilities/types.py +28 -15
- fastmcp/utilities/ui.py +1 -1
- {fastmcp-2.13.0.2.dist-info → fastmcp-2.13.1.dist-info}/METADATA +12 -9
- {fastmcp-2.13.0.2.dist-info → fastmcp-2.13.1.dist-info}/RECORD +42 -40
- {fastmcp-2.13.0.2.dist-info → fastmcp-2.13.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.0.2.dist-info → fastmcp-2.13.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.0.2.dist-info → fastmcp-2.13.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -13,6 +13,7 @@ The enhancement adds:
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import json
|
|
16
17
|
from typing import TYPE_CHECKING
|
|
17
18
|
|
|
18
19
|
from mcp.server.auth.handlers.authorize import (
|
|
@@ -211,12 +212,15 @@ class AuthorizationHandler(SDKAuthorizationHandler):
|
|
|
211
212
|
# Check if this is a client not found error
|
|
212
213
|
if response.status_code == 400:
|
|
213
214
|
# Try to extract client_id from request for enhanced error
|
|
214
|
-
client_id = None
|
|
215
|
+
client_id: str | None = None
|
|
215
216
|
if request.method == "GET":
|
|
216
217
|
client_id = request.query_params.get("client_id")
|
|
217
218
|
else:
|
|
218
219
|
form = await request.form()
|
|
219
|
-
|
|
220
|
+
client_id_value = form.get("client_id")
|
|
221
|
+
# Ensure client_id is a string, not UploadFile
|
|
222
|
+
if isinstance(client_id_value, str):
|
|
223
|
+
client_id = client_id_value
|
|
220
224
|
|
|
221
225
|
# If we have a client_id and the error is about it not being found,
|
|
222
226
|
# enhance the response
|
|
@@ -224,9 +228,7 @@ class AuthorizationHandler(SDKAuthorizationHandler):
|
|
|
224
228
|
try:
|
|
225
229
|
# Check if response body contains "not found" error
|
|
226
230
|
if hasattr(response, "body"):
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
body = json.loads(response.body)
|
|
231
|
+
body = json.loads(bytes(response.body))
|
|
230
232
|
if (
|
|
231
233
|
body.get("error") == "invalid_request"
|
|
232
234
|
and "not found" in body.get("error_description", "").lower()
|
|
@@ -44,6 +44,7 @@ from mcp.server.auth.provider import (
|
|
|
44
44
|
AccessToken,
|
|
45
45
|
AuthorizationCode,
|
|
46
46
|
AuthorizationParams,
|
|
47
|
+
AuthorizeError,
|
|
47
48
|
RefreshToken,
|
|
48
49
|
TokenError,
|
|
49
50
|
)
|
|
@@ -309,10 +310,13 @@ def create_consent_html(
|
|
|
309
310
|
"""
|
|
310
311
|
|
|
311
312
|
# Build form with buttons
|
|
313
|
+
# Use empty action to submit to current URL (/consent or /mcp/consent)
|
|
314
|
+
# The POST handler is registered at the same path as GET
|
|
312
315
|
form = f"""
|
|
313
|
-
<form id="consentForm" method="POST" action="
|
|
316
|
+
<form id="consentForm" method="POST" action="">
|
|
314
317
|
<input type="hidden" name="txn_id" value="{txn_id}" />
|
|
315
318
|
<input type="hidden" name="csrf_token" value="{csrf_token}" />
|
|
319
|
+
<input type="hidden" name="submit" value="true" />
|
|
316
320
|
<div class="button-group">
|
|
317
321
|
<button type="submit" name="action" value="approve" class="btn-approve">Allow Access</button>
|
|
318
322
|
<button type="submit" name="action" value="deny" class="btn-deny">Deny</button>
|
|
@@ -365,7 +369,19 @@ def create_consent_html(
|
|
|
365
369
|
)
|
|
366
370
|
|
|
367
371
|
# Need to allow form-action for form submission
|
|
368
|
-
|
|
372
|
+
# Chrome requires explicit scheme declarations in CSP form-action when redirect chains
|
|
373
|
+
# end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme.
|
|
374
|
+
parsed_redirect = urlparse(redirect_uri)
|
|
375
|
+
redirect_scheme = parsed_redirect.scheme.lower()
|
|
376
|
+
|
|
377
|
+
# Build form-action directive with standard schemes plus custom protocol if present
|
|
378
|
+
form_action_schemes = ["https:", "http:"]
|
|
379
|
+
if redirect_scheme and redirect_scheme not in ("http", "https"):
|
|
380
|
+
# Custom protocol scheme (e.g., cursor:, vscode:, etc.)
|
|
381
|
+
form_action_schemes.append(f"{redirect_scheme}:")
|
|
382
|
+
|
|
383
|
+
form_action_directive = " ".join(form_action_schemes)
|
|
384
|
+
csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}"
|
|
369
385
|
|
|
370
386
|
return create_page(
|
|
371
387
|
content=content,
|
|
@@ -375,6 +391,94 @@ def create_consent_html(
|
|
|
375
391
|
)
|
|
376
392
|
|
|
377
393
|
|
|
394
|
+
def create_error_html(
|
|
395
|
+
error_title: str,
|
|
396
|
+
error_message: str,
|
|
397
|
+
error_details: dict[str, str] | None = None,
|
|
398
|
+
server_name: str | None = None,
|
|
399
|
+
server_icon_url: str | None = None,
|
|
400
|
+
) -> str:
|
|
401
|
+
"""Create a styled HTML error page for OAuth errors.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
error_title: The error title (e.g., "OAuth Error", "Authorization Failed")
|
|
405
|
+
error_message: The main error message to display
|
|
406
|
+
error_details: Optional dictionary of error details to show (e.g., {"Error Code": "invalid_client"})
|
|
407
|
+
server_name: Optional server name to display
|
|
408
|
+
server_icon_url: Optional URL to server icon/logo
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Complete HTML page as a string
|
|
412
|
+
"""
|
|
413
|
+
import html as html_module
|
|
414
|
+
|
|
415
|
+
error_message_escaped = html_module.escape(error_message)
|
|
416
|
+
|
|
417
|
+
# Build error message box
|
|
418
|
+
error_box = f"""
|
|
419
|
+
<div class="info-box error">
|
|
420
|
+
<p>{error_message_escaped}</p>
|
|
421
|
+
</div>
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
# Build error details section if provided
|
|
425
|
+
details_section = ""
|
|
426
|
+
if error_details:
|
|
427
|
+
detail_rows_html = "\n".join(
|
|
428
|
+
[
|
|
429
|
+
f"""
|
|
430
|
+
<div class="detail-row">
|
|
431
|
+
<div class="detail-label">{html_module.escape(label)}:</div>
|
|
432
|
+
<div class="detail-value">{html_module.escape(value)}</div>
|
|
433
|
+
</div>
|
|
434
|
+
"""
|
|
435
|
+
for label, value in error_details.items()
|
|
436
|
+
]
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
details_section = f"""
|
|
440
|
+
<details>
|
|
441
|
+
<summary>Error Details</summary>
|
|
442
|
+
<div class="detail-box">
|
|
443
|
+
{detail_rows_html}
|
|
444
|
+
</div>
|
|
445
|
+
</details>
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
# Build the page content
|
|
449
|
+
content = f"""
|
|
450
|
+
<div class="container">
|
|
451
|
+
{create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
|
|
452
|
+
<h1>{html_module.escape(error_title)}</h1>
|
|
453
|
+
{error_box}
|
|
454
|
+
{details_section}
|
|
455
|
+
</div>
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
# Additional styles needed for this page
|
|
459
|
+
# Override .info-box.error to use normal text color instead of red
|
|
460
|
+
additional_styles = (
|
|
461
|
+
INFO_BOX_STYLES
|
|
462
|
+
+ DETAILS_STYLES
|
|
463
|
+
+ DETAIL_BOX_STYLES
|
|
464
|
+
+ """
|
|
465
|
+
.info-box.error {
|
|
466
|
+
color: #111827;
|
|
467
|
+
}
|
|
468
|
+
"""
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Simple CSP policy for error pages (no forms needed)
|
|
472
|
+
csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'"
|
|
473
|
+
|
|
474
|
+
return create_page(
|
|
475
|
+
content=content,
|
|
476
|
+
title=error_title,
|
|
477
|
+
additional_styles=additional_styles,
|
|
478
|
+
csp_policy=csp_policy,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
378
482
|
# -------------------------------------------------------------------------
|
|
379
483
|
# Handler Classes
|
|
380
484
|
# -------------------------------------------------------------------------
|
|
@@ -836,6 +940,8 @@ class OAuthProxy(OAuthProvider):
|
|
|
836
940
|
"""
|
|
837
941
|
|
|
838
942
|
# Create a ProxyDCRClient with configured redirect URI validation
|
|
943
|
+
if client_info.client_id is None:
|
|
944
|
+
raise ValueError("client_id is required for client registration")
|
|
839
945
|
proxy_client: ProxyDCRClient = ProxyDCRClient(
|
|
840
946
|
client_id=client_info.client_id,
|
|
841
947
|
client_secret=client_info.client_secret,
|
|
@@ -865,7 +971,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
865
971
|
logger.debug(
|
|
866
972
|
"Registered client %s with %d redirect URIs",
|
|
867
973
|
client_info.client_id,
|
|
868
|
-
len(proxy_client.redirect_uris),
|
|
974
|
+
len(proxy_client.redirect_uris) if proxy_client.redirect_uris else 0,
|
|
869
975
|
)
|
|
870
976
|
|
|
871
977
|
# -------------------------------------------------------------------------
|
|
@@ -902,6 +1008,10 @@ class OAuthProxy(OAuthProvider):
|
|
|
902
1008
|
)
|
|
903
1009
|
|
|
904
1010
|
# Store transaction data for IdP callback processing
|
|
1011
|
+
if client.client_id is None:
|
|
1012
|
+
raise AuthorizeError(
|
|
1013
|
+
error="invalid_client", error_description="Client ID is required"
|
|
1014
|
+
)
|
|
905
1015
|
transaction = OAuthTransaction(
|
|
906
1016
|
txn_id=txn_id,
|
|
907
1017
|
client_id=client.client_id,
|
|
@@ -980,6 +1090,10 @@ class OAuthProxy(OAuthProvider):
|
|
|
980
1090
|
return None
|
|
981
1091
|
|
|
982
1092
|
# Create authorization code object with PKCE challenge
|
|
1093
|
+
if client.client_id is None:
|
|
1094
|
+
raise AuthorizeError(
|
|
1095
|
+
error="invalid_client", error_description="Client ID is required"
|
|
1096
|
+
)
|
|
983
1097
|
return AuthorizationCode(
|
|
984
1098
|
code=authorization_code,
|
|
985
1099
|
client_id=client.client_id,
|
|
@@ -1065,18 +1179,21 @@ class OAuthProxy(OAuthProvider):
|
|
|
1065
1179
|
expires_at=time.time() + expires_in,
|
|
1066
1180
|
token_type=idp_tokens.get("token_type", "Bearer"),
|
|
1067
1181
|
scope=" ".join(authorization_code.scopes),
|
|
1068
|
-
client_id=client.client_id,
|
|
1182
|
+
client_id=client.client_id or "",
|
|
1069
1183
|
created_at=time.time(),
|
|
1070
1184
|
raw_token_data=idp_tokens,
|
|
1071
1185
|
)
|
|
1072
1186
|
await self._upstream_token_store.put(
|
|
1073
1187
|
key=upstream_token_id,
|
|
1074
1188
|
value=upstream_token_set,
|
|
1075
|
-
ttl=
|
|
1189
|
+
ttl=refresh_expires_in
|
|
1190
|
+
or expires_in, # Auto-expire when refresh token, or access token expires
|
|
1076
1191
|
)
|
|
1077
1192
|
logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8])
|
|
1078
1193
|
|
|
1079
1194
|
# Issue minimal FastMCP access token (just a reference via JTI)
|
|
1195
|
+
if client.client_id is None:
|
|
1196
|
+
raise TokenError("invalid_client", "Client ID is required")
|
|
1080
1197
|
fastmcp_access_token = self._jwt_issuer.issue_access_token(
|
|
1081
1198
|
client_id=client.client_id,
|
|
1082
1199
|
scopes=authorization_code.scopes,
|
|
@@ -1222,6 +1339,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
1222
1339
|
url=self._upstream_token_endpoint,
|
|
1223
1340
|
refresh_token=upstream_token_set.refresh_token,
|
|
1224
1341
|
scope=" ".join(scopes) if scopes else None,
|
|
1342
|
+
**self._extra_token_params,
|
|
1225
1343
|
)
|
|
1226
1344
|
logger.debug("Successfully refreshed upstream token")
|
|
1227
1345
|
except Exception as e:
|
|
@@ -1268,10 +1386,17 @@ class OAuthProxy(OAuthProvider):
|
|
|
1268
1386
|
await self._upstream_token_store.put(
|
|
1269
1387
|
key=upstream_token_set.upstream_token_id,
|
|
1270
1388
|
value=upstream_token_set,
|
|
1271
|
-
ttl=
|
|
1389
|
+
ttl=new_refresh_expires_in
|
|
1390
|
+
or (
|
|
1391
|
+
int(upstream_token_set.refresh_token_expires_at - time.time())
|
|
1392
|
+
if upstream_token_set.refresh_token_expires_at
|
|
1393
|
+
else 60 * 60 * 24 * 30 # Default to 30 days if unknown
|
|
1394
|
+
), # Auto-expire when refresh token expires
|
|
1272
1395
|
)
|
|
1273
1396
|
|
|
1274
1397
|
# Issue new minimal FastMCP access token (just a reference via JTI)
|
|
1398
|
+
if client.client_id is None:
|
|
1399
|
+
raise TokenError("invalid_client", "Client ID is required")
|
|
1275
1400
|
new_access_jti = secrets.token_urlsafe(32)
|
|
1276
1401
|
new_fastmcp_access = self._jwt_issuer.issue_access_token(
|
|
1277
1402
|
client_id=client.client_id,
|
|
@@ -1503,9 +1628,10 @@ class OAuthProxy(OAuthProvider):
|
|
|
1503
1628
|
):
|
|
1504
1629
|
authorize_route_found = True
|
|
1505
1630
|
# Replace with our enhanced authorization handler
|
|
1631
|
+
# Note: self.base_url is guaranteed to be set in parent __init__
|
|
1506
1632
|
authorize_handler = AuthorizationHandler(
|
|
1507
1633
|
provider=self,
|
|
1508
|
-
base_url=self.base_url,
|
|
1634
|
+
base_url=self.base_url, # ty: ignore[invalid-argument-type]
|
|
1509
1635
|
server_name=None, # Could be extended to pass server metadata
|
|
1510
1636
|
server_icon_url=None,
|
|
1511
1637
|
)
|
|
@@ -1551,12 +1677,10 @@ class OAuthProxy(OAuthProvider):
|
|
|
1551
1677
|
)
|
|
1552
1678
|
|
|
1553
1679
|
# Add consent endpoints
|
|
1554
|
-
|
|
1555
|
-
Route(path="/consent", endpoint=self._show_consent_page, methods=["GET"])
|
|
1556
|
-
)
|
|
1680
|
+
# Handle both GET (show page) and POST (submit) at /consent
|
|
1557
1681
|
custom_routes.append(
|
|
1558
1682
|
Route(
|
|
1559
|
-
path="/consent
|
|
1683
|
+
path="/consent", endpoint=self._handle_consent, methods=["GET", "POST"]
|
|
1560
1684
|
)
|
|
1561
1685
|
)
|
|
1562
1686
|
|
|
@@ -1569,7 +1693,9 @@ class OAuthProxy(OAuthProvider):
|
|
|
1569
1693
|
# IdP Callback Forwarding
|
|
1570
1694
|
# -------------------------------------------------------------------------
|
|
1571
1695
|
|
|
1572
|
-
async def _handle_idp_callback(
|
|
1696
|
+
async def _handle_idp_callback(
|
|
1697
|
+
self, request: Request
|
|
1698
|
+
) -> HTMLResponse | RedirectResponse:
|
|
1573
1699
|
"""Handle callback from upstream IdP and forward to client.
|
|
1574
1700
|
|
|
1575
1701
|
This implements the DCR-compliant callback forwarding:
|
|
@@ -1584,32 +1710,37 @@ class OAuthProxy(OAuthProvider):
|
|
|
1584
1710
|
error = request.query_params.get("error")
|
|
1585
1711
|
|
|
1586
1712
|
if error:
|
|
1713
|
+
error_description = request.query_params.get("error_description")
|
|
1587
1714
|
logger.error(
|
|
1588
1715
|
"IdP callback error: %s - %s",
|
|
1589
1716
|
error,
|
|
1590
|
-
|
|
1717
|
+
error_description,
|
|
1591
1718
|
)
|
|
1592
|
-
#
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1719
|
+
# Show error page to user
|
|
1720
|
+
html_content = create_error_html(
|
|
1721
|
+
error_title="OAuth Error",
|
|
1722
|
+
error_message=f"Authentication failed: {error_description or 'Unknown error'}",
|
|
1723
|
+
error_details={"Error Code": error} if error else None,
|
|
1596
1724
|
)
|
|
1725
|
+
return HTMLResponse(content=html_content, status_code=400)
|
|
1597
1726
|
|
|
1598
1727
|
if not idp_code or not txn_id:
|
|
1599
1728
|
logger.error("IdP callback missing code or transaction ID")
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1729
|
+
html_content = create_error_html(
|
|
1730
|
+
error_title="OAuth Error",
|
|
1731
|
+
error_message="Missing authorization code or transaction ID from the identity provider.",
|
|
1603
1732
|
)
|
|
1733
|
+
return HTMLResponse(content=html_content, status_code=400)
|
|
1604
1734
|
|
|
1605
1735
|
# Look up transaction data
|
|
1606
1736
|
transaction_model = await self._transaction_store.get(key=txn_id)
|
|
1607
1737
|
if not transaction_model:
|
|
1608
1738
|
logger.error("IdP callback with invalid transaction ID: %s", txn_id)
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1739
|
+
html_content = create_error_html(
|
|
1740
|
+
error_title="OAuth Error",
|
|
1741
|
+
error_message="Invalid or expired authorization transaction. Please try authenticating again.",
|
|
1612
1742
|
)
|
|
1743
|
+
return HTMLResponse(content=html_content, status_code=400)
|
|
1613
1744
|
transaction = transaction_model.model_dump()
|
|
1614
1745
|
|
|
1615
1746
|
# Exchange IdP code for tokens (server-side)
|
|
@@ -1663,11 +1794,11 @@ class OAuthProxy(OAuthProvider):
|
|
|
1663
1794
|
|
|
1664
1795
|
except Exception as e:
|
|
1665
1796
|
logger.error("IdP token exchange failed: %s", e)
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
status_code=302,
|
|
1797
|
+
html_content = create_error_html(
|
|
1798
|
+
error_title="OAuth Error",
|
|
1799
|
+
error_message=f"Token exchange with identity provider failed: {e}",
|
|
1670
1800
|
)
|
|
1801
|
+
return HTMLResponse(content=html_content, status_code=500)
|
|
1671
1802
|
|
|
1672
1803
|
# Generate our own authorization code for the client
|
|
1673
1804
|
client_code = secrets.token_urlsafe(32)
|
|
@@ -1714,10 +1845,11 @@ class OAuthProxy(OAuthProvider):
|
|
|
1714
1845
|
|
|
1715
1846
|
except Exception as e:
|
|
1716
1847
|
logger.error("Error in IdP callback handler: %s", e, exc_info=True)
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1848
|
+
html_content = create_error_html(
|
|
1849
|
+
error_title="OAuth Error",
|
|
1850
|
+
error_message="Internal server error during OAuth callback processing. Please try again.",
|
|
1720
1851
|
)
|
|
1852
|
+
return HTMLResponse(content=html_content, status_code=500)
|
|
1721
1853
|
|
|
1722
1854
|
# -------------------------------------------------------------------------
|
|
1723
1855
|
# Consent Interstitial
|
|
@@ -1863,6 +1995,14 @@ class OAuthProxy(OAuthProvider):
|
|
|
1863
1995
|
separator = "&" if "?" in self._upstream_authorization_endpoint else "?"
|
|
1864
1996
|
return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}"
|
|
1865
1997
|
|
|
1998
|
+
async def _handle_consent(
|
|
1999
|
+
self, request: Request
|
|
2000
|
+
) -> HTMLResponse | RedirectResponse:
|
|
2001
|
+
"""Handle consent page - dispatch to GET or POST handler based on method."""
|
|
2002
|
+
if request.method == "POST":
|
|
2003
|
+
return await self._submit_consent(request)
|
|
2004
|
+
return await self._show_consent_page(request)
|
|
2005
|
+
|
|
1866
2006
|
async def _show_consent_page(
|
|
1867
2007
|
self, request: Request
|
|
1868
2008
|
) -> HTMLResponse | RedirectResponse:
|
|
@@ -206,6 +206,7 @@ class OIDCProxy(OAuthProxy):
|
|
|
206
206
|
audience: str | None = None,
|
|
207
207
|
timeout_seconds: int | None = None,
|
|
208
208
|
# Token verifier
|
|
209
|
+
token_verifier: TokenVerifier | None = None,
|
|
209
210
|
algorithm: str | None = None,
|
|
210
211
|
required_scopes: list[str] | None = None,
|
|
211
212
|
# FastMCP server configuration
|
|
@@ -231,8 +232,11 @@ class OIDCProxy(OAuthProxy):
|
|
|
231
232
|
client_secret: Client secret for upstream server
|
|
232
233
|
audience: Audience for upstream server
|
|
233
234
|
timeout_seconds: HTTP request timeout in seconds
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
token_verifier: Optional custom token verifier (e.g., IntrospectionTokenVerifier for opaque tokens).
|
|
236
|
+
If not provided, a JWTVerifier will be created using the OIDC configuration.
|
|
237
|
+
Cannot be used with algorithm or required_scopes parameters (configure these on your verifier instead).
|
|
238
|
+
algorithm: Token verifier algorithm (only used if token_verifier is not provided)
|
|
239
|
+
required_scopes: Required scopes for token validation (only used if token_verifier is not provided)
|
|
236
240
|
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
|
237
241
|
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
|
238
242
|
to avoid 404s during discovery when mounting under a path.
|
|
@@ -268,6 +272,19 @@ class OIDCProxy(OAuthProxy):
|
|
|
268
272
|
if not base_url:
|
|
269
273
|
raise ValueError("Missing required base URL")
|
|
270
274
|
|
|
275
|
+
# Validate that verifier-specific parameters are not used with custom verifier
|
|
276
|
+
if token_verifier is not None:
|
|
277
|
+
if algorithm is not None:
|
|
278
|
+
raise ValueError(
|
|
279
|
+
"Cannot specify 'algorithm' when providing a custom token_verifier. "
|
|
280
|
+
"Configure the algorithm on your token verifier instead."
|
|
281
|
+
)
|
|
282
|
+
if required_scopes is not None:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
"Cannot specify 'required_scopes' when providing a custom token_verifier. "
|
|
285
|
+
"Configure required scopes on your token verifier instead."
|
|
286
|
+
)
|
|
287
|
+
|
|
271
288
|
if isinstance(config_url, str):
|
|
272
289
|
config_url = AnyHttpUrl(config_url)
|
|
273
290
|
|
|
@@ -287,12 +304,14 @@ class OIDCProxy(OAuthProxy):
|
|
|
287
304
|
else None
|
|
288
305
|
)
|
|
289
306
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
307
|
+
# Use custom verifier if provided, otherwise create default JWTVerifier
|
|
308
|
+
if token_verifier is None:
|
|
309
|
+
token_verifier = self.get_token_verifier(
|
|
310
|
+
algorithm=algorithm,
|
|
311
|
+
audience=audience,
|
|
312
|
+
required_scopes=required_scopes,
|
|
313
|
+
timeout_seconds=timeout_seconds,
|
|
314
|
+
)
|
|
296
315
|
|
|
297
316
|
init_kwargs = {
|
|
298
317
|
"upstream_authorization_endpoint": str(
|
|
@@ -321,7 +340,7 @@ class OIDCProxy(OAuthProxy):
|
|
|
321
340
|
init_kwargs["extra_authorize_params"] = extra_params
|
|
322
341
|
init_kwargs["extra_token_params"] = extra_params
|
|
323
342
|
|
|
324
|
-
super().__init__(**init_kwargs)
|
|
343
|
+
super().__init__(**init_kwargs) # ty: ignore[invalid-argument-type]
|
|
325
344
|
|
|
326
345
|
def get_oidc_configuration(
|
|
327
346
|
self,
|
|
@@ -46,6 +46,7 @@ class AzureProviderSettings(BaseSettings):
|
|
|
46
46
|
additional_authorize_scopes: list[str] | None = None
|
|
47
47
|
allowed_client_redirect_uris: list[str] | None = None
|
|
48
48
|
jwt_signing_key: str | None = None
|
|
49
|
+
base_authority: str = "login.microsoftonline.com"
|
|
49
50
|
|
|
50
51
|
@field_validator("required_scopes", mode="before")
|
|
51
52
|
@classmethod
|
|
@@ -93,6 +94,7 @@ class AzureProvider(OAuthProxy):
|
|
|
93
94
|
from fastmcp import FastMCP
|
|
94
95
|
from fastmcp.server.auth.providers.azure import AzureProvider
|
|
95
96
|
|
|
97
|
+
# Standard Azure (Public Cloud)
|
|
96
98
|
auth = AzureProvider(
|
|
97
99
|
client_id="your-client-id",
|
|
98
100
|
client_secret="your-client-secret",
|
|
@@ -103,6 +105,16 @@ class AzureProvider(OAuthProxy):
|
|
|
103
105
|
# identifier_uri defaults to api://{client_id}
|
|
104
106
|
)
|
|
105
107
|
|
|
108
|
+
# Azure Government
|
|
109
|
+
auth_gov = AzureProvider(
|
|
110
|
+
client_id="your-client-id",
|
|
111
|
+
client_secret="your-client-secret",
|
|
112
|
+
tenant_id="your-tenant-id",
|
|
113
|
+
required_scopes=["read", "write"],
|
|
114
|
+
base_authority="login.microsoftonline.us", # Override for Azure Gov
|
|
115
|
+
base_url="http://localhost:8000",
|
|
116
|
+
)
|
|
117
|
+
|
|
106
118
|
mcp = FastMCP("My App", auth=auth)
|
|
107
119
|
```
|
|
108
120
|
"""
|
|
@@ -123,6 +135,7 @@ class AzureProvider(OAuthProxy):
|
|
|
123
135
|
client_storage: AsyncKeyValue | None = None,
|
|
124
136
|
jwt_signing_key: str | bytes | NotSetT = NotSet,
|
|
125
137
|
require_authorization_consent: bool = True,
|
|
138
|
+
base_authority: str | NotSetT = NotSet,
|
|
126
139
|
) -> None:
|
|
127
140
|
"""Initialize Azure OAuth provider.
|
|
128
141
|
|
|
@@ -138,6 +151,8 @@ class AzureProvider(OAuthProxy):
|
|
|
138
151
|
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
|
139
152
|
to avoid 404s during discovery when mounting under a path.
|
|
140
153
|
redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback")
|
|
154
|
+
base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
|
|
155
|
+
For Azure Government, use "login.microsoftonline.us".
|
|
141
156
|
required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]).
|
|
142
157
|
- Automatically prefixed with identifier_uri during initialization
|
|
143
158
|
- Validated on all tokens
|
|
@@ -180,6 +195,7 @@ class AzureProvider(OAuthProxy):
|
|
|
180
195
|
"additional_authorize_scopes": additional_authorize_scopes,
|
|
181
196
|
"allowed_client_redirect_uris": allowed_client_redirect_uris,
|
|
182
197
|
"jwt_signing_key": jwt_signing_key,
|
|
198
|
+
"base_authority": base_authority,
|
|
183
199
|
}.items()
|
|
184
200
|
if v is not NotSet
|
|
185
201
|
}
|
|
@@ -218,9 +234,10 @@ class AzureProvider(OAuthProxy):
|
|
|
218
234
|
tenant_id_final = settings.tenant_id
|
|
219
235
|
|
|
220
236
|
# Always validate tokens against the app's API client ID using JWT
|
|
221
|
-
|
|
237
|
+
base_authority_final = settings.base_authority
|
|
238
|
+
issuer = f"https://{base_authority_final}/{tenant_id_final}/v2.0"
|
|
222
239
|
jwks_uri = (
|
|
223
|
-
f"https://
|
|
240
|
+
f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys"
|
|
224
241
|
)
|
|
225
242
|
|
|
226
243
|
# Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes
|
|
@@ -239,10 +256,10 @@ class AzureProvider(OAuthProxy):
|
|
|
239
256
|
|
|
240
257
|
# Build Azure OAuth endpoints with tenant
|
|
241
258
|
authorization_endpoint = (
|
|
242
|
-
f"https://
|
|
259
|
+
f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/authorize"
|
|
243
260
|
)
|
|
244
261
|
token_endpoint = (
|
|
245
|
-
f"https://
|
|
262
|
+
f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/token"
|
|
246
263
|
)
|
|
247
264
|
|
|
248
265
|
# Initialize OAuth proxy with Azure endpoints
|
|
@@ -262,11 +279,15 @@ class AzureProvider(OAuthProxy):
|
|
|
262
279
|
require_authorization_consent=require_authorization_consent,
|
|
263
280
|
)
|
|
264
281
|
|
|
282
|
+
authority_info = ""
|
|
283
|
+
if base_authority_final != "login.microsoftonline.com":
|
|
284
|
+
authority_info = f" using authority {base_authority_final}"
|
|
265
285
|
logger.info(
|
|
266
|
-
"Initialized Azure OAuth provider for client %s with tenant %s%s",
|
|
286
|
+
"Initialized Azure OAuth provider for client %s with tenant %s%s%s",
|
|
267
287
|
settings.client_id,
|
|
268
288
|
tenant_id_final,
|
|
269
289
|
f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "",
|
|
290
|
+
authority_info,
|
|
270
291
|
)
|
|
271
292
|
|
|
272
293
|
async def authorize(
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Debug token verifier for testing and special cases.
|
|
2
|
+
|
|
3
|
+
This module provides a flexible token verifier that delegates validation
|
|
4
|
+
to a custom callable. Useful for testing, development, or scenarios where
|
|
5
|
+
standard verification isn't possible (like opaque tokens without introspection).
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
```python
|
|
9
|
+
from fastmcp import FastMCP
|
|
10
|
+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
|
|
11
|
+
|
|
12
|
+
# Accept all tokens (default - useful for testing)
|
|
13
|
+
auth = DebugTokenVerifier()
|
|
14
|
+
|
|
15
|
+
# Custom sync validation logic
|
|
16
|
+
auth = DebugTokenVerifier(validate=lambda token: token.startswith("valid-"))
|
|
17
|
+
|
|
18
|
+
# Custom async validation logic
|
|
19
|
+
async def check_cache(token: str) -> bool:
|
|
20
|
+
return await redis.exists(f"token:{token}")
|
|
21
|
+
|
|
22
|
+
auth = DebugTokenVerifier(validate=check_cache)
|
|
23
|
+
|
|
24
|
+
mcp = FastMCP("My Server", auth=auth)
|
|
25
|
+
```
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import inspect
|
|
31
|
+
from collections.abc import Awaitable, Callable
|
|
32
|
+
|
|
33
|
+
from fastmcp.server.auth import TokenVerifier
|
|
34
|
+
from fastmcp.server.auth.auth import AccessToken
|
|
35
|
+
from fastmcp.utilities.logging import get_logger
|
|
36
|
+
|
|
37
|
+
logger = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DebugTokenVerifier(TokenVerifier):
|
|
41
|
+
"""Token verifier with custom validation logic.
|
|
42
|
+
|
|
43
|
+
This verifier delegates token validation to a user-provided callable.
|
|
44
|
+
By default, it accepts all non-empty tokens (useful for testing).
|
|
45
|
+
|
|
46
|
+
Use cases:
|
|
47
|
+
- Testing: Accept any token without real verification
|
|
48
|
+
- Development: Custom validation logic for prototyping
|
|
49
|
+
- Opaque tokens: When you have tokens with no introspection endpoint
|
|
50
|
+
|
|
51
|
+
WARNING: This bypasses standard security checks. Only use in controlled
|
|
52
|
+
environments or when you understand the security implications.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
validate: Callable[[str], bool]
|
|
58
|
+
| Callable[[str], Awaitable[bool]] = lambda token: True,
|
|
59
|
+
client_id: str = "debug-client",
|
|
60
|
+
scopes: list[str] | None = None,
|
|
61
|
+
required_scopes: list[str] | None = None,
|
|
62
|
+
):
|
|
63
|
+
"""Initialize the debug token verifier.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
validate: Callable that takes a token string and returns True if valid.
|
|
67
|
+
Can be sync or async. Default accepts all tokens.
|
|
68
|
+
client_id: Client ID to assign to validated tokens
|
|
69
|
+
scopes: Scopes to assign to validated tokens
|
|
70
|
+
required_scopes: Required scopes (inherited from TokenVerifier base class)
|
|
71
|
+
"""
|
|
72
|
+
super().__init__(required_scopes=required_scopes)
|
|
73
|
+
self.validate = validate
|
|
74
|
+
self.client_id = client_id
|
|
75
|
+
self.scopes = scopes or []
|
|
76
|
+
|
|
77
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
78
|
+
"""Verify token using custom validation logic.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
token: The token string to validate
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
AccessToken if validation succeeds, None otherwise
|
|
85
|
+
"""
|
|
86
|
+
# Reject empty tokens
|
|
87
|
+
if not token or not token.strip():
|
|
88
|
+
logger.debug("Rejecting empty token")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
# Call validation function and await if result is awaitable
|
|
93
|
+
result = self.validate(token)
|
|
94
|
+
if inspect.isawaitable(result):
|
|
95
|
+
is_valid = await result
|
|
96
|
+
else:
|
|
97
|
+
is_valid = result
|
|
98
|
+
|
|
99
|
+
if not is_valid:
|
|
100
|
+
logger.debug("Token validation failed: callable returned False")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Return valid AccessToken
|
|
104
|
+
return AccessToken(
|
|
105
|
+
token=token,
|
|
106
|
+
client_id=self.client_id,
|
|
107
|
+
scopes=self.scopes,
|
|
108
|
+
expires_at=None, # No expiration
|
|
109
|
+
claims={"token": token}, # Store original token in claims
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.debug("Token validation error: %s", e, exc_info=True)
|
|
114
|
+
return None
|