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.
@@ -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
- # Need to allow form-action for form submission
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}"
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
- - _access_tokens, _refresh_tokens: Token storage for revocation
618
- - Token relationship mappings for cleanup and rotation
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
- # Local state for token bookkeeping only (no client caching)
878
- self._access_tokens: dict[str, AccessToken] = {}
879
- self._refresh_tokens: dict[str, RefreshToken] = {}
880
-
881
- # Token relation mappings for cleanup
882
- self._access_to_refresh: dict[str, str] = {}
883
- self._refresh_to_access: dict[str, str] = {}
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 FastMCP access token for MCP framework validation
1237
- self._access_tokens[fastmcp_access_token] = AccessToken(
1238
- token=fastmcp_access_token,
1239
- client_id=client.client_id,
1240
- scopes=authorization_code.scopes,
1241
- expires_at=int(time.time() + expires_in),
1242
- )
1243
-
1244
- # Store FastMCP refresh token if provided
1245
- if fastmcp_refresh_token:
1246
- self._refresh_tokens[fastmcp_refresh_token] = RefreshToken(
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 local storage."""
1282
- return self._refresh_tokens.get(refresh_token)
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(scopes) if scopes else None,
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
- # Update local token tracking
1449
- self._access_tokens[new_fastmcp_access] = AccessToken(
1450
- token=new_fastmcp_access,
1451
- client_id=client.client_id,
1452
- scopes=scopes,
1453
- expires_at=int(time.time() + new_expires_in),
1454
- )
1455
- self._refresh_tokens[new_fastmcp_refresh] = RefreshToken(
1456
- token=new_fastmcp_refresh,
1457
- client_id=client.client_id,
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
- # Update token relationship mappings
1463
- self._access_to_refresh[new_fastmcp_access] = new_fastmcp_refresh
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
- Removes tokens from local storage and attempts to revoke them with
1553
- the upstream server if a revocation endpoint is configured.
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
- # Clean up local token storage
1556
- if isinstance(token, AccessToken):
1557
- self._access_tokens.pop(token.token, None)
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
- extra_params = {"audience": audience}
340
- init_kwargs["extra_authorize_params"] = extra_params
341
- init_kwargs["extra_token_params"] = extra_params
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 returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes
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=settings.required_scopes, # Unprefixed scopes for validation
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