From 9e28da11f2e9c3be32a9ae11111b17d7774e846d Mon Sep 17 00:00:00 2001 From: Nestor Qin Date: Wed, 19 Nov 2025 19:02:31 +0000 Subject: [PATCH] fix Azure token refresh issue --- src/fastmcp/server/auth/oauth_proxy.py | 24 ++++++++++- src/fastmcp/server/auth/providers/azure.py | 50 +++++++++++++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index f8babfa2e..c526ddcd3 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -1273,6 +1273,23 @@ class OAuthProxy(OAuthProvider): # Refresh Token Flow # ------------------------------------------------------------------------- + def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]: + """Prepare scopes for upstream token refresh request. + + Override this method to transform scopes before sending to upstream provider. + For example, Azure needs to prefix scopes and add additional Graph scopes. + + The scopes parameter represents what should be stored in the RefreshToken. + This method returns what should be sent to the upstream provider. + + Args: + scopes: Base scopes that will be stored in RefreshToken + + Returns: + Scopes to send to upstream provider (may be transformed/augmented) + """ + return scopes + async def load_refresh_token( self, client: OAuthClientInformationFull, @@ -1333,12 +1350,17 @@ class OAuthProxy(OAuthProvider): timeout=HTTP_TIMEOUT_SECONDS, ) + # Allow child classes to transform scopes before sending to upstream + # This enables provider-specific scope formatting (e.g., Azure prefixing) + # while keeping original scopes in storage + upstream_scopes = self._prepare_scopes_for_upstream_refresh(scopes) + try: logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8]) token_response: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc] url=self._upstream_token_endpoint, refresh_token=upstream_token_set.refresh_token, - scope=" ".join(scopes) if scopes else None, + scope=" ".join(upstream_scopes) if upstream_scopes else None, **self._extra_token_params, ) logger.debug("Successfully refreshed upstream token") diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 4c7cb8359..e206ed6ec 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -6,9 +6,11 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any from key_value.aio.protocols import AsyncKeyValue +from mcp.server.auth.provider import AuthorizationParams +from mcp.shared.auth import OAuthClientInformationFull from pydantic import SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -19,10 +21,6 @@ from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT -if TYPE_CHECKING: - from mcp.server.auth.provider import AuthorizationParams - from mcp.shared.auth import OAuthClientInformationFull - logger = get_logger(__name__) @@ -358,3 +356,45 @@ class AzureProvider(OAuthProxy): # Let parent build the URL with prefixed scopes return super()._build_upstream_authorize_url(txn_id, modified_transaction) + + def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]: + """Prepare scopes for Azure token refresh. + + Azure requires: + 1. Fully-qualified custom scopes (e.g., "api://xxx/read" not "read") + 2. Microsoft Graph scopes (e.g., "User.Read", "openid") sent as-is + 3. Additional scopes from provider config (additional_authorize_scopes) + + This method transforms base client scopes for Azure while keeping them + unprefixed in storage to prevent accumulation. + + Args: + scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"]) + + Returns: + Scopes formatted for Azure token endpoint + """ + logger.debug(f"Base scopes from storage: {scopes}") + + # Filter out any additional_authorize_scopes that may have been stored + # (they shouldn't be in storage, but clean them up if they are) + additional_scopes_set = set(self.additional_authorize_scopes or []) + base_scopes = [s for s in scopes if s not in additional_scopes_set] + + # Prefix base scopes with identifier_uri for Azure + prefixed_scopes = [] + for scope in base_scopes: + if "://" in scope or "/" in scope: + # Already fully-qualified (e.g., "api://xxx/read") + prefixed_scopes.append(scope) + else: + # Unprefixed client scope - prefix with identifier_uri + prefixed_scopes.append(f"{self.identifier_uri}/{scope}") + + # Add additional scopes (Graph + OIDC) for the Azure request + # These are NOT stored in RefreshToken, only sent to Azure + if self.additional_authorize_scopes: + prefixed_scopes.extend(self.additional_authorize_scopes) + + logger.debug(f"Scopes for Azure token endpoint: {prefixed_scopes}") + return prefixed_scopes