fastmcp 2.13.1__py3-none-any.whl → 2.13.3__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/server/auth/oauth_proxy.py +152 -85
- fastmcp/server/auth/oidc_proxy.py +31 -3
- fastmcp/server/auth/providers/azure.py +96 -10
- fastmcp/server/auth/providers/descope.py +82 -23
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/google.py +18 -0
- fastmcp/server/auth/providers/scalekit.py +76 -17
- fastmcp/server/dependencies.py +26 -4
- fastmcp/server/proxy.py +10 -0
- fastmcp/server/server.py +4 -1
- fastmcp/tools/tool.py +19 -1
- fastmcp/tools/tool_transform.py +3 -1
- fastmcp/utilities/types.py +49 -0
- fastmcp/utilities/ui.py +11 -2
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/METADATA +4 -3
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/RECORD +19 -18
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/WHEEL +1 -1
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.1.dist-info → fastmcp-2.13.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -179,6 +179,28 @@ class JTIMapping(BaseModel):
|
|
|
179
179
|
created_at: float # Unix timestamp
|
|
180
180
|
|
|
181
181
|
|
|
182
|
+
class RefreshTokenMetadata(BaseModel):
|
|
183
|
+
"""Metadata for a refresh token, stored keyed by token hash.
|
|
184
|
+
|
|
185
|
+
We store only metadata (not the token itself) for security - if storage
|
|
186
|
+
is compromised, attackers get hashes they can't reverse into usable tokens.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
client_id: str
|
|
190
|
+
scopes: list[str]
|
|
191
|
+
expires_at: int | None = None
|
|
192
|
+
created_at: float
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _hash_token(token: str) -> str:
|
|
196
|
+
"""Hash a token for secure storage lookup.
|
|
197
|
+
|
|
198
|
+
Uses SHA-256 to create a one-way hash. The original token cannot be
|
|
199
|
+
recovered from the hash, providing defense in depth if storage is compromised.
|
|
200
|
+
"""
|
|
201
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
202
|
+
|
|
203
|
+
|
|
182
204
|
class ProxyDCRClient(OAuthClientInformationFull):
|
|
183
205
|
"""Client for DCR proxy with configurable redirect URI validation.
|
|
184
206
|
|
|
@@ -246,8 +268,16 @@ def create_consent_html(
|
|
|
246
268
|
server_icon_url: str | None = None,
|
|
247
269
|
server_website_url: str | None = None,
|
|
248
270
|
client_website_url: str | None = None,
|
|
271
|
+
csp_policy: str | None = None,
|
|
249
272
|
) -> str:
|
|
250
|
-
"""Create a styled HTML consent page for OAuth authorization requests.
|
|
273
|
+
"""Create a styled HTML consent page for OAuth authorization requests.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
csp_policy: Content Security Policy override.
|
|
277
|
+
If None, uses the built-in CSP policy with appropriate directives.
|
|
278
|
+
If empty string "", disables CSP entirely (no meta tag is rendered).
|
|
279
|
+
If a non-empty string, uses that as the CSP policy value.
|
|
280
|
+
"""
|
|
251
281
|
import html as html_module
|
|
252
282
|
|
|
253
283
|
client_display = html_module.escape(client_name or client_id)
|
|
@@ -368,20 +398,25 @@ def create_consent_html(
|
|
|
368
398
|
+ TOOLTIP_STYLES
|
|
369
399
|
)
|
|
370
400
|
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
401
|
+
# Determine CSP policy to use
|
|
402
|
+
# If csp_policy is None, build the default CSP policy
|
|
403
|
+
# If csp_policy is empty string, CSP will be disabled entirely in create_page
|
|
404
|
+
# If csp_policy is a non-empty string, use it as-is
|
|
405
|
+
if csp_policy is None:
|
|
406
|
+
# Need to allow form-action for form submission
|
|
407
|
+
# Chrome requires explicit scheme declarations in CSP form-action when redirect chains
|
|
408
|
+
# end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme.
|
|
409
|
+
parsed_redirect = urlparse(redirect_uri)
|
|
410
|
+
redirect_scheme = parsed_redirect.scheme.lower()
|
|
411
|
+
|
|
412
|
+
# Build form-action directive with standard schemes plus custom protocol if present
|
|
413
|
+
form_action_schemes = ["https:", "http:"]
|
|
414
|
+
if redirect_scheme and redirect_scheme not in ("http", "https"):
|
|
415
|
+
# Custom protocol scheme (e.g., cursor:, vscode:, etc.)
|
|
416
|
+
form_action_schemes.append(f"{redirect_scheme}:")
|
|
417
|
+
|
|
418
|
+
form_action_directive = " ".join(form_action_schemes)
|
|
419
|
+
csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}"
|
|
385
420
|
|
|
386
421
|
return create_page(
|
|
387
422
|
content=content,
|
|
@@ -611,14 +646,18 @@ class OAuthProxy(OAuthProvider):
|
|
|
611
646
|
|
|
612
647
|
State Management
|
|
613
648
|
---------------
|
|
614
|
-
The proxy maintains minimal but crucial state:
|
|
649
|
+
The proxy maintains minimal but crucial state via pluggable storage (client_storage):
|
|
615
650
|
- _oauth_transactions: Active authorization flows with client context
|
|
616
651
|
- _client_codes: Authorization codes with PKCE challenges and upstream tokens
|
|
617
|
-
-
|
|
618
|
-
-
|
|
652
|
+
- _jti_mapping_store: Maps FastMCP token JTIs to upstream token IDs
|
|
653
|
+
- _refresh_token_store: Refresh token metadata (keyed by token hash)
|
|
654
|
+
|
|
655
|
+
All state is stored in the configured client_storage backend (Redis, disk, etc.)
|
|
656
|
+
enabling horizontal scaling across multiple instances.
|
|
619
657
|
|
|
620
658
|
Security Considerations
|
|
621
659
|
----------------------
|
|
660
|
+
- Refresh tokens stored by hash only (defense in depth if storage compromised)
|
|
622
661
|
- PKCE enforced end-to-end (client to proxy, proxy to upstream)
|
|
623
662
|
- Authorization codes are single-use with short expiry
|
|
624
663
|
- Transaction IDs are cryptographically random
|
|
@@ -672,6 +711,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
672
711
|
jwt_signing_key: str | bytes | None = None,
|
|
673
712
|
# Consent screen configuration
|
|
674
713
|
require_authorization_consent: bool = True,
|
|
714
|
+
consent_csp_policy: str | None = None,
|
|
675
715
|
):
|
|
676
716
|
"""Initialize the OAuth proxy provider.
|
|
677
717
|
|
|
@@ -715,6 +755,12 @@ class OAuthProxy(OAuthProvider):
|
|
|
715
755
|
When True, users see a consent screen before being redirected to the upstream IdP.
|
|
716
756
|
When False, authorization proceeds directly without user confirmation.
|
|
717
757
|
SECURITY WARNING: Only disable for local development or testing environments.
|
|
758
|
+
consent_csp_policy: Content Security Policy for the consent page.
|
|
759
|
+
If None (default), uses the built-in CSP policy with appropriate directives.
|
|
760
|
+
If empty string "", disables CSP entirely (no meta tag is rendered).
|
|
761
|
+
If a non-empty string, uses that as the CSP policy value.
|
|
762
|
+
This allows organizations with their own CSP policies to override or disable
|
|
763
|
+
the built-in CSP directives.
|
|
718
764
|
"""
|
|
719
765
|
|
|
720
766
|
# Always enable DCR since we implement it locally for MCP clients
|
|
@@ -775,6 +821,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
775
821
|
|
|
776
822
|
# Consent screen configuration
|
|
777
823
|
self._require_authorization_consent: bool = require_authorization_consent
|
|
824
|
+
self._consent_csp_policy: str | None = consent_csp_policy
|
|
778
825
|
if not require_authorization_consent:
|
|
779
826
|
logger.warning(
|
|
780
827
|
"Authorization consent screen disabled - only use for local development or testing. "
|
|
@@ -874,13 +921,17 @@ class OAuthProxy(OAuthProvider):
|
|
|
874
921
|
raise_on_validation_error=True,
|
|
875
922
|
)
|
|
876
923
|
|
|
877
|
-
#
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
924
|
+
# Refresh token metadata storage, keyed by token hash for security.
|
|
925
|
+
# We only store metadata (not the token itself) - if storage is compromised,
|
|
926
|
+
# attackers get hashes they can't reverse into usable tokens.
|
|
927
|
+
self._refresh_token_store: PydanticAdapter[RefreshTokenMetadata] = (
|
|
928
|
+
PydanticAdapter[RefreshTokenMetadata](
|
|
929
|
+
key_value=self._client_storage,
|
|
930
|
+
pydantic_model=RefreshTokenMetadata,
|
|
931
|
+
default_collection="mcp-refresh-tokens",
|
|
932
|
+
raise_on_validation_error=True,
|
|
933
|
+
)
|
|
934
|
+
)
|
|
884
935
|
|
|
885
936
|
# Use the provided token validator
|
|
886
937
|
self._token_validator: TokenVerifier = token_verifier
|
|
@@ -1233,25 +1284,18 @@ class OAuthProxy(OAuthProvider):
|
|
|
1233
1284
|
ttl=60 * 60 * 24 * 30, # Auto-expire with refresh token (30 days)
|
|
1234
1285
|
)
|
|
1235
1286
|
|
|
1236
|
-
# Store
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
token=fastmcp_refresh_token,
|
|
1248
|
-
client_id=client.client_id,
|
|
1249
|
-
scopes=authorization_code.scopes,
|
|
1250
|
-
expires_at=None,
|
|
1287
|
+
# Store refresh token metadata (keyed by hash for security)
|
|
1288
|
+
if fastmcp_refresh_token and refresh_expires_in:
|
|
1289
|
+
await self._refresh_token_store.put(
|
|
1290
|
+
key=_hash_token(fastmcp_refresh_token),
|
|
1291
|
+
value=RefreshTokenMetadata(
|
|
1292
|
+
client_id=client.client_id,
|
|
1293
|
+
scopes=authorization_code.scopes,
|
|
1294
|
+
expires_at=int(time.time()) + refresh_expires_in,
|
|
1295
|
+
created_at=time.time(),
|
|
1296
|
+
),
|
|
1297
|
+
ttl=refresh_expires_in,
|
|
1251
1298
|
)
|
|
1252
|
-
# Maintain token relationships for cleanup
|
|
1253
|
-
self._access_to_refresh[fastmcp_access_token] = fastmcp_refresh_token
|
|
1254
|
-
self._refresh_to_access[fastmcp_refresh_token] = fastmcp_access_token
|
|
1255
1299
|
|
|
1256
1300
|
logger.debug(
|
|
1257
1301
|
"Issued FastMCP tokens for client=%s (access_jti=%s, refresh_jti=%s)",
|
|
@@ -1273,13 +1317,51 @@ class OAuthProxy(OAuthProvider):
|
|
|
1273
1317
|
# Refresh Token Flow
|
|
1274
1318
|
# -------------------------------------------------------------------------
|
|
1275
1319
|
|
|
1320
|
+
def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
|
|
1321
|
+
"""Prepare scopes for upstream token refresh request.
|
|
1322
|
+
|
|
1323
|
+
Override this method to transform scopes before sending to upstream provider.
|
|
1324
|
+
For example, Azure needs to prefix scopes and add additional Graph scopes.
|
|
1325
|
+
|
|
1326
|
+
The scopes parameter represents what should be stored in the RefreshToken.
|
|
1327
|
+
This method returns what should be sent to the upstream provider.
|
|
1328
|
+
|
|
1329
|
+
Args:
|
|
1330
|
+
scopes: Base scopes that will be stored in RefreshToken
|
|
1331
|
+
|
|
1332
|
+
Returns:
|
|
1333
|
+
Scopes to send to upstream provider (may be transformed/augmented)
|
|
1334
|
+
"""
|
|
1335
|
+
return scopes
|
|
1336
|
+
|
|
1276
1337
|
async def load_refresh_token(
|
|
1277
1338
|
self,
|
|
1278
1339
|
client: OAuthClientInformationFull,
|
|
1279
1340
|
refresh_token: str,
|
|
1280
1341
|
) -> RefreshToken | None:
|
|
1281
|
-
"""Load refresh token from
|
|
1282
|
-
|
|
1342
|
+
"""Load refresh token metadata from distributed storage.
|
|
1343
|
+
|
|
1344
|
+
Looks up by token hash and reconstructs the RefreshToken object.
|
|
1345
|
+
Validates that the token belongs to the requesting client.
|
|
1346
|
+
"""
|
|
1347
|
+
token_hash = _hash_token(refresh_token)
|
|
1348
|
+
metadata = await self._refresh_token_store.get(key=token_hash)
|
|
1349
|
+
if not metadata:
|
|
1350
|
+
return None
|
|
1351
|
+
# Verify token belongs to this client (prevents cross-client token usage)
|
|
1352
|
+
if metadata.client_id != client.client_id:
|
|
1353
|
+
logger.warning(
|
|
1354
|
+
"Refresh token client_id mismatch: expected %s, got %s",
|
|
1355
|
+
client.client_id,
|
|
1356
|
+
metadata.client_id,
|
|
1357
|
+
)
|
|
1358
|
+
return None
|
|
1359
|
+
return RefreshToken(
|
|
1360
|
+
token=refresh_token,
|
|
1361
|
+
client_id=metadata.client_id,
|
|
1362
|
+
scopes=metadata.scopes,
|
|
1363
|
+
expires_at=metadata.expires_at,
|
|
1364
|
+
)
|
|
1283
1365
|
|
|
1284
1366
|
async def exchange_refresh_token(
|
|
1285
1367
|
self,
|
|
@@ -1333,12 +1415,17 @@ class OAuthProxy(OAuthProvider):
|
|
|
1333
1415
|
timeout=HTTP_TIMEOUT_SECONDS,
|
|
1334
1416
|
)
|
|
1335
1417
|
|
|
1418
|
+
# Allow child classes to transform scopes before sending to upstream
|
|
1419
|
+
# This enables provider-specific scope formatting (e.g., Azure prefixing)
|
|
1420
|
+
# while keeping original scopes in storage
|
|
1421
|
+
upstream_scopes = self._prepare_scopes_for_upstream_refresh(scopes)
|
|
1422
|
+
|
|
1336
1423
|
try:
|
|
1337
1424
|
logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8])
|
|
1338
1425
|
token_response: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc]
|
|
1339
1426
|
url=self._upstream_token_endpoint,
|
|
1340
1427
|
refresh_token=upstream_token_set.refresh_token,
|
|
1341
|
-
scope=" ".join(
|
|
1428
|
+
scope=" ".join(upstream_scopes) if upstream_scopes else None,
|
|
1342
1429
|
**self._extra_token_params,
|
|
1343
1430
|
)
|
|
1344
1431
|
logger.debug("Successfully refreshed upstream token")
|
|
@@ -1445,30 +1532,20 @@ class OAuthProxy(OAuthProvider):
|
|
|
1445
1532
|
"Rotated refresh token (old JTI invalidated - one-time use enforced)"
|
|
1446
1533
|
)
|
|
1447
1534
|
|
|
1448
|
-
#
|
|
1449
|
-
self.
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
scopes=scopes,
|
|
1459
|
-
expires_at=None,
|
|
1535
|
+
# Store new refresh token metadata (keyed by hash)
|
|
1536
|
+
await self._refresh_token_store.put(
|
|
1537
|
+
key=_hash_token(new_fastmcp_refresh),
|
|
1538
|
+
value=RefreshTokenMetadata(
|
|
1539
|
+
client_id=client.client_id,
|
|
1540
|
+
scopes=scopes,
|
|
1541
|
+
expires_at=int(time.time()) + refresh_ttl,
|
|
1542
|
+
created_at=time.time(),
|
|
1543
|
+
),
|
|
1544
|
+
ttl=refresh_ttl,
|
|
1460
1545
|
)
|
|
1461
1546
|
|
|
1462
|
-
#
|
|
1463
|
-
self.
|
|
1464
|
-
self._refresh_to_access[new_fastmcp_refresh] = new_fastmcp_access
|
|
1465
|
-
|
|
1466
|
-
# Clean up old token from in-memory tracking
|
|
1467
|
-
self._refresh_tokens.pop(refresh_token.token, None)
|
|
1468
|
-
old_access = self._refresh_to_access.pop(refresh_token.token, None)
|
|
1469
|
-
if old_access:
|
|
1470
|
-
self._access_tokens.pop(old_access, None)
|
|
1471
|
-
self._access_to_refresh.pop(old_access, None)
|
|
1547
|
+
# Delete old refresh token (by hash)
|
|
1548
|
+
await self._refresh_token_store.delete(key=_hash_token(refresh_token.token))
|
|
1472
1549
|
|
|
1473
1550
|
logger.info(
|
|
1474
1551
|
"Issued new FastMCP tokens (rotated refresh) for client=%s (access_jti=%s, refresh_jti=%s)",
|
|
@@ -1549,24 +1626,13 @@ class OAuthProxy(OAuthProvider):
|
|
|
1549
1626
|
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
|
1550
1627
|
"""Revoke token locally and with upstream server if supported.
|
|
1551
1628
|
|
|
1552
|
-
|
|
1553
|
-
|
|
1629
|
+
For refresh tokens, removes from local storage by hash.
|
|
1630
|
+
For all tokens, attempts upstream revocation if endpoint is configured.
|
|
1631
|
+
Access token JTI mappings expire via TTL.
|
|
1554
1632
|
"""
|
|
1555
|
-
#
|
|
1556
|
-
if isinstance(token,
|
|
1557
|
-
self.
|
|
1558
|
-
# Also remove associated refresh token
|
|
1559
|
-
paired_refresh = self._access_to_refresh.pop(token.token, None)
|
|
1560
|
-
if paired_refresh:
|
|
1561
|
-
self._refresh_tokens.pop(paired_refresh, None)
|
|
1562
|
-
self._refresh_to_access.pop(paired_refresh, None)
|
|
1563
|
-
else: # RefreshToken
|
|
1564
|
-
self._refresh_tokens.pop(token.token, None)
|
|
1565
|
-
# Also remove associated access token
|
|
1566
|
-
paired_access = self._refresh_to_access.pop(token.token, None)
|
|
1567
|
-
if paired_access:
|
|
1568
|
-
self._access_tokens.pop(paired_access, None)
|
|
1569
|
-
self._access_to_refresh.pop(paired_access, None)
|
|
1633
|
+
# For refresh tokens, delete from local storage by hash
|
|
1634
|
+
if isinstance(token, RefreshToken):
|
|
1635
|
+
await self._refresh_token_store.delete(key=_hash_token(token.token))
|
|
1570
1636
|
|
|
1571
1637
|
# Attempt upstream revocation if endpoint is configured
|
|
1572
1638
|
if self._upstream_revocation_endpoint:
|
|
@@ -2084,6 +2150,7 @@ class OAuthProxy(OAuthProvider):
|
|
|
2084
2150
|
server_name=server_name,
|
|
2085
2151
|
server_icon_url=server_icon_url,
|
|
2086
2152
|
server_website_url=server_website_url,
|
|
2153
|
+
csp_policy=self._consent_csp_policy,
|
|
2087
2154
|
)
|
|
2088
2155
|
response = create_secure_html_response(html)
|
|
2089
2156
|
# Store CSRF in cookie with short lifetime
|
|
@@ -222,6 +222,10 @@ class OIDCProxy(OAuthProxy):
|
|
|
222
222
|
token_endpoint_auth_method: str | None = None,
|
|
223
223
|
# Consent screen configuration
|
|
224
224
|
require_authorization_consent: bool = True,
|
|
225
|
+
consent_csp_policy: str | None = None,
|
|
226
|
+
# Extra parameters
|
|
227
|
+
extra_authorize_params: dict[str, str] | None = None,
|
|
228
|
+
extra_token_params: dict[str, str] | None = None,
|
|
225
229
|
) -> None:
|
|
226
230
|
"""Initialize the OIDC proxy provider.
|
|
227
231
|
|
|
@@ -259,6 +263,15 @@ class OIDCProxy(OAuthProxy):
|
|
|
259
263
|
When True, users see a consent screen before being redirected to the upstream IdP.
|
|
260
264
|
When False, authorization proceeds directly without user confirmation.
|
|
261
265
|
SECURITY WARNING: Only disable for local development or testing environments.
|
|
266
|
+
consent_csp_policy: Content Security Policy for the consent page.
|
|
267
|
+
If None (default), uses the built-in CSP policy with appropriate directives.
|
|
268
|
+
If empty string "", disables CSP entirely (no meta tag is rendered).
|
|
269
|
+
If a non-empty string, uses that as the CSP policy value.
|
|
270
|
+
extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint.
|
|
271
|
+
Useful for provider-specific parameters like prompt=consent or access_type=offline.
|
|
272
|
+
Example: {"prompt": "consent", "access_type": "offline"}
|
|
273
|
+
extra_token_params: Additional parameters to forward to the upstream token endpoint.
|
|
274
|
+
Useful for provider-specific parameters during token exchange.
|
|
262
275
|
"""
|
|
263
276
|
if not config_url:
|
|
264
277
|
raise ValueError("Missing required config URL")
|
|
@@ -330,15 +343,30 @@ class OIDCProxy(OAuthProxy):
|
|
|
330
343
|
"jwt_signing_key": jwt_signing_key,
|
|
331
344
|
"token_endpoint_auth_method": token_endpoint_auth_method,
|
|
332
345
|
"require_authorization_consent": require_authorization_consent,
|
|
346
|
+
"consent_csp_policy": consent_csp_policy,
|
|
333
347
|
}
|
|
334
348
|
|
|
335
349
|
if redirect_path:
|
|
336
350
|
init_kwargs["redirect_path"] = redirect_path
|
|
337
351
|
|
|
352
|
+
# Build extra params, merging audience with user-provided params
|
|
353
|
+
# User params override audience if there's a conflict
|
|
354
|
+
final_authorize_params: dict[str, str] = {}
|
|
355
|
+
final_token_params: dict[str, str] = {}
|
|
356
|
+
|
|
338
357
|
if audience:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
358
|
+
final_authorize_params["audience"] = audience
|
|
359
|
+
final_token_params["audience"] = audience
|
|
360
|
+
|
|
361
|
+
if extra_authorize_params:
|
|
362
|
+
final_authorize_params.update(extra_authorize_params)
|
|
363
|
+
if extra_token_params:
|
|
364
|
+
final_token_params.update(extra_token_params)
|
|
365
|
+
|
|
366
|
+
if final_authorize_params:
|
|
367
|
+
init_kwargs["extra_authorize_params"] = final_authorize_params
|
|
368
|
+
if final_token_params:
|
|
369
|
+
init_kwargs["extra_token_params"] = final_token_params
|
|
342
370
|
|
|
343
371
|
super().__init__(**init_kwargs) # ty: ignore[invalid-argument-type]
|
|
344
372
|
|
|
@@ -25,6 +25,11 @@ if TYPE_CHECKING:
|
|
|
25
25
|
|
|
26
26
|
logger = get_logger(__name__)
|
|
27
27
|
|
|
28
|
+
# Standard OIDC scopes that should never be prefixed with identifier_uri.
|
|
29
|
+
# Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc
|
|
30
|
+
# "OIDC scopes are requested as simple string identifiers without resource prefixes"
|
|
31
|
+
OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"})
|
|
32
|
+
|
|
28
33
|
|
|
29
34
|
class AzureProviderSettings(BaseSettings):
|
|
30
35
|
"""Settings for Azure OAuth provider."""
|
|
@@ -240,13 +245,25 @@ class AzureProvider(OAuthProxy):
|
|
|
240
245
|
f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys"
|
|
241
246
|
)
|
|
242
247
|
|
|
243
|
-
# Azure
|
|
248
|
+
# Azure access tokens only include custom API scopes in the `scp` claim,
|
|
249
|
+
# NOT standard OIDC scopes (openid, profile, email, offline_access).
|
|
250
|
+
# Filter out OIDC scopes from validation - they'll still be sent to Azure
|
|
251
|
+
# during authorization (handled by _prefix_scopes_for_azure).
|
|
252
|
+
validation_scopes = None
|
|
253
|
+
if settings.required_scopes:
|
|
254
|
+
validation_scopes = [
|
|
255
|
+
s for s in settings.required_scopes if s not in OIDC_SCOPES
|
|
256
|
+
]
|
|
257
|
+
# If all scopes were OIDC scopes, use None (no scope validation)
|
|
258
|
+
if not validation_scopes:
|
|
259
|
+
validation_scopes = None
|
|
260
|
+
|
|
244
261
|
token_verifier = JWTVerifier(
|
|
245
262
|
jwks_uri=jwks_uri,
|
|
246
263
|
issuer=issuer,
|
|
247
264
|
audience=settings.client_id,
|
|
248
265
|
algorithm="RS256",
|
|
249
|
-
required_scopes=
|
|
266
|
+
required_scopes=validation_scopes, # Only validate non-OIDC scopes
|
|
250
267
|
)
|
|
251
268
|
|
|
252
269
|
# Extract secret string from SecretStr
|
|
@@ -277,6 +294,8 @@ class AzureProvider(OAuthProxy):
|
|
|
277
294
|
client_storage=client_storage,
|
|
278
295
|
jwt_signing_key=settings.jwt_signing_key,
|
|
279
296
|
require_authorization_consent=require_authorization_consent,
|
|
297
|
+
# Advertise full scopes including OIDC (even though we only validate non-OIDC)
|
|
298
|
+
valid_scopes=settings.required_scopes,
|
|
280
299
|
)
|
|
281
300
|
|
|
282
301
|
authority_info = ""
|
|
@@ -327,6 +346,41 @@ class AzureProvider(OAuthProxy):
|
|
|
327
346
|
separator = "&" if "?" in auth_url else "?"
|
|
328
347
|
return f"{auth_url}{separator}prompt=select_account"
|
|
329
348
|
|
|
349
|
+
def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
|
|
350
|
+
"""Prefix unprefixed custom API scopes with identifier_uri for Azure.
|
|
351
|
+
|
|
352
|
+
This helper centralizes the scope prefixing logic used in both
|
|
353
|
+
authorization and token refresh flows.
|
|
354
|
+
|
|
355
|
+
Scopes that are NOT prefixed:
|
|
356
|
+
- Standard OIDC scopes (openid, profile, email, offline_access)
|
|
357
|
+
- Fully-qualified URIs (contain "://")
|
|
358
|
+
- Scopes with path component (contain "/")
|
|
359
|
+
|
|
360
|
+
Note: Microsoft Graph scopes (e.g., User.Read) should be passed via
|
|
361
|
+
`additional_authorize_scopes` or use fully-qualified format
|
|
362
|
+
(e.g., https://graph.microsoft.com/User.Read).
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
scopes: List of scopes, may be prefixed or unprefixed
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
List of scopes with identifier_uri prefix applied where needed
|
|
369
|
+
"""
|
|
370
|
+
prefixed = []
|
|
371
|
+
for scope in scopes:
|
|
372
|
+
if scope in OIDC_SCOPES:
|
|
373
|
+
# Standard OIDC scopes - never prefix
|
|
374
|
+
prefixed.append(scope)
|
|
375
|
+
elif "://" in scope or "/" in scope:
|
|
376
|
+
# Already fully-qualified (e.g., "api://xxx/read" or
|
|
377
|
+
# "https://graph.microsoft.com/User.Read")
|
|
378
|
+
prefixed.append(scope)
|
|
379
|
+
else:
|
|
380
|
+
# Unprefixed custom API scope - prefix with identifier_uri
|
|
381
|
+
prefixed.append(f"{self.identifier_uri}/{scope}")
|
|
382
|
+
return prefixed
|
|
383
|
+
|
|
330
384
|
def _build_upstream_authorize_url(
|
|
331
385
|
self, txn_id: str, transaction: dict[str, Any]
|
|
332
386
|
) -> str:
|
|
@@ -339,14 +393,7 @@ class AzureProvider(OAuthProxy):
|
|
|
339
393
|
unprefixed_scopes = transaction.get("scopes") or self.required_scopes or []
|
|
340
394
|
|
|
341
395
|
# Prefix scopes for Azure authorization request
|
|
342
|
-
prefixed_scopes =
|
|
343
|
-
for scope in unprefixed_scopes:
|
|
344
|
-
if "://" in scope or "/" in scope:
|
|
345
|
-
# Already a full URI or path (e.g., "api://xxx/read" or "User.Read")
|
|
346
|
-
prefixed_scopes.append(scope)
|
|
347
|
-
else:
|
|
348
|
-
# Unprefixed scope name - prefix it with identifier_uri
|
|
349
|
-
prefixed_scopes.append(f"{self.identifier_uri}/{scope}")
|
|
396
|
+
prefixed_scopes = self._prefix_scopes_for_azure(unprefixed_scopes)
|
|
350
397
|
|
|
351
398
|
# Add Microsoft Graph scopes (not validated, not prefixed)
|
|
352
399
|
if self.additional_authorize_scopes:
|
|
@@ -358,3 +405,42 @@ class AzureProvider(OAuthProxy):
|
|
|
358
405
|
|
|
359
406
|
# Let parent build the URL with prefixed scopes
|
|
360
407
|
return super()._build_upstream_authorize_url(txn_id, modified_transaction)
|
|
408
|
+
|
|
409
|
+
def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
|
|
410
|
+
"""Prepare scopes for Azure token refresh.
|
|
411
|
+
|
|
412
|
+
Azure requires:
|
|
413
|
+
1. Fully-qualified custom scopes (e.g., "api://xxx/read" not "read")
|
|
414
|
+
2. Microsoft Graph scopes (e.g., "User.Read", "openid") sent as-is
|
|
415
|
+
3. Additional scopes from provider config (additional_authorize_scopes)
|
|
416
|
+
|
|
417
|
+
This method transforms base client scopes for Azure while keeping them
|
|
418
|
+
unprefixed in storage to prevent accumulation.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"])
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Deduplicated list of scopes formatted for Azure token endpoint
|
|
425
|
+
"""
|
|
426
|
+
logger.debug("Base scopes from storage: %s", scopes)
|
|
427
|
+
|
|
428
|
+
# Filter out any additional_authorize_scopes that may have been stored
|
|
429
|
+
# (they shouldn't be in storage, but clean them up if they are)
|
|
430
|
+
additional_scopes_set = set(self.additional_authorize_scopes or [])
|
|
431
|
+
base_scopes = [s for s in scopes if s not in additional_scopes_set]
|
|
432
|
+
|
|
433
|
+
# Prefix base scopes with identifier_uri for Azure using shared helper
|
|
434
|
+
prefixed_scopes = self._prefix_scopes_for_azure(base_scopes)
|
|
435
|
+
|
|
436
|
+
# Add additional scopes (Graph + OIDC) for the Azure request
|
|
437
|
+
# These are NOT stored in RefreshToken, only sent to Azure
|
|
438
|
+
if self.additional_authorize_scopes:
|
|
439
|
+
prefixed_scopes.extend(self.additional_authorize_scopes)
|
|
440
|
+
|
|
441
|
+
# Deduplicate while preserving order (in case older tokens have duplicates)
|
|
442
|
+
# Use dict.fromkeys() for O(n) deduplication with order preservation
|
|
443
|
+
deduplicated_scopes = list(dict.fromkeys(prefixed_scopes))
|
|
444
|
+
|
|
445
|
+
logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes)
|
|
446
|
+
return deduplicated_scopes
|